Files
akbasic/TODO.md
Andrew Kesterson 9e43acc0b0 Give the characters breakout its bricks as collision geometry
`HITTEST`, `XBRICK` and `YBRICK` are gone -- about forty lines that divided
pixels by cell sizes to recover a grid index, tested the ball's leading edge
rather than its box, and could miss a brick clipped at the corner by seven
pixels' worth of ball. In their place: sixty `SOLID` rectangles registered as the
wall is built, `COLLISION 2, BRICKHIT`, and a fifteen-line handler.

The handler reads better than what it replaces because it asks rather than
derives. `RCOLLISION(1, 1)` is which brick -- the id is the array index plus one,
so nothing is looked up. Fields 2, 3 and 4 are the way out and how far, so the
ball is pushed exactly clear instead of being restored to a remembered `OX#`/`OY#`.
Field 7 is which axis to reverse, which `TESTCELL` in the other game computes by
hand from an overlap rectangle.

`MOVEBAL` loses its two brick calls and its position backup. `KILLBR` retires the
rectangle in the same breath as clearing the array element, so the next frame
cannot hit a brick that is no longer drawn.

**A latent defect in the target prescan had to be fixed first, and `RCOLLISION`
is the first name in the language to reach it.** `src/renumber.c` walks a line
character by character looking for `GOTO`, `GOSUB`, `COLLISION` and the rest, and
checked only the character *after* a match -- so `RCOLLISION(1, 1)` found
`COLLISION` at its second character, read the `(1,` that followed as a handler
line number, and refused the whole program with "branch to line 1, which the
program did not number", naming a line that contains no branch at all. It now
requires a word boundary on both sides. The comment there was already right that
the trailing check protects `GOTOX#`; nothing protected `XGOTO#`.
`tests/unnumbered.c` covers all three shapes and TODO.md section 6 item 42
records it.

The game runs ninety seconds headless with no error line and the attract mode
scores 1320, so bricks are being found and broken through the new path.

Chapter 17 is not updated yet -- that is the other half of section 6 item 39, and
it is a bigger edit than this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 11:17:34 -04:00

203 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

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/reference/ becomes a regression suite rather than a 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:

  1. MAINTENANCE.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. 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 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; 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_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 MAINTENANCE.md.

akbasic owns 512767 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 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 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/reference/ was written against, so changing one means changing golden 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/reference/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, 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, 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/reference/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 reference's own corpus, checked in at tests/reference/. All 41 .bas files are registered as individual CTest cases and byte-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 byte-identical to basicinterpreter@d76162c, and tests/reference/README.md records the provenance, the cost of the drift nobody is watching for now, and the rule that those expectations are never edited to suit this interpreter.

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.

  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.

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 falls back to the keycode when a keystroke carries no composed text, folded to upper case. A worse keyboard is a great deal better than no keyboard, and it means a host that embeds these adaptors and forgets SDL_StartTextInput() gets a usable editor rather than a dead one. Both halves are asserted in tests/akgl_frontend.c, and both were checked by reverting each in turn and watching the test fail.

Worth knowing about the test suite that missed this: every other keyboard test pushes SDL_EVENT_TEXT_INPUT into SDL's queue by hand, which is what a real keyboard produces — once text input has been started. Synthesising the end of a chain cannot test the beginning of it. The new test asserts SDL_TextInputActive() directly for that reason, and the whole frontend suite has been run against a real X11 window as well as the dummy driver.

One limit remains, and it is a choice rather than a gap:

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

A non-ASCII character is accepted by the keyboard and then dropped rather than stored, which is §1.2 rather than the editor: the grid is a byte per cell and a value's string is a fixed 256 bytes, so a multi-byte character has nowhere to go. Dropping it is honest where storing half of it is not.

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 donesrc/runtime_structure.c, tests/structure_verbs.c
B. Housekeeping NEW, CLR, CONT, SWAP, TRON, TROFF, HELP donesrc/runtime_housekeeping.c, tests/housekeeping_verbs.c
B. Housekeeping, deferred RESTORE, RENUMBER donesrc/data.c and src/renumber.c, tests/read_data.c and tests/renumber.c
C. Errors TRAP, RESUME, ER, ERR donesrc/runtime_trap.c, tests/trap_verbs.c. ER/EL are ER#/EL#; see §5
D. Strings/format USING, PUDEF, WIDTH, CHAR donesrc/format.c, src/runtime_format.c, tests/format_verbs.c
E. Console WINDOW, KEY, SLEEP, WAIT, TI donesrc/runtime_console.c, tests/console_verbs.c
F. Disk all 21 donesrc/runtime_disk.c, tests/disk_verbs.c. Five refuse for want of a drive and DIRECTORY for want of an upstream wrapper; see §5
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, SPRSAV, COLLISION donesrc/runtime_sprite.c, src/sprite_akgl.c, tests/sprite_verbs.c. SPRDEF is out of scope; see below
I. Audio PLAY, SOUND, ENVELOPE, VOL, TEMPO donesrc/runtime_audio.c, src/play.c, tests/audio_verbs.c
I. Audio, still gapped FILTER libakgl has no filter stage and SDL3 supplies no primitive; refused with AKBASIC_ERR_DEVICE. SOUND's sweep arguments used to be here and landed with akgl_audio_sweep in libakgl 0.3.0
E. Console input GET, GETKEY, SCNCLR donesrc/runtime_input.c, tests/input_verbs.c
J. Machine SYS, FETCH, STASH donesrc/runtime_machine.c, tests/machine_verbs.c. SYS is refused by name; see §5

RESTORE and RENUMBER are deferred, and neither is a small job.

  • RESTORE needs a DATA pointer, and there isn't one. Done. Every DATA statement is pre-scanned into one flat list before the program runs -- src/data.c, called from akbasic_runtime_set_mode() beside the label prescan -- and READ walks a cursor along it. RESTORE resets the cursor; RESTORE (line) moves it to the first item on or after that line.

    It fixed two defects on the way, both of which were consequences of READ not reading. A DATA line above its READ is now found, where skipping forward from the READ used to run off the end of the program in silence. And the lines between a READ and its DATA now execute, where the skip used to swallow them -- a C128 runs them, because READ takes the next item and execution carries on. The one corpus case has its DATA immediately after its READ, so neither was observable there. tests/read_data.c.

    DATA at run time is now a no-op: it is a declaration, and reaching the statement means walking past it.

  • RENUMBER has to rewrite references or it is worse than useless. Done. src/renumber.c builds the whole old-to-new map first, then rewrites every line -- including lines before the renumbered region, which can branch into it -- and only then moves them.

    The rewrite is textual and understands exactly one thing about the rest of the language: where a string literal starts and stops, so PRINT "GOTO 10" survives. GOTO, GOSUB, RUN, RESTORE and TRAP targets are rewritten, and so is the comma-separated list after ON X GOTO -- for free, since the targets follow the GOTO. COLLISION is special-cased because its handler is its second argument.

    Two deliberate non-rewrites. A target naming a line that does not exist is left alone: GOTO 9999 in a program with no line 9999 is already broken, and inventing a destination would hide that. And THEN/ELSE do not take bare line numbers in this dialect -- the corpus writes THEN GOTO 100 -- so a number after THEN is an expression, not a target.

    A renumbering that would land a moved line on a kept one is refused before anything is written, because losing a line to a renumbering is not recoverable.

    A program written with labels needs none of this. GOTO DONE is unaffected by any renumbering, which is the strongest argument for LABEL there is.

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). SPRDEF joins them on the same reasoning: it is not a programmable verb but an interactive full-screen sprite editor, driven by single keystrokes and its own cursor. A program cannot call it usefully and this interpreter does not own the screen it would take over. The three SPRSAV source forms are what replace it.

The libakgl JSON layer turned out not to be in the way at all. Group H was parked on "check the sprite/actor API before filing", and the concern was that libakgl's sprites are described by JSON documents that would be cumbersome to reach through BASIC. akgl_sprite_load_json() is a thin wrapper over akgl_spritesheet_initialize + akgl_sprite_initialize + writes to public struct fields, and every field it fills from a document — frame list, animation speed, loop flags, state-to-sprite map — is something a Commodore sprite does not have. So src/sprite_akgl.c builds the same four objects directly. The one field that did survive into the verb syntax is the spritesheet's filename: SPRSAV "ship.png", 1 goes through akgl_path_relative() and akgl_spritesheet_initialize(), which is the same pair of calls the JSON loader makes.

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

  • Multiple statements per line Done. akbasic_parser_parse() consumes a run of COLON tokens before each statement and hands the caller a NULL leaf when nothing followed them, which is how an empty statement — a trailing separator, or :: — stays legal rather than becoming an error. tests/parser_commands.c counts statements per line; tests/language/statements/multiple_per_line.bas asserts what actually runs.

    One thing about it needed a decision rather than a transcription, and it is recorded as deviation 31 in §5: BASIC 7.0 scopes everything after THEN to the condition, so IF C THEN A : B must run neither A nor B when C is false. The parser takes exactly one statement per arm, so the rest of the line reaches the statement loop as ordinary statements and the branch has to tell the loop whether to run them.

    A known limitation, not a defect: a whole FOR/NEXT on one line (FOR I# = 1 TO 3 : PRINT I# : NEXT I#) does not loop. The block structure is the reference's waitingForCommand model (§1.6), which skips forward by source line to the verb it is waiting for, so a NEXT on the same line as its FOR is never reached. Fixing it means restructuring control flow to work on statements rather than lines, which is a deliberate piece of work and wants its own commit. Group A's DO/LOOP will meet the same wall.

  • Array references in parameter lists Done, and it turned out to be §6 item 13 a third time rather than a separate defect. An identifier's subscript list moved to .expr, and akbasic_ASTLeaf grew a next field so an argument list chains through a link of its own. That last part is the real fix: the reference chains arguments through each argument's own .right, and every leaf type that can be an argument already uses .right for something, so moving one operand out of the way only shifted the collision along. Covered by tests/language/arrays_in_parameter_lists.bas and tests/runtime_evaluate.c.


5. Deliberate deviations from the reference

Keep this list current. Most of these change structure without changing observable output; the ones that do change output say so and carry the golden file they moved.

The bar has dropped since this list was started. It used to be "defensible against the golden suite", because matching the Go implementation byte for byte was goal 1's headline claim. That implementation is now deprecated (§0.1), so the bar is the ordinary one: defensible on its own merits, recorded here, and tested.

  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 cannot fill at all, and the plan for it is to spell fill as a negative angle. 7.0's last argument selects outline or fill and sits after the rotation, which would make a filled box a seven-argument call — one more than akbasic_cmd_box() collects. Spelling it as a negative rotation instead is the intended answer and is not implemented: today a rotation of zero outlines through the renderer's rectangle and any other rotation, negative included, draws four lines.

    This entry used to claim in bold that it fills, with the body then saying it was filed rather than implemented — a headline that contradicted its own paragraph, which is exactly how a reader ends up believing a feature exists. Found by writing a documentation figure for BOX and getting an outline back. PAINT is the fill a program has today.

    akbasic_GraphicsBackend::filled_rect is dead from the language's side as a direct consequence: src/graphics_akgl.c implements it and tests/mockdevice.h records it, but no BASIC verb reaches it. It is the entry point this fix would call, so it is waiting rather than unused — worth knowing before somebody tidies it away.

  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 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. The mode is readable as RGR(0), which is 7.0's whole RGR and the one field of ours that is.

  6. A coordinate reaching a backend is a device pixel, and the whole of the host's window is reachable from BASIC. This used to read "coordinates are BASIC's 320x200 space and scaling to the window is the host's job", on the reasoning that the interpreter cannot ask the renderer how big it is without owning it. That reasoning was wrong twice over, which is what §8 item 9 came to say.

    It was wrong about the mechanism: not owning a thing is no bar to asking it, and the backend record is how everything else here asks. akbasic_GraphicsBackend grew a size entry point, require_graphics() calls it before every verb that draws -- so a resized window is honoured between two statements rather than at attach -- and 320x200 became the fallback for a backend that leaves size NULL. It is the record's one optional entry point, so a host written against the old header keeps working and gets exactly the behaviour it used to get.

    And it was wrong about the description: nothing ever stretched anything. With SCALE off a coordinate was passed straight to akgl_draw_* as a pixel address, so an 800x600 window drew a C128 listing into its top-left 320x200 corner and left the rest unused. The documentation described a coordinate transform that did not exist.

    What changed observably is SCALE, which now maps user coordinates onto the device rather than onto the constants, and RGR(1) / RGR(2), which report the drawing surface's width and height so a program can use a window whose size it did not choose. A C128 listing draws in the corner as it always did; SCALE 1, 319, 199 gives it the whole window.

    One fix rode along, in the same line of code and too small to defer. SCALE mapped xmax onto the width, which put the user space's far corner one pixel past the surface: SCALE 1, 319, 199 then DRAW 1, 319, 199 drew nothing at all. It maps onto the last pixel now, which is what makes that sentence above true rather than nearly true. tests/graphics_verbs.c and tests/akgl_backends.c, the latter against a 128x128 target chosen because it is smaller than the old constants -- a SCALE still dividing by them misses it entirely rather than landing somewhere plausible.

  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 sweep runs once rather than oscillating. 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 used to be refused outright — there was no akgl_audio_* equivalent and faking one would have tied audible pitch to how often the host calls us. libakgl 0.3.0 added akgl_audio_sweep() and SOUND voice, freq, dur, dir, min, step now reaches it. What does not survive is dir 3, "oscillate": akgl_audio_sweep runs one pass between two endpoints, and a real oscillation needs the mixer to turn around at them. What this changes for a program: a dir 3 siren rises or falls once instead of warbling. dir 1 and 2 are exact, and so is the direction — both the SID and akgl_audio_sweep take it from which endpoint is higher, so a min above the starting frequency rises whatever dir says.

    step is converted as a delta rather than a position: it is a register increment, and the register-to-hertz table maps positions, so it goes across as the distance between register 0 and register step. A step that converts to zero would never arrive and is raised to one hertz, which is the smallest move that still gets there.

    A backend whose record has no sweep — a host written against libakgl 0.2.0 — still refuses a swept note with AKBASIC_ERR_DEVICE and plays a held one normally. tests/audio_verbs.c asserts both halves.

  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 text layer repaints every row it owns, every frame. Not just the rows that changed.

    That was tried — a drawn[] array marking which rows had carried glyphs, so an untouched row could be skipped — and it is wrong on real hardware. SDL_RenderPresent swaps buffers, so a frame does not inherit the frame before it; it inherits the one two back, and on some backends something undefined. A row erased once is clean in one buffer and still dirty in the other, and presenting alternates between them.

    That is what a backspace across a line wrap looked like: the first character of the wrapped tail flickering in and out forever after the rest had gone. It reproduces on X11 and never under the dummy driver or a software renderer, which is exactly why the suite was green through two rounds of fixing it. tests/akgl_frontend.c now paints stale pixels by hand — which is what a swapped-in buffer hands back — so the case is covered without needing real hardware.

    There is no cheaper correct answer available: a frame either owns every pixel it presents or it inherits pixels it cannot reason about.

    What this costs, and it is more than it used to. The host still does not call SDL_RenderClear, but the text area is now repainted in full every frame, so anything a graphics verb drew underneath it is erased every frame rather than only where text lands. In the standalone driver the text area is the whole window, so DRAW and PRINT no longer coexist there.

    And the same buffer swap means the graphics verbs were never reliable across frames anyway, which is worth stating plainly rather than leaving as a surprise: a one-shot DRAW lands in one buffer and the next present shows the other. It looked fine in a single screenshot and would have flickered exactly as the text did. Making graphics survive needs a persistent surface the frame is composited from, which is real work and its own commit — filed here rather than half-done.

  3. The cursor is a blinking block. The reference draws an underscore glyph (drawCursor, basicruntime_graphics.go:33), and so did this until the underscore turned out to sit under the text being typed and make it hard to read. A block occupies the cell after the text instead of the space beneath it.

    Half a second per blink (AKBASIC_SINK_CURSOR_BLINK_MS), which is about a Commodore's and slow enough to read under. akbasic_AkglSink.cursorperiodms set to zero holds it solid, which is what makes a frame deterministic for a test.

    Two details that are decisions rather than accidents. The clock is SDL's, not the host's: everywhere else the host owns the clock because the library owns no loop and must not block, but this is an adaptor that already links SDL and threading a timestamp through the sink interface to animate a cursor would be a poor trade. And a line that exactly fills a row leaves the cursor one column past the end, because putchar_at wraps only when the next character arrives — the cursor is drawn at the start of the next row instead, which is where the next character will actually land and where a C128 puts it.

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

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

Deviations that change what a program is allowed to do

  1. A verb name with a type suffix is not a variable name. PRINT$ = 1 is refused where the reference accepts it, because its reserved-word check searched the keyword tables with the suffix still attached and therefore never matched (§6 item 16). A real C128 refuses it too, so this is the reference being wrong rather than this interpreter being strict.

    What it costs a program: a listing that used INPUT$, LEN# or GOTO% as a variable stops parsing, and the fix is to rename the variable. One case in the reference's own corpus did exactly that; see tests/reference/README.md.

Deviations in statement separation

  1. A branch decides who owns the rest of its line. BASIC 7.0 scopes every statement after THEN to the condition, and the reference has no opinion on the matter because it never consumed the COLON token at all. The parser here takes exactly one statement for each arm, so the rest of the line arrives at the statement loop as ordinary top-level statements and something has to say whether to run them. The BRANCH case in akbasic_runtime_evaluate() sets runtime->skiprestofline, and the two statement loops stop on it.

    The rule is not "skip when false", which is the reason this is written down:

    Line Condition What runs
    IF C THEN A : B true A, B
    IF C THEN A : B false nothing
    IF C THEN A ELSE B : D true A
    IF C THEN A ELSE B : D false B, D

    The remainder always belongs to whichever arm was written last, so it is skipped exactly when that arm is the one not taken — with an ELSE the last arm is ELSE, without one it is THEN. That is one line of code and it reads as an oddity without the table above it.

Deviations in the structure and error verbs (groups A and C)

  1. Structures are an addition, not a port. BASIC 7.0 has no records at all, so TYPE/END TYPE, DIM X@ AS T, . and ->, PTR TO and POINT ... AT are invented here. The @ suffix was not: the Go reference reserved IDENTIFIER_STRUCT and never used it, and src/grammar.c rendered such a leaf as "NOT IMPLEMENTED" until this landed. docs/16-structures.md is the whole feature.

    Assignment copies and POINT shares, which is the decision everything else rests on. A structure is a value like every other value here, so a program that never writes POINT can never be surprised by aliasing — and the two field operators are kept apart so a reader always knows from the spelling which they are looking at. . on a pointer and -> on a value are both errors, each naming the other.

    A declared type is what buys the storage model. An instance has a known slot count and is laid out exactly as an array is, out of the same value pool, so nothing new holds data — only a table of descriptors. It also bounds copy depth statically, since a TYPE cannot contain itself by value.

    Three limits are ours and are stated rather than derived: 16 types, 16 fields per type, and four levels of nesting in PRINT. The last is not a safety bound on copying — copy stops at pointers by construction — but on rendering, which must follow a pointer and would not come back on a cycle. Four rather than eight because the bound has to bite before the 256-byte render buffer does, or a cycle stops because it ran out of room rather than because it was told to.

    A type name shares a namespace with verbs and labels, since all three are bare words. Refused at declaration with a message that says so, because the parser's own answer was "Expected expression or literal" pointing at the line rather than the problem. Field names follow the same reserved-word rule variable names already do, enforced by the loader's scan — TO@ is a bad field name for exactly the reason TO# is a bad variable name.

  2. A host's C struct is the same thing with its bytes somewhere else. akbasic_host_register_type() puts it in the same table a TYPE fills, so copy, PTR TO, . and -> all work across the boundary with no second set of rules — and the language's own copy-versus-POINT distinction turns out to be exactly the snapshot-versus-share distinction a host needs, so there is one API rather than two.

    A binding takes a shadow run of slots; a read refreshes from host memory and a write converts back. Conversion refuses rather than truncates — 70000 into an int16_t names the field — and a field name's suffix must agree with the C type it describes, refused at registration.

    It is the only pointer this interpreter holds that it did not allocate, and that is the one thing a host has to think about. akbasic_host_unbind() exists for it. examples/hoststruct.c is a working host, built and run by every build.

  3. A whole loop on one line does not loop. DO : PRINT 1 : LOOP runs once, exactly as FOR I=1 TO 3 : PRINT I : NEXT I does. Block skipping walks source lines, which is the reference's waitingForCommand model (§1.6), so a LOOP on the same line as its DO is never reached as a separate step. Fixing it means making the skip operate on statements rather than lines — a deliberate piece of work, already noted in §4 against FOR.

  4. ER and EL are ER# and EL#. A C128 exposes them as bare reserved names. This dialect cannot: an identifier carries its type in a suffix, and a name without one is a label. They are written into the global scope when a trap fires, so PRINT ER# reads the way the rest of the language does and needs no new syntax.

    ER# carries the libakerror status code, not a Commodore error number — 515 for an out-of-bounds subscript rather than C128 error 9. There is no correspondence to reproduce: the errors this interpreter raises are not the errors a 1985 ROM raised. ERR(ER#) gives the name, and that is the part a program should be printing anyway.

  5. END does not arm CONT. A C128 lets CONT resume after END as well as after STOP. Here END means the program finished, and continuing from a finished program resumes at whatever line happens to follow — so CONT refuses instead. STOP still arms it.

Deviations in the format verbs (group D)

  1. PRINT USING renders one field per statement. BASIC 7.0 lets a format string hold several fields and PRINT USING take a list of values against them. Here the first field is used and any literal text around it is copied through, so PRINT USING "A: ###"; X works and PRINT USING "### ###"; X, Y does not. Multiple fields need PRINT to take a value list, which it does not yet — a separate piece of work from the field rendering itself, which src/format.c does properly.

    Exponential fields (^^^^) are not implemented either. Nothing in the language produces a value that needs one, since PRINT renders a float with %f.

  2. WIDTH is emulated with parallel passes. It sets the thickness of drawn lines, and neither the graphics backend record nor akgl_draw_line takes a thickness — so a WIDTH 2 line is drawn twice, offset one pixel along whichever axis the line does not mostly run along. Visually right at the only other value the verb accepts; a general thickness would want a real perpendicular offset and an API that takes one.

  3. CHAR ignores its colour argument, and needs a sink that has a cursor. The text sink draws in one colour, chosen by the host when it built the sink, so a per-call colour would have to become part of the sink interface. The argument is accepted and ignored rather than refused, because refusing it would make every published CHAR line a syntax error.

    Positioning is a new optional moveto on the sink record. A stdio sink leaves it NULL and CHAR refuses by name — a terminal's cursor is not this library's to move and a pipe has no cursor at all — so CHAR works in an AKGL build and says why it cannot in a stdio one.

Deviations in the console verbs (group E)

  1. SLEEP and WAIT hold the step loop; they do not block. §1.6 forbids blocking, so a waiting program is one that does not advanceakbasic_runtime_step() still returns, a bounded akbasic_runtime_run() still comes back on time, and an embedded game keeps its frame rate while a script sleeps.

    SLEEP with no host clock does nothing at all rather than waiting forever. A deadline computed from a clock that never advances is never reached, and the first version of this hung the test suite proving it.

  2. TI and TI$ are TI# and TI$. Same reason as ER#/EL#: no bare variable names. They are ordinary globals refreshed once per step from the host's clock, rather than pseudo-variables, because this dialect has no mechanism for a name that computes itself. A host that never sets a clock gets a stopped clock rather than a wrong one.

  3. WAIT polls ordinary process memory. On a C128 the byte is a hardware register an interrupt is changing. Here nothing changes it but the host, another thread, or a POKE from a TRAP handler — so a program that waits on a byte nobody writes waits forever, which is exactly what the same program does on a C128 with the wrong address. Read through a volatile pointer, because the whole point is that something outside this program writes it.

  4. KEY stores its macros and nothing expands them. The definitions are kept and bare KEY lists them, which is the half that is this library's business. Expanding a macro when a function key is pressed belongs to whatever owns the keyboard — the frontend's line editor — and the input backend delivers keycodes rather than editor commands.

Deviations in the machine verbs (group J)

  1. FETCH and STASH are the same byte copy. On a C128 they differ by which side is the RAM Expansion Unit — STASH writes out to it, FETCH reads back. There is no expansion unit and there are no banks, so both are memmove between two addresses and saying so is better than inventing a distinction. memmove rather than memcpy because a program shifting a buffer along by a few bytes overlaps its own source, which is a normal ask.

    Addresses are real process addresses, which is the decision POKE, PEEK and POINTER already made. There is no bounds check and there cannot be one: a wrong address is a segmentation fault, not an error message.

  2. SYS is refused by name. It calls machine code at an address. There is no 6502 and no ROM to call, so there is nothing to jump to and nothing sensible to emulate — and jumping to a real address in this process would be a way to crash it on purpose. It parses first and refuses second, so a listing containing SYS still loads and still lists; only running it fails, and it says why.

    Same reasoning as BANK, FAST and MONITOR, with one difference: those have no table row at all, and SYS has a handler because it is common enough in published listings to deserve a specific message rather than "Unknown command".

Deviations in the disk verbs (group F)

  1. There is no 1541, so the verbs split three ways. The ones that mean something on a filesystem are implemented against aksl_f*DOPEN, DCLOSE, APPEND, RECORD, SCRATCH, RENAME, COPY, CONCAT, BSAVE, BLOAD, VERIFY. The ones that are spellings of verbs already here are aliases: SAVE is DSAVE, LOAD is DLOAD, CATALOG is DIRECTORY, DVERIFY is VERIFY.

    HEADER, COLLECT, BACKUP and BOOT are refused by name, with the reason. Each operates on a physical disk — formatting one, validating its block allocation map, duplicating it, booting from it — and a filesystem has no equivalent that is not a lie. HEADER would have to mean "delete everything in this directory", which is a spectacularly bad thing to do to somebody who typed a C128 verb. DCLEAR is the exception: resetting a drive also closes its channels, and closing the channels is real, so that is what it does.

  2. DIRECTORY is refused for want of an upstream wrapper. libakstdlib has no aksl_opendir, and this project's rule is that a missing capability is filed upstream rather than worked around — so it is filed in deps/libakstdlib/TODO.md and the verb says so. Calling opendir(3) here would mean reporting through errno in a file where everything else reports through an akerr_ErrorContext *.

  3. PRINT # and INPUT # need the space before the #. A C128 writes PRINT#1,A$. The scanner reads # as a type suffix, so PRINT# comes out as an identifier named PRINT with an integer suffix — and a verb name carrying a suffix is refused (deviation 30). With a space the # is its own token and the parse handler reads it.

  4. RECORD counts lines, not fixed-length records. A C128's relative file has a record length fixed when the file was created; a filesystem file has none. So a record is a line and RECORD rewinds and reads forward to it, which is slower than a seek and is the only definition that does not invent a record length the file does not have.

  5. BLOAD requires a length. A C128 reads until the file ends. Here the address is a real process address, so a file longer than the caller expected would write past whatever it was pointed at with no way to notice. Refusing to guess is the only safe answer.

Deviations in conditions

  1. A lone = is equality inside a condition. IF A# = 2 THEN works, which it did not before: the scanner reads == as EQUAL and a single = as ASSIGNMENT, because at that point it cannot know whether it is looking at a statement or a condition, and the reference never resolved the ambiguity anywhere. A condition is the one context where it has an answer — BASIC has no assignment expression — so akbasic_Parser::comparing is set around the condition and the relation parser offers ASSIGNMENT as a comparison operator only while it is. == keeps working, and the whole checked-in corpus is written in it.

    Outside a condition = has to stay an assignment, which is what the flag is for: the first attempt put ASSIGNMENT in the operator list unconditionally, and FOR I# = 1 TO 5 promptly stopped initializing its counter.

  2. A condition is a whole expression, so AND and OR work in one. IF A# = 5 AND B# = 3 THEN used to report "Incomplete IF statement". The reference parses a single relation after IF, and a relation sits below AND and OR in the grammar chain, so the AND was never consumed and the THEN check found the wrong token. akbasic_parse_if() calls akbasic_parser_expression() now.

  3. Truth is nonzero, not "a boolean holding true". akbasic_value_is_truthy() is what a branch tests. Commodore BASIC has no boolean type: a comparison yields -1 or 0, AND and OR are the bitwise operators, and IF A THEN is legal for any numeric A. The reference tests boolvalue == -1 regardless of the value's type, so IF A# = 1 OR B# = 2 THEN — whose OR yields an integer — was silently always false, and so was IF A# THEN.

    The bitwise operators accept a truth value as an operand for the same reason: -1 is every bit set precisely so that AND and OR double as the logical pair. A string is still never true; a C128 raises a type mismatch there, which is a stricter answer this could adopt later.

Deviations in the sprite verbs (group H)

  1. Labels are filed before the program runs, not as each LABEL executes. akbasic_runtime_scan_labels() walks the stored source on every entry into AKBASIC_MODE_RUN and files every LABEL <name> it finds, textually, without parsing the lines around it. The reference resolves a label only once its LABEL statement has run, so GOTO and GOSUB reached backwards and never forwards.

    This was not a nice-to-have. An interrupt handler by definition sits on a line normal flow does not fall into, so COLLISION 1, BUMPED could not be made to work at all under the old rule — neither resolving at arm time nor at fire time helps when the LABEL line is never executed. Forward GOTO is the improvement that came with it, covered by tests/language/statements/label_forward.bas.

    LABEL still executes and still files itself, so a name that appears twice resolves to the last one in the source until one of them runs. The scan is textual on purpose: parsing every line up front would raise on lines the program would never have reached.

  2. Sprite coordinates are device pixels, not the VIC-II's 0511 by 0255. MOVSPR takes the same coordinate space DRAW does. A C128's sprite coordinates are the raster's, offset so that (24, 50) is the top-left of the visible screen; reproducing that would give the language two coordinate systems and make MOVSPR 1, 0, 0 put a sprite off-screen. Consistent with deviation 18, which is where "the same space DRAW uses" is defined — and it moved, so this one moved with it.

    SCALE deliberately does not apply to sprites. It is a graphics-verb transform and the sprite verbs do not call it, which was true before deviation 18 was rewritten and is worth writing down now that the two spaces can differ: with SCALE 1, 319, 199 on, a DRAW at 160,100 and a MOVSPR to 160,100 land in different places. Arguably they should agree; not changed here because it is a decision about what SCALE means rather than a slip, and it wants its own commit.

  3. MOVSPR's speed unit is ours. BASIC 7.0 documents speed 015 with 15 fastest and says nothing about what a unit is worth. Here one unit is AKBASIC_SPRITE_SPEED_PIXELS_PER_SECOND (5) pixels per second, so speed 15 crosses the 320-pixel screen in about four seconds. Paced off akbasic_runtime_settime() from akbasic_runtime_step(), exactly as the PLAY queue is, so a host that never sets a clock gets a still picture rather than a hang — same trade as deviation 20.

  4. SPRSAV takes an integer array where a C128 takes a string, and takes a file path as well. Three source forms, one of which is the C128's:

    Form Source
    SPRSAV A$, n a region SSHAPE saved. The C128 documents SPRSAV's string as the SSHAPE data format at a fixed 24×21, so sharing the shape pool is faithful rather than a shortcut
    SPRSAV A#, n 63 elements of a DIMmed integer array — the DATA-driven path a type-in listing uses
    SPRSAV "ship.png", n an image file

    The array form exists because a string here cannot hold a sprite. A value carries a NUL-terminated char[256] (§1.2) and a pattern is 63 raw bytes including zeros, so the C128's own spelling is unrepresentable. DIM P#(63) is exactly the right size.

    The file form is a deliberate addition, not a fallback. A string source is a path unless it starts with SHAPE:, which is the prefix SSHAPE mints, so the two never collide. It resolves through akgl_path_relative() — the working directory first, then the directory the running program was loaded from — and loads through akgl_spritesheet_initialize(), which are the same two calls akgl_sprite_load_json() makes for a spritesheet. A sprite loaded this way takes the image's own size rather than being forced to 24×21; a modern PC has no reason to throw away art that is not sprite-shaped.

    akbasic_runtime_set_source_path() is what tells the interpreter where the program came from. Group F's disk verbs will want the same thing.

    The reverse direction is refused. SPRSAV n, A$ on a C128 copies a sprite out; here that would mean writing an image file, which is a disk operation, and group F is unimplemented as a whole. Refused by name rather than half-built.

  5. COLLISION implements type 1 only, and types 2 and 3 are refused by name. Sprite-to-background needs the whole render target read back and compared against each sprite every frame; a light pen has no meaning on a machine with no light pen. Both raise AKBASIC_ERR_DEVICE with the reason rather than being accepted and never firing.

    Collision is bounding-box, not pixel. A C128's VIC-II collides on set pixels, so two sprites whose boxes overlap but whose art does not are reported as colliding here and would not be there. Pixel-exact collision would mean keeping every sprite's unpacked bitmap and testing the overlap rectangle a pixel at a time; the box test is four comparisons. akgl_collide_rectangles() is deliberately not used — it has a documented corner-containment defect that misses a plus-shaped overlap, and nothing in libakgl calls it.

  6. A collision handler is entered between source lines, and must end in RETURN. akbasic_runtime_service_interrupts() runs at the top of akbasic_runtime_step() and injects what amounts to a GOSUB the program did not write. Between lines is the only safe place: a handler entered mid-statement would have to return into the middle of a line and the parser keeps no state that could resume there. That is the same granularity block skipping already works at (§1.6).

    An interrupt does not interrupt an interrupt. A collision that is still true while its own handler runs would otherwise re-enter on the next line until the environment pool was gone. The event is not lost — it is taken as soon as the handler's RETURN lands.

  7. Sprite priority is recorded and not honoured. SPRITE n,,,1 sets the bit, RSPRITE reads it back, and nothing draws differently. A C128 draws a low-priority sprite behind the bitmap plane; here the text layer, the drawing surface and the sprites share one render target and sprites are composited on top of it every frame, so there is nothing to go behind. Honouring it would mean a separate composited surface per plane — which is the same piece of work deviation 19 already needs.

  8. Multicolour mode is recorded and not honoured. Same shape as priority: SPRITE's seventh argument and SPRCOLOR's two registers are kept, RSPRITE and RSPCOLOR read them back, and no sprite is drawn in multicolour. Multicolour packs two bitmap bits per pixel and selects between the sprite's own colour and the two shared registers; every SPRSAV source form here carries one bit per pixel or a full-colour image, so there is no second bit to select with. The argument positions are kept honest for the day a multicolour pattern format exists.

  9. A sprite is a real libakgl actor, and it is drawn by a renderfunc of ours. Each of the eight becomes a SpriteSheet + Sprite + Character + Actor registered in AKGL_REGISTRY_ACTOR, so an embedding game sees BASIC's sprites alongside its own (goal 3). But akgl_actor_render() is not what draws them. There were two reasons and libakgl 0.5.0 fixed one: the default used to compute its destination height from the sprite's width, drawing a 24×21 Commodore sprite as a 24×24 square, and it now uses the height — libakgl defect 26, filed from here and closed there.

    The reason that remains is the one that keeps this file. An actor carries a single scalar scale applied to both axes, which cannot express SPRITE's separate x- and y-expand bits, so MOVSPR's twice-as-wide-same-height is unrepresentable. libakgl records it as open and names this interpreter as the caller that needs it; the fix is a design choice over there — scale_x/scale_y beside scale, or replacing scale and taking the ABI break while the major is still 0 — rather than a patch. Until then src/sprite_akgl.c installs its own renderfunc, which is libakgl's own extension point for exactly this.

    Worth keeping as a pattern: both defects were found by using the library for something it had not been used for, and both were filed rather than worked around silently. One came back fixed.

  10. Line numbers are optional in a loaded program, and a blank line is not a program line. This one moved a golden file. A line with no number used to be filed under the loader's cursor unchanged — that is, on top of the line before it — so two unnumbered lines in a row silently lost the first, and a blank line erased whatever preceded it. The reference does the same, and tests/reference/language/arithmetic/integer.bas is the proof: four PRINT statements, an expectation with three values, and a trailing blank line that erased 40 PRINT 4 - 2 before the program ran. The expectation is now 4 4 2 2 and tests/reference/README.md records it.

    In its place: akbasic_runtime_file_line() (src/runtime.c) is the one implementation of the rule, shared by akbasic_runtime_load(), RUNSTREAM and DLOAD. A numbered line is filed under its number and moves the cursor; an unnumbered one takes the slot after it; blank lines are skipped by all three. A collision involving an assigned number is refused with AKBASIC_ERR_BOUNDS rather than overwriting. akbasic_SourceLine grew a numbered flag so deviation 64 can tell the two apart.

    The prompt is deliberately untouched. A line typed without a number is direct mode and runs now; that is the only thing separating program text from a statement at a REPL, and it is why this is a loading feature rather than a language-wide one. AUTO remains the way to enter program text without typing numbers.

    Consequence worth stating: an unnumbered program is capped at 9998 lines, the cap that already existed, and its assigned numbers are increment-1 — so LIST and DSAVE show a listing with no gaps to insert into. RENUMBER before DSAVE gives the gaps back, and marks every line numbered.

    Not changed: two lines that both carry the same written number still silently keep the last, as they always have. That is a separate decision with its own corpus risk and it is not this one.

  11. Branching by number to a line the program did not number is refused before it runs. The other half of deviation 63, and the reason akbasic_SourceLine carries a flag rather than the loader just picking slots quietly. In a script written without line numbers GOTO 100 finds the hundredth line and branches there: plausible, silent and wrong, which is the worst thing to hand somebody at run time.

    akbasic_runtime_check_targets() is a fourth prescan, run beside the label, DATA and TYPE ones on every entry into AKBASIC_MODE_RUN — the earliest the check can be made and the only place all four ways a program arrives pass through. It refuses with AKBASIC_ERR_SYNTAX, naming the line and saying what to do instead:

    ? 1 : PARSE ERROR Line 1: branch to line 4, which the program did not number.
          Branch by LABEL, or RENUMBER first
    

    Two things are deliberately not refused. A target naming an empty line, for the same reason RENUMBER leaves one alone — GOTO 9999 in a program with no line 9999 is already broken and inventing a rule about it would hide that. And a target in a fully numbered program, which is every program that existed before this, so nothing checked in changed.

    It shares RENUMBER's walk rather than repeating it. src/renumber.c grew an akbasic_TargetWalk — a self pointer and a visit function — and rewrite_line() now takes one. RENUMBER's visitor substitutes the number a line moved to; the check's substitutes the number unchanged and raises if it names an assigned line. One walk, so the two cannot disagree about what a branch target is, which they would have within a release.

    Worth knowing: the check points environment->lineno at the line it is walking, so the ? N : prefix names the offending line. The other three prescans do not, so a malformed TYPE or a bad DATA item still reports whichever line the loader stopped on — usually the last line of the program. They work around it by putting Line %d: in the message text, which is why the message above says the number twice. Worth fixing in akbasic_runtime_set_mode()'s wrapper rather than in four places; not fixed here.



6. Reference defects — fix them; fidelity is no longer a reason not to

These are real bugs in deps/basicinterpret, reproduced faithfully during the port.

The rule that governed this section is gone. It used to read "port the behaviour first so the golden suite passes and the port is provably faithful, then fix each one deliberately" — and faithfulness was the reason several of these are still here. The Go implementation is deprecated and will not be updated (§0.1), so there is no longer anything to be faithful to. Each of these is now an ordinary defect, to be judged on whether the fix is right rather than on whether it matches.

What has not changed is the working method: one fix per commit, with a test that asserts the correct contract, and a golden file moved in the same commit if the fix changes output. AKBASIC_KNOWN_FAILING_TESTS is empty now and is kept declared for the next defect that has to be reproduced before it can be fixed.

  1. basicvariable.go:176toString() tests len(self.values) == 0 and then indexes self.values[0]. Never ported. There is no akbasic_variable_to_string: rendering a value is akbasic_value_to_string(), which switches on the value type and has no count test to invert. The buggy function had no counterpart to reproduce.

  2. basicvariable.go:108setBoolean builds a value with valuetype: TYPE_STRING. Fixed in the port. akbasic_value_set_bool() sets AKBASIC_TYPE_BOOLEAN, and tests/value_compare.c reads a boolean back through every comparison operator.

  3. basicenvironment.go:157stopWaiting(command) ignores command. Fixed in the port. akbasic_environment_stop_waiting() walks the scope chain and clears the first environment actually waiting for that verb, so an inner block can no longer clear an outer block's wait. A miss is tolerated rather than raised, which is the one part of the suggested fix not taken: EXIT and the end of a DEF body both call it speculatively.

  4. basicvalue.go:181mathPlus mutates self in place when self.mutable is true. Fixed. It clones like every other operator now, so A# + 1 cannot modify A#.

    The gate on this item was FOR/NEXT coverage, because NEXT's increment relied on the mutation to advance the counter. tests/for_next.c is that coverage -- STEP, a negative step, a float counter, a body that assigns to the counter, EXIT, and nesting, none of which the golden corpus reaches -- and akbasic_cmd_next() now writes the incremented value back with akbasic_variable_set_subscript(). Removing that write-back does not fail the suite, it hangs it: the counter stops advancing and the loop never ends.

  5. basicvalue.go:191 and siblings — every binary operator adds both of the right-hand operand's numeric fields. Fixed. rval_as_int() and rval_as_float() select on the operand's type instead of summing intval + (int64_t)floatval.

    It was filed as a landmine with no consequence today, and that is why it needed a test written against the value API rather than against the language: no BASIC program can build a value carrying both fields, and the old code passes every other test in the tree. tests/value_arithmetic.c constructs one directly.

  6. basicvalue.go, all comparisonsdest is cloned from self, so the resulting boolean inherits self.name. Moot. BasicValue.name was dropped as dead during the port along with BasicEnvironment.update(), its only reader — deviation 9 above. There is no name for a comparison result to inherit.

  7. basicruntime_commands.go:484CommandIF dereferences expr.right after the loop guaranteed it is nil, so the "Malformed IF statement" check is unreachable. Fixed in the port. akbasic_parse_if() matches the THEN token and compares its lexeme directly, so the check runs on every IF.

  8. basicruntime_commands.go:669CommandEXIT pops the environment without stopWaiting. Fixed, and then fixed again — see item 18, which is what clearing the wait left behind.

  9. basicruntime.go:149newVariable() and BasicRuntime.variables[MAX_VARIABLES] are dead. Not ported as dead code. The pair is live here and is the storage model: akbasic_environment_create() takes a slot from akbasic_runtime_new_variable(), because a pool is what replaced Go's per-environment map (§1.3). The reference's versions were dead; these are not.

  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. Fixed in the port. They come from a fixed pool and akbasic_runtime_prev_environment() releases each one — deviation 3 above. An unreleased scope shows up as pool exhaustion rather than as unbounded growth, which is how item 18 announced itself.

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

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

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

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

  16. basicscanner.go:349 — the "Reserved word in variable name" check never fires. Fixed, and it is the item §0.1 unblocked. The keyword tables are now searched on the base name with any type suffix stripped, so PRINT$, LEN# and GOTO% are refused as variable names and the reference's own diagnostic stops being dead code. A name that merely contains a verb — PRINTER$ — is still fine, which is what says the check compares the whole base name rather than a prefix. Regression tests in tests/scanner_tokens.c.

    It cost one golden case, exactly as predicted, and the cost turned out to be nothing. The reference's examples/strreverse.bas names a variable INPUT$; the variable is renamed to SOURCE$ in tests/reference/, the expectation is byte-for-byte unchanged — the program still prints REVERSED: OLLEH — and the case keeps every bit of its coverage. Recorded in tests/reference/README.md's divergence table, which this is the first entry in.

    This item sat parked because the corpus was the acceptance contract and lived in a submodule this repository could not edit. Both premises are gone: the corpus is checked in, and matching the Go implementation is no longer a goal. Worth keeping the history, because it is a clean example of a fix that was correct all along and blocked entirely by a constraint that has since expired.

Ours, not the reference's

  1. A host cannot reliably create a script variable while a script is suspended. Fixed. akbasic_runtime_global(obj, name, dest) walks obj->environment to the root and finds or creates there unconditionally. README.md and examples/hostvars.c both point at it now, and tests/hostvars.c asserts it: a global created from inside a FOR body and from inside a GOSUB, still readable by the script after the body pops, plus the seed-before / read-after path that always worked.

    This one was not inherited — it fell out of §1.4's environment pool meeting §1.6's bounded run(), a combination the reference never had because its run() never returned. Both failure modes were silent: a variable created through akbasic_environment_get() landed in the loop's scope and died with it, so the script read it correctly inside the loop and got 0 immediately after; and reaching for the root explicitly returned NULL through dest without raising, because that function only auto-creates when obj->runtime->environment == obj.

    Three things settled while doing it, each worth keeping:

    • The design question §6 named has an answer. Suspended inside a user function's scope, the root is still right and the walk still gets there: a funcdef's environment is initialized with the caller's as its parent (akbasic_runtime_user_function), so the chain terminates at the same root as any other.
    • akbasic_environment_create() is the new half, and it searches only the scope it is given — no walk in either direction. Walking up would find an outer variable and put the value somewhere other than where the caller asked for it.
    • akbasic_environment_get() is unchanged. Declining to create in a non-active scope is the right behaviour for what the interpreter uses it for; it is only wrong as a host API, which is why the answer was a new entry point rather than a change to that one. tests/hostvars.c asserts the old behaviour too, so a later "tidy-up" has to argue with a test.

Found while auditing this section against the C port

Writing the FOR/NEXT coverage that item 4 was gated on turned these up. The first two are the reference's semantics reproduced faithfully; the third is the port's own.

  1. EXIT on the first pass through a loop restarts the program. Fixed. EXIT jumped to loopExitLine, which is only ever written by NEXT — so an EXIT before any NEXT had run jumped to line 0. Each restart entered the FOR again and took another environment and another variable, so what a reader saw was "Maximum runtime variables reached" reported against the FOR, on a four-line program.

    It now skips forward to the NEXT using the same waitingForCommand machinery a zero-iteration body uses, and the NEXT pops on an exiting flag. Neither verb has to know where the loop ends. tests/for_next.c.

  2. A FOR body runs once with the overshot counter. The loop condition is tested against the counter before the increment, so the increment's result reaches the body before anything compares it to the limit. FOR I = 1 TO 9 STEP 3 runs its body with 1, 4, 7 and then 10. Invisible whenever the step lands exactly on the limit, which is every case in the golden corpus and every case in the reference's own tests.

    Related, and the same off-by-one from the other end: FOR I = 1 TO 1 does not run its body at all, where every BASIC ever written runs it once. The two errors compensate for a step of 1, which is why neither was noticed.

    Not fixed, because the corpus pins it. tests/reference/language/flowcontrol/forloopwaitingforcommand.bas depends on FOR I# = 1 TO 1 skipping its body, and tests/reference/README.md forbids editing an expectation to suit this interpreter. Registered as tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract. Fixing it means deciding what to do with that golden file, and that is a decision rather than a patch.

  3. A FOR counter does not survive its loop. It is created in the environment the loop pushes, which pops when the loop ends, so reading it afterwards finds a fresh variable holding zero. A C128 leaves it at the value that ended the loop and plenty of published listings read it there. Same known-failing test as item 19; the fix is a scoping decision about where a loop counter is created, not an ordering one.

Found while fixing the DEF recursion hang

  1. A call leaked one variable slot per parameter. Fixed. akbasic_runtime_prev_environment() released the scope but not the variables the scope created, so a subroutine with a local of its own exhausted the 128-slot pool after about 128 calls -- Maximum runtime variables reached, on a four-line program that does nothing unusual.

    It was there for GOSUB all along, measured on a build stashed back rather than assumed: FOR I# = 1 TO 300 : GOSUB 100 : NEXT where the subroutine assigns a local failed before this work and passes after it. Giving each DEF call its own scope (item 26) simply made it reachable a second way, which is how it was found.

    The release is safe because a scope's own table holds only what it created -- akbasic_environment_get() walks up to find an outer variable and returns it without caching a reference. That is worth knowing before anyone adds caching there, and the loop says so. It is the variable slot that comes back, not its storage: the value pool never frees, so a pointer into a record DIMmed in a scope stays sound after the scope is gone, which is a documented property of structures rather than an accident this could have taken away.

  2. A function could not take a structure. Done. DEF AREA(S@ AS RECT) and DEF POKEIT(P@ AS PTR TO RECT) now work, and a bare DEF F(S@) is refused: @ says "a structure" without saying which, so it does not state a contract the way S$ does. Accepting it would mean checking fields at the call rather than at the declaration -- duck typing, and the hole naming the type closes. The cost is that there are no generic functions, one AREA cannot serve two types, and that is a real loss recorded rather than glossed.

    Passing is by value because a parameter is bound by assignment and assignment copies; a pointer parameter copies its reference. Neither is a special rule. A structure parameter needs its storage prepared before the copy, which is the one thing a primitive parameter does not -- a structure variable is a run of slots and there is nothing to copy into until the run exists.

    akbasic_value_is_truthy() learned that a pointer is true when it points at something, which had to come with it: without it there is no way to test for the end of a list at all, and comparing a pointer to 0 reads a numeric field it does not carry. A structure is deliberately given no truth value -- it always exists, so the question has no answer worth guessing at.

  3. A statement containing a failed multi-line DEF call still completes, and prints a junk value. The call's error is reported and the run stops, but the enclosing statement carries on with whatever *dest was left holding:

    10 DEF BAD(N#)
    20 RETURN N# / 0
    30 PRINT 99
    40 PRINT BAD(1)
    

    prints 99, the division-by-zero line, and then (UNDEFINED STRING REPRESENTATION FOR 0).

    Pre-existing, and measured as such: identical before and after the re-entrancy fix, on a build stashed back to compare. It is visible more often now only because runaway recursion reaches it where it used to hang instead.

    The cause is that akbasic_runtime_process_line_run() swallows a script's error by design -- goal 3 -- so the loop inside akbasic_runtime_user_function() sees the run end normally and hands back a value nobody should use. The fix is for the multi-line path to notice the run did not return through RETURN and refuse rather than produce a value; it is not done here because it is a decision about what a failed call evaluates to, and tests/language/functions/recursion.bas deliberately does not pin the current answer.

Found while building structures

  1. A host could not run one script twice. Fixed. akbasic_runtime_start() never rewound: RUN sets nextline = 0 and clears stopped, and start() set neither. That cost nothing while a host started a script once, because nextline is already 0 the first time — and cost everything to a host running one script per enemy, where the second start() began past the end and silently did nothing at all. start means start now. Found by writing examples/hoststruct.c, which is exactly that shape.

  2. The prescan wiped host-registered types. Fixed. It runs again on every RUN and used to reinitialise the whole table, so the first RUN unregistered everything the host had registered before loading — with no way for the host to know it had to register them again. It now drops what the script declared and keeps what the host registered, which works because host types are always a prefix and so every index a field already recorded keeps pointing at the same type. tests/hoststruct.c pins it.

  3. A DEF call was not re-entrant, which cost a wrong answer and a hang. Fixed. The function's environment was owned by the funcdef and re-initialised on every call, so a second call trampled the first:

    • Two calls in one expression aliased one slot. DBL(10) + DBL(1) was 4 rather than 22 -- both operands became the last call's answer, because the result was a pointer into the funcdef's own environment. Two different functions were fine, which is most of why it was invisible. Same severity class as §6 item 12, and the same shape: a silent wrong answer from a two-line program.
    • Recursion never came back. The recursive call re-initialised the environment the outer call was still using, so the loop waiting for control could not see it return. No error, no bound, no diagnostic -- the one place in this interpreter that looped forever instead of raising.

    A call takes an environment from the pool now, exactly as GOSUB does, and the result is copied into a caller-scope scratch before that environment goes back. RETURN parks its result on the parent rather than on the environment about to be released, so nothing reads a freed slot. Recursion depth answers to AKBASIC_MAX_ENVIRONMENTS like every other nesting, so too deep is Environment pool exhausted -- a diagnosis where there was none.

    akbasic_FunctionDef.environment is gone with it, as dead state. tests/user_functions.c and tests/language/functions/recursion.bas.

Found while auditing akbasic_value_clone() for the structures work

  1. The numeric operators' else was a catch-all, not a float branch. Fixed. math_minus, math_multiply and math_divide were if ( INTEGER ) … else <treat as float>, and that else read floatval from whatever it was handed. A truth value keeps its payload in boolvalue and leaves floatval zero, so a truth value on the left computed into a field nothing reads and kept its BOOLEAN type:

    (A# == 1) - 1      printed  true      should be -2
    (A# == 1) * 3      printed  true      should be -3
    (A# == 1) + 1      correctly refused
    

    Wrong in value and in type, and silent. math_plus escaped only because it enumerates its cases and ends in an error; the other three now share one require_numeric() guard so a type added later is refused by all of them at once instead of quietly taking the float branch in each.

    The two operands have different rules, deliberately. The left one picks the branch and must be a number. The right one is read through rval_as_int(), which handles a truth value on purpose — 5 - (A# == 1) is 6, and that is the same property that lets AND and OR double as logical operators. Refusing it on the right would have broken every condition in the language, and tests/language/numeric/truth_value_arithmetic.bas pins both halves.

    Found by asking what a structure operand would do, since AKBASIC_TYPE_STRUCT would have taken exactly this path and read a floatval it does not carry. The structures work did not create this defect; it made an already-reachable one worth chasing. Same shape as item 5 and fixed the same way, with the assertions against the value API where the bad value can actually be constructed.

    Two stale comments went with it: src/value.c's file header and a duplicate block above rval_as_int() both cited "TODO.md section 12", which does not exist — the defect list is §6 — and the duplicate described the summing behaviour item 5 had already removed.

Found while writing the error-code appendix

  1. A dependency's status code reaches ER# where the interpreter has one of its own. VAL("XYZ") reports AKERR_VALUE — 144 on this machine — rather than AKBASIC_ERR_VALUE (517), because akbasic_fn_val() (src/runtime_functions.c:261) hands the string to aksl_atof() and returns whatever comes back. Both register the name Value Error, so ERR(ER#) cannot tell them apart and only the number can.

    The consequence is that the number is not portable. libakerror's codes are offsets from the platform's largest errno, so a program testing IF ER# = 144 is right on one machine and wrong on the next, where 517 — which is absolute — is what it should have been able to test. docs/15-error-codes.md documents the behaviour as it stands and tells a program to compare the code rather than the text.

    Not fixed here because it is a decision about a boundary rather than a patch: every place the interpreter calls into libakstdlib on a program's behalf could translate the failure into the akbasic band, and the list of those places wants collecting before the first one is changed. The disk verbs are the interesting counter-case — ENOENT out of DOPEN is genuinely more useful to a program than a flat 519 — so this is not "translate everything", which is exactly why it needs deciding rather than doing.

Found while making line numbers optional

  1. akbasic_environment_set_label() files into the active scope, not the root. src/environment.c:246 walks up until obj->runtime->environment == obj, which is the currently executing scope, despite the comment above it saying "Only the top-level environment creates labels". So a LABEL reached inside a GOSUB or FOR body is filed into that scope and dies when the body pops, while akbasic_runtime_scan_labels() (src/runtime.c:1286) files the same label into the real root.

    The consequence is that a prescanned label and a re-filed one behave differently, and which one you get depends on whether control has passed through the LABEL statement inside a scope. Nothing in either corpus does that, so nothing catches it. The fix is one condition -- walk to parent == NULL -- plus a test that GOSUBs past a LABEL and then branches to it from the top level. Wants checking against deviation 32 first, because "LABEL still executes and re-files itself" is deliberate and the re-filing is the part worth keeping.

  2. Three of the four prescans report against the wrong line. akbasic_runtime_set_mode()'s handler builds its BASIC error line from environment->lineno, which after a load is wherever the loader stopped -- usually the last line of the program. The label, DATA and TYPE scans therefore print ? 47 : for a fault on line 3, and work around it by putting Line %d: in the message text (src/structtype.c:342), so the message names the number twice and the prefix is wrong.

    akbasic_runtime_check_targets() does not have the problem because it points environment->lineno at the line it is walking. That is the fix, and it belongs in the wrapper rather than in four places: give each prescan a way to say which line it failed on, or have each set the cursor as the target check does. Cosmetic, but it is the first number a person reads when a program will not start.

  3. Two lines carrying the same written line number still silently keep the last. akbasic_runtime_file_line() refuses a collision that involves an assigned number, but leaves numbered-onto-numbered alone, which is what a file with 10 PRINT "A" twice has always done. A duplicate in a hand-written file is a mistake rather than an intent, and refusing it would be consistent with the other three cases.

    Not changed with the rest because it is the one collision that can appear in an existing program, so it carries corpus risk the others do not: 41 reference cases and 22 local ones would all have to be shown clean first. src/runtime.c, in the FAIL_NONZERO_RETURN that already names the slot.

  4. akbasic_renumber() keeps two 9999-entry static locals. src/renumber.c holds map[] and rewritten[] at file scope, and akbasic_runtime_check_targets() now adds a static scratch buffer beside them. That is against the no-file-scope-mutable-state rule in MAINTENANCE.md, and it means RENUMBER and the target prescan are not reentrant across two akbasic_Runtimes in one process -- the exact thing that rule exists to guarantee.

    They are static because they will not fit on a default stack, which is the same reason akbasic_Runtime itself is too big for one. The honest fix is to hang them off the runtime like every other pool, which costs another ~2.5MB inline per interpreter for something used by one verb and one prescan. Recorded rather than done, because "make it reentrant" and "do not grow the runtime by a third for a scratch buffer" are both right and picking between them is a decision.

Mutation-testing gaps in the files the game work touched

Recorded the way §8's CI note records src/audio_tables.c: a real test gap rather than a reason to avoid the file.

  1. src/runtime.c has never had a complete mutation run, and the one attempted here was cut off at 551 of 997 mutants after ninety minutes. What it did cover included all three regions the value-pool, trap and skipped-block work touched, and the survivors there are worth naming:

    • report_and_reraise(): closed. Deleting the LOG_ERROR_WITH_MESSAGE survived, because nothing looked at the log -- and that line is the visible half of item 33's second fix. tests/trap_verbs.c now redirects akerr_log_method into a buffer and drives the path with a sink that refuses every write, which kills it.
    • The skipped-block guard (src/runtime.c, the BEND test): four survivors, and three of them are equivalent for any program that can be written -- loopFirstLine != 0 mutated to != 1 differs only for a loop on line 1, waitingForCommand[0] to [1] only for a one-character wait, and flipping strcmp(...) == 0 to != 0 moves the release from the FOR to the NEXT that must follow it. A test with a loop on line 1 would kill the first. The other two are noted rather than chased.
    • A NULL-argument guard in akbasic_runtime_reserve_globals(), which is the same survivor class every file in this tree has: nothing calls these with NULL.

    The remaining 446 mutants are unexamined. A full run is a release-workflow job, not a per-commit one -- .gitea/workflows/release.yaml already mutates the whole tree -- and what this entry is for is that the partial result has been read rather than filed unopened.

    Measured alongside: src/variable.c scores 68.2% with every mutant on the inline- storage lines killed, and src/runtime_trap.c 82.5% with no survivors in set_error_variables(). Both files' remaining survivors are pre-existing and are mostly NULL guards and MAX - 1 arithmetic in string helpers.

Found while writing a game against the interpreter

A complete Breakout, sprites and text grid and keyboard, running for minutes at a time. Nothing in either corpus runs for minutes, which is why the first of these had never been seen.

  1. A variable created inside a scope costs pool slots that never come back, so a long-running program has a hard budget of 4096 name creations. Done — a scalar now lives in the variable rather than in the pool (akbasic_Variable::inlinevalue, src/variable.c), so a GOSUB local, a FOR counter and a DEF parameter all cost nothing at all. Twenty thousand scoped creations where four thousand used to be the whole run. tests/value_pool.c is the coverage, and it asserts the mechanism as well as the consequence — a later change that moved arrays inline too would pass every behavioural case and break the pointer guarantee. An array created in a scope still leaks, which is deliberate and is the same statement akbasic_ValuePool has always made: a pointer into a record outlives the scope that DIMmed it. The record below is what was found and why.

    Also fixed with it: SWAP exchanges whole variable records, so the values pointer that came over named the other variable's inline slot and SWAP silently did nothing. Caught by tests/language/housekeeping/verbs.bas, which is the golden corpus earning its keep.

    The original report: akbasic_ValuePool is a bump allocator on purpose (include/akbasic/value.h:30), and the reason given is that "nothing in BASIC destroys a variable". Scope exit destroys variables. akbasic_runtime_prev_environment() (src/runtime.c:121) marks a popped environment's variables used = false; akbasic_runtime_new_variable() (src/runtime.c:31) memsets the slot it hands back, clearing values and valuecount; and akbasic_variable_init() (src/variable.c:98) therefore takes fresh slots for a variable whose old ones are still counted against obj->next. Nothing decrements it, so every GOSUB that creates a local leaks the local's slots for the life of the run.

    Reduced, and it fails on both builds:

    X# = 0
    FOR T# = 1 TO 6000
      GOSUB SUBA
    NEXT T#
    PRINT "OK " + X#
    END
    
    LABEL SUBA
    LOC# = 1
    X# = X# + LOC#
    RETURN
    

    ? 110 : RUNTIME ERROR Array of 1 elements does not fit in the 0 remaining value slots, at LOC# = 1, on the 4091st call -- 4096 less the handful of names the program itself declared. Both builds, same line, same count. Declare LOC# alongside X# and the same 6000 calls cost nothing at all. A FOR counter behaves the same way: name one that does not already exist and every entry to the loop spends a slot.

    What it costs a program: a game loop that creates one name per tick is dead in half a minute, and the error names the line that happened to be unlucky rather than the line that caused it. Breakout ran twenty-five seconds before this was understood. The workaround is real and not onerous -- declare every name, loop counters included, at the top -- but it has to be known, and right now the only way to learn it is to hit it.

    Three ways out, in increasing order of work. Reclaim on scope exit: give the pool a mark and release, take the mark when an environment is pushed and rewind to it when it pops, which is exactly the discipline the environments themselves already have and is why they are a pool in the first place. Or keep the slice on the recycled variable: stop the memset in new_variable() from clearing values/valuecount and let variable_init()'s existing "reuse the slice when it is already big enough" branch do the work -- much smaller, and it fixes the common case of the same-sized scalar being recreated over and over. Or, at minimum, say so in the manual and in value.h's comment, which currently states the opposite.

    Whichever is chosen, the test is a CTest program that enters and leaves a scope creating a variable tens of thousands of times and returns zero -- and it wants to exist even for the documentation-only outcome, pinned at whatever the real budget turns out to be. Item 33 has to be settled with it: while the pool is dry the failure cannot be trapped, so a program cannot even report this one against itself.

  2. WINDOW cannot be reached in the standalone AKGL build. Donetee_window() (src/sink_tee.c) forwards to whichever half offers one, offered only when one does, with the case in tests/sink_tee.c beside the moveto one. WINDOW 0, 0, 20, 4 now works in the windowed build and still refuses by name without a grid.

    akbasic_sink_init_tee() and akbasic_sink_init_stdio() also now clear moveto and window rather than leaving them alone. Writing the test found it: both are conditional in the tee, so a caller with a sink on the stack got whatever was in that memory, and CHAR decides whether it can move a cursor by testing that pointer for NULL. An initializer that leaves a field alone is not an initializer.

    The grid-size half of this item is closed separately; see item 31a below.

    The original report: akbasic_sink_init_tee() (src/sink_tee.c:143) wires write, writeln, readline, clear and -- conditionally -- moveto, and never sets window. The standalone frontend runs the interpreter against the tee (src/frontend_akgl.c:215), so the fully implemented sink_window() underneath it (src/sink_akgl.c:413) is unreachable: WINDOW 0, 0, 10, 10 in the windowed build answers ? RUNTIME ERROR WINDOW needs a text device with a character grid, and this one has none. The verb is only usable by a host that hands the runtime the akgl sink directly.

    It looks like an oversight rather than a decision, because moveto right above it is forwarded, and with the same reasoning that would apply here. The fix is a tee_window() that forwards to whichever half offers one, offered only when one does, and a case in tests/sink_tee.c beside the moveto one. §3's "Still missing from the sink" closes by calling WINDOW "ordinary work here rather than anything blocked", which is now half true: the work was done and then not plumbed.

    Worth deciding at the same time: a program still has no way to ask how big the text grid is. Done, and with BASIC 7.0's own function rather than more RGR fields. RWINDOW(0) is the current text window's rows and RWINDOW(1) its columns -- the C128 has exactly this function and this interpreter had never implemented it. RWINDOW(2) is refused by name: it reports a 40 or 80 column screen mode and there is no such mode here, so answering 0 would be a plausible lie.

    The cell size in pixels is RGR(3) and RGR(4), beside the surface's own dimensions, because that is a fact about the surface -- and because RWINDOW reports the window, which makes dividing RGR(1) by a column count wrong the moment a program calls WINDOW. Both read a new optional grid entry point on akbasic_TextSink, implemented by the akgl sink and forwarded by the tee, NULL everywhere else so both refuse by name.

    RGR(3) on the standalone build now answers 16 -- the number the Breakout listing had measured by hand and written out as a constant.

    The original note: RGR(1) and RGR(2) give the window in pixels, and nothing gives columns, rows or the cell size, so anything that wants to place a character and a sprite at the same spot has to hardcode a cell size measured against the bundled font -- which is what the Breakout in examples/breakout/characters does, and it is the one thing in that listing that will break on a different font or window. Two more RGR() indices would answer it.

  3. A character written past a short row's terminator is stored where nothing will render it. Done -- putchar_at() (src/sink_akgl.c) fills the gap with spaces before storing, so a positioned write always lands.

    Padded in putchar_at() rather than in sink_moveto(), which is the friendlier of the two options this entry offered and avoids its own objection: moveto stays read-only, and a row is only ever padded when a character actually arrives. The pad fills from the terminator rather than replacing it, because the buffer is not cleared between rows -- replacing only the terminator would expose the tail of whatever longer row used to be there, and tests/akgl_backends.c asserts exactly that case.

    The documented truncation on the way back is unaffected and is asserted beside it.

    The original report: A grid row is a NUL-terminated string and putchar_at() (src/sink_akgl.c:89) writes the character, advances, and terminates -- so CHAR 1, 40, 1, "#" on an empty row stores # at column 40 with text[1][0] still '\0', and sink_akgl_render() draws nothing at all. The same call on a row already 50 characters long draws the # and erases columns 41 onward, which is the documented truncation and is fine.

    It is the silent nothing that is the trap: the write succeeds, the cursor moves, stdout's mirror shows the character, and the window stays blank. Either pad the gap with spaces on a moveto past the terminator, so a positioned write always lands, or say plainly beside CHAR in docs/11-verb-reference.md and deviation 49 that a row is written from column 0 or not at all. Padding is the friendlier of the two and costs one loop in sink_moveto(); the argument against it is that it makes moveto write to the grid, which it currently does not.

  4. A TRAP handler cannot be entered once the value pool is dry, and the error is dropped in silence. Done, in two halves. akbasic_runtime_reserve_globals() (src/runtime.c) creates ER# and EL# at runtime init and again after CLR, so the dispatch never allocates -- there is nothing left for it to fail at. And report_and_reraise() no longer lets a failure inside reporting replace the error the program actually made: the secondary one is logged where a developer will see it and the original is re-raised.

    The reduction below no longer reproduces, and not because of this fix. §6 item 30 made a scalar free, so the value pool can no longer be emptied by creating names and ER#/EL# could be created after all. The defect was still there, reachable through the variable table instead: 124 names and a TRAP armed, and the handler was silently skipped while the program carried on. That is what tests/trap_verbs.c now pins, along with the invariant itself -- both globals present before a program runs.

    The original report: akbasic_trap_set_error_variables() (src/runtime_trap.c:36) sets ER# and EL# through akbasic_runtime_global() (:51 and :53), which creates them if the program never named them. Creating them needs pool slots. So the one error most likely to leave the pool empty -- item 30's -- is the one error whose handler cannot be reached, and because the dispatch fails inside the error path rather than raising, the program keeps running with the failed statement's effect quietly missing.

    It is easy to see, and it is much worse than an abort:

    X# = 0
    TRAP RANOUT
    FOR T# = 1 TO 6000
      GOSUB SUBA
    NEXT T#
    PRINT "OK " + X#
    END
    
    LABEL SUBA
    LOC# = 1
    X# = X# + LOC#
    RETURN
    
    LABEL RANOUT
    PRINT "DIED AT X " + X#
    QUIT
    

    prints OK 4092. The handler never ran, nothing was reported, and X# is 1908 short of the 6000 the loop counted -- a wrong answer delivered as a right one. Add ER# = 0 and EL# = 0 at the top, so the dispatch has nothing left to create, and the same program prints DIED AT X 4090 from its handler as it should.

    Two things want fixing and they are separable. The dispatch should not allocate: create ER# and EL# at runtime init, where there is always room, rather than at the moment the program is in trouble -- they are reserved names and §1's other reserved globals are already there. And a failure inside the trap dispatch should not be swallowed; if the handler cannot be entered, the original error is the thing to report, and reporting it is what the untrapped path already does correctly.

    AKBASIC_ERR_BOUNDS from the pool is documented as "bounded and diagnosable, which is the point" (include/akbasic/value.h:34). With a TRAP armed it is currently neither.

Found while rewriting the game tutorials

Chapters 17 and 18 were rewritten to teach a beginner rather than to explain a finished listing, which meant writing every rule down as a rule instead of as a comment beside the code that dodges it. Three of them turned out to have no entry anywhere. The first two are the same shape as item 12 above — a parser function that consumes one operator where it should loop — and the third is why both games are built out of LABEL and GOTO.

  1. An expression mixing - and + at the same level is evaluated right to left. a - b + c computes a - (b + c). A C128, and every BASIC this descends from, folds a run of same-precedence additive operators left to right.

    PRINT 10 - 3 + 2
    PRINT 1 - 2 - 3
    
    5
    -4
    

    The second line is right and the first is not; 9 is the answer. Nothing fails — this is item 4's failure mode over again, a program that quietly computes something else — and the two tutorials work around it by parenthesising every mixed + and - as a rule rather than where it happens to matter.

    The cause is the grammar's shape, not a missing loop. subtraction() (src/parser.c:425) sits above addition() (:447) as its own precedence level, and both loop correctly — but subtraction()'s right operand is parsed by addition(), which then consumes the + that should have been the next term of the subtraction. 1 - 2 - 3 is right because the outer loop sees both minuses; 10 - 3 + 2 is wrong because the inner one eats the plus first.

    The fix is one additive level rather than two: a single function matching PLUS and MINUS in one while, building left-associatively, with multiplication() beneath it. That is what item 12 left half-done — it made each of the two levels fold left without noticing that there should only have been one. Touches src/parser.c and wants cases in tests/parser_expressions.c beside item 12's, asserting 10 - 3 + 2 is 9 and 10 + 3 - 2 is 11 (which already passes, and is the asymmetry that gives it away).

  2. Only one unparenthesised AND or OR is matched per expression. A second one is left in the token stream, and in the place a program actually writes them — a condition — the THEN check then finds the wrong token and the line is refused:

    X# = 1
    Y# = 2
    IF X# = 1 AND Y# = 2 THEN PRINT "TWO IS FINE"
    IF X# = 1 AND Y# = 2 AND X# = 1 THEN PRINT "THREE IS NOT"
    
    TWO IS FINE
    ? 4 : PARSE ERROR Incomplete IF statement
    

    Parenthesising is the workaround and it works — IF (A AND B) AND C THEN parses — which is what the tutorials tell a reader to do. It is a refusal rather than a wrong answer, so it costs a puzzled minute rather than an evening; item 42 in §5 fixed the case of one AND and stopped there.

    logicalandor() (src/parser.c:329) is if ( akbasic_parser_match(obj, OPS, 2) ) at :339 where subtraction() at :425 is while. This is item 12's defect exactly, on the one operator pair item 12 did not reach, and the reference has the same shape at basicparser.go. The fix is the same fix: while, folding left, reassigning left from the leaf built on the previous pass. tests/parser_expressions.c for the expression form and tests/parser_commands.c for the IF that made it visible.

    Worth doing together with item 35: they are one afternoon, one file, and between them they are the two rules a tutorial currently has to spend a paragraph on.

  3. A GOTO that leaves a FOR or a DO leaks the loop's scope, so a program that uses the pattern as its main loop stops after the thirty-second time round:

    N# = 0
    LABEL TOP
    DO
      N# = N# + 1
      IF N# < 100 THEN GOTO TOP
    LOOP UNTIL N# > 99
    PRINT "SURVIVED " + N#
    
    ? 3 : PARSE ERROR Environment pool exhausted at line 3 (32 in use)
    

    Thirty-two is AKBASIC_MAX_ENVIRONMENTS (include/akbasic/types.h:31), and the branch that reports is the DO being entered for the thirty-third time rather than the GOTO that did the damage — the same misattribution item 2 had. The FOR form behaves identically.

    This is a real pattern rather than a contrivance. A game's main loop is a loop the program leaves from the middle of: losing a life, clearing a level and quitting are all "stop what you are doing and go somewhere else". Both Breakout listings in examples/ are built out of LABEL/GOTO for exactly this reason, which is the shape a reader of chapters 17 and 18 is now told to copy, and it is a workaround rather than a preference — a DO ... LOOP around the frame would be the natural way to write either one.

    The mechanism is the one item 2 documents from the other end: a loop's environment is pushed when the line is parsed and popped by its NEXT or LOOP, and nothing else pops it. GOTO sets the next line and returns (akbasic_cmd_goto(), src/runtime_commands.c); it has no idea it has just left a scope.

    A fix has to know which scopes the branch target is outside of, which the environment stack can answer if a loop's environment records the source line that pushed it: on a GOTO, pop every loop scope whose line is not on the path to the target. That is more machinery than items 35 and 36 want between them, and it is worth pricing against simply documenting it — chapters 13 and 17 both now say "loop with GOTO, never jump out of a DO", which is a rule a reader can follow. Wants tests/for_next.c either way, because the reduction above is four lines and currently nothing asserts it in either direction.

Found while integrating libakgl's collision subsystem

spr_collisions() now answers through akgl_collision_test() and every sprite carries an akgl_CollisionShape and a pooled proxy. Three things were deliberately left for later, and each is here so the reasoning does not have to be reconstructed.

  1. Finish moving COLLISION and BUMP onto the subsystem. Half done. The contact is no longer discarded: RCOLLISION(n, f) reports what was hit, the normal, the depth, the contact point and the minimum translation axis, and tests/akgl_backends.c::test_contact_geometry pins the normal's sign for both parties of a sprite-against-sprite hit -- getting that backwards tells both to move the same way, and nothing else would have noticed. What stands is the pairing and the threshold below. What landed is the narrowphase and the shapes; the seam is still collisions(self, uint16_t *mask) and the pairing is still all-against-all over eight slots with a bounding-box reject in front of it.

    That is the right shape at eight sprites and the benchmark says so: 54.9 ns a scan spread out, 211.8 ns for breakout's layout, against a 1.20 ms frame. A uniform grid over eight proxies costs more in per-proxy insert and move bookkeeping than an all-pairs loop over 28 pairs saves, which is why the proxies are deliberately not registered with any partitioner — akgl_collision_test() takes two positioned proxies and needs no world.

    The threshold is what to record, not the work. Registering the proxies and letting akgl_collision_query_box() or the partitioner's each_pair do the pairing becomes worth it somewhere above eight, and AKBASIC_MAX_SPRITES is a #define (include/akbasic/sprite.h:38). Anybody raising it should have the number in front of them rather than discovering the cost afterwards: re-run tests/collision_perf.c at the new ceiling and compare against the frame row. Note that libakgl's own measurement puts the naive all-pairs sweep at 0.7% of a frame at 64 actors and 11% at 256, so the crossover is well above anything a Commodore-shaped sprite budget will reach.

    Also still open at the seam: the contact is computed and discarded. §6 item 40 and the BASIC surface built on it are what consume it.

  2. Convert the breakout listings and chapters 17 and 18 to the collision verbs. This is the payoff, and it is deliberately not part of the integration commit.

    Neither game calls COLLISION or BUMP even once today. Of roughly 230 lines of collision code across the two, about 19 are sprite-against-sprite; the rest is a sprite against numbers in a BASIC array or against bare constants, because bricks are text characters in one game and stamped pixels inside one big sprite in the other. Static collision geometry is what changes that — a brick becomes a collidable box without spending a sprite slot.

    What goes, concretely:

    • examples/breakout/sprites/breakout.basBALLBRICKS (29 lines) and TESTCELL (28) become a handler that reads which box was hit and which axis to reverse. The hand-written minimum-translation-axis at :661-666 is a contact field. BUILDLIVE and BUILDROW stop maintaining a parallel BRK#() grid.
    • examples/breakout/characters/breakout.basHITTEST (14 lines) and the pixel-to-cell arithmetic go the same way, and PADHIT's five-line overlap test becomes BUMP.

    The documentation cost is the reason this is separate. Chapter 17 Step 10 and chapter 18 Steps 9 and 13 are built around that arithmetic, both chapters' examples are executed by docs_examples, and both were rewritten and validated immediately before this work started. Folding a listing rewrite into a library integration would make neither reviewable.

    Sequence it after §9 item 9 if that is ever fixed, since a game that no longer spends two sprites on the screen has two more to spend on collidable things, and the two rewrites would otherwise touch the same lines twice.

  3. akbasic_runtime_call_function(): call a BASIC function from C with bound values.

    A verb cannot currently take a BASIC function as an argument, and it came up while designing the collision query — a callback per overlapping shape is one obvious shape for that verb. It was rejected for the query on the use case rather than on feasibility: the motivating program wants one answer rather than many, a loop is the idiomatic form in this dialect, and the language already has a callback shaped the way a BASIC programmer expects — an interrupt handler taking a label, which COLLISION uses.

    The finding worth keeping is that the hard half already exists. src/runtime.c:1031 is the multi-line DEF path:

    callenv->gosubReturnLine = callenv->lineno + 1;
    callenv->nextline = fndef->lineno;
    while ( obj->environment != targetenv && obj->mode == AKBASIC_MODE_RUN ) {
        PASS(errctx, akbasic_runtime_process_line_run(obj));
    }
    

    The interpreter already re-enters its own line loop synchronously from inside expression evaluation, with a scope from the pool, re-entrant since §6 item 26, and with recursion depth answering to AKBASIC_MAX_ENVIRONMENTS as a diagnosis rather than a hang.

    What is missing is narrow. Done, as akbasic_runtime_call_function(): the argument binding is split out of the call-site handling, so the AST path evaluates leaves and then calls the same code a verb can call with values it already has. Bounded by item 41 -- a multi-line body only runs while a program is running, which is the case a verb is in and not the case a host with a stopped runtime is in.

    What is still not done is the language half: no verb takes a function yet, and nothing resolves a bare word to a function rather than a label. That wants akbasic_environment_get_function() and the precedent of COLLISION 1, BUMPED taking a bare word by name (src/runtime_sprite.c) -- and a verb that actually needs it, rather than being built on speculation.

    One hazard, if it is ever wired to a collision query. Do not pass the BASIC function through as an akgl_CollisionVisitFunc: it would run inside the partitioner's cell-chain walk, and the callback a brick loop wants removes the brick it was just told about, which is partitioner.remove() rewriting the chain the walk holds a cursor into. Collect the candidates during the walk, close the walk, then dispatch.

  4. A multi-line DEF called outside a running program does not run its body, and says nothing about it.

    DEF TRIPLE(N#)
    T# = N# * 3
    RETURN T#
    PRINT TRIPLE(14)
    

    At the REPL that prints (UNDEFINED STRING REPRESENTATION FOR 0). In a program run from a file the same function answers 42. No error either way.

    akbasic_runtime_user_function() (src/runtime.c) runs a multi-line body by handing control to the call's environment and spinning a line loop until RETURN pops back out, guarded on mode == AKBASIC_MODE_RUN -- which is true only of a program running from a file. In REPL mode the loop is skipped entirely and the result is whatever is in the caller's return slot, which is zero. The single-expression form has no such loop and is unaffected, which is most of why this has gone unnoticed; and tests/user_functions.c drives every case through run_program, so the whole suite is in RUN mode.

    The obvious fix is wrong, and was tried. Widening the guard to mode != AKBASIC_MODE_QUIT makes the interpreter hang rather than answer wrongly: akbasic_runtime_process_line_run() does not advance a REPL-mode runtime the way this loop assumes, so the environment never comes back. Trading a silent wrong answer for a lock-up is worse. The fix wants the REPL's own line cycle driving the body, which is a change to how a call is executed rather than to a condition.

    It also bounds item 40: akbasic_runtime_call_function() reaches a multi-line body only while a program is running, which is exactly the case a verb calling a callback is in and not the case a host poking at a stopped runtime is in.

    A test belongs in tests/user_functions.c asserting the correct contract -- a multi-line function answering 42 at the REPL -- registered in AKBASIC_KNOWN_FAILING_TESTS until it does.

  5. A name that merely contains a branch verb was treated as a branch. Fixed. The target prescan walks a line character by character looking for GOTO, GOSUB, COLLISION and the rest, and checked only the character after a match:

    T# = RCOLLISION(1, 1)
    
    ? 2 : PARSE ERROR Line 2: branch to line 1, which the program did not number. Branch by LABEL, or RENUMBER first
    
    

    COLLISION was found at RCOLLISION's second character, the (1, after it read as a handler line number, and the program was refused -- naming a line that contains no branch. src/renumber.c now requires a non-alphanumeric character on both sides. Its own comment was already right that the trailing check protects GOTOX#; nothing protected XGOTO#, and RCOLLISION is the first name in the language to reach it. tests/unnumbered.c covers all three shapes.

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 ten gaps filed so far are closed upstream, as of libakgl 0.3.0. Nothing here is blocked on libakgl, and this repository carries no libakgl workarounds at all — the four §3 used to list are deleted.

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
Vendored dependencies when embedded added unconditionally, 0.3.0
controller.h not self-contained includes <akgl/actor.h>, 0.3.0
Binding a 2D backend to an existing renderer akgl_render_bind2d(), 0.3.0
akgl_text_rendertextat() on an unbound backend checked, 0.3.0
SOUND's frequency sweep (§5 item 21) akgl_audio_sweep(), 0.3.0
The line editor could not see Shift (§3) akgl_Keystroke + akgl_controller_poll_keystroke(), 0.3.0

Three notes for whoever files the next one. 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. 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. And akgl_controller_poll_key() survives alongside the keystroke form on purpose: GET and GETKEY still use it, because a script asking "was the up arrow pressed" wants a keycode and would get the empty string from the other one.

FILTER is the one verb still refused, and it is not filed as a gap because upstream said plainly what it would take: 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.

One thing went the other way, and it is worth recording because it is the only defect this project has ever sent upstream in a release it also consumed. 0.3.0's vendored-dependency fix also made libakgl's add_test() shadow unconditional. CMake chains command overrides exactly one level deep, so two projects in one tree cannot both shadow it: the second rebinds _add_test to the first and the builtin becomes unreachable to everyone, and this repository's own test registrations recursed until CMake stopped at depth 1000. akbasic is the only consumer that embeds libakgl next to other projects, so it is the only place this could show up. Fixed upstream by restoring the top-level guard and filed as libakgl defect 27.

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.


8. Status

The port is done. The C interpreter passes the Go reference's entire corpus and passes clean under ASan and UBSan. It reproduced that corpus byte for byte until §0.1 retired the requirement; two cases have diverged on purpose since, and tests/reference/README.md lists them.

Gate Result
ctest 109/109 — 41 reference golden cases, 23 local ones, 40 unit tests, 1 known-failing, 3 embedding examples, and docs_examples
ctest with -DAKBASIC_WITH_AKGL=ON 109/109 headless, with akgl_typing skipping itself. The same set minus the three no_device cases the SDL driver contradicts, plus akgl_backends, akgl_frontend, docs_screenshots and akgl_typing — the last of which is the skip, and the akgl_build CI job is where it skips
docs_examples Every fenced block in README.md, MAINTENANCE.md and docs/ executed and byte-compared: 55 programs, 9 transcripts, 63 output comparisons, 2 excerpts, 2 shell blocks and 8 figures in the default build. The C-snippet count reads 0 when the harness is run by hand without --cflags-file; CTest passes it. MAINTENANCE.md documents the fence-tag convention
docs_screenshots 8/8 figures re-rendered and byte-identical to the checked-in PNGs. AKGL build only — rendering a picture needs the SDL half
Golden corpus 41/41 byte-exact from tests/reference/and 41/41 again through the SDL binary, which is most of what proves the frontend changes no output
ASan + UBSan 109/109
Line coverage 94.9% (6767/7130) — above the 90% gate
Function coverage 98.5% (447/454)
Warnings none under -Wall -Wextra
doxygen Doxyfile clean
Mutation (src/symtab.c) 74.1%, against a gate of 65 — see .gitea/workflows/ci.yaml

Two defects surfaced from writing that harness, both fixed with a test that fails when the fix is reverted:

  1. DLOAD overwrote the last line of every program it loaded from a prompt. It scans the file through the runtime's own scanner, so on return the tokens of the calling line are gone and hadlinenumber and environment->lineno describe the last line of the file. The REPL carried on parsing what it believed was still its own buffer, decided the leftovers were program text, and filed the DLOAD command under that line number. src/runtime_commands.c now sets skiprestofline. tests/disk_verbs.c.

  2. A BASIC error typed at the prompt terminated the driver. process_line_run() had always swallowed the context after interpret() put the error line on the sink — goal 3, in the comment, in so many words — but the direct-mode branch of process_line_repl() used a bare PASS. A VERIFY against a file that did not match, which is an ordinary user answer rather than a fault, came out as a stack trace and would have taken an embedding host with it. tests/housekeeping_verbs.c.

  3. An armed TRAP made a parse error vanish outright. Found the same way, writing §8 item 11's error-code appendix: a program with TRAP set and a line that would not parse printed nothing at all and exited zero. No error line, because akbasic_runtime_error() intercepts for the trap and deliberately prints nothing; and no handler, because the parse branch of process_line_run() then set run_finished_mode regardless — QUIT for a file run — so the next line boundary the handler would have been entered at was never reached. Arming an error handler made errors disappear, which is the exact opposite of the verb's purpose, and it was silent in both directions.

    The guard is one if: set the finished mode only when the error was actually reported. The runtime path was always right, because report_and_reraise() never touched the mode.

    And the same branch never recorded the status, so the handler that now runs was handed ER# 0. It sets obj->lasterrorstatus from the context the way report_and_reraise() does, and a trapped SCALE with no arguments now reports 512. Both halves in tests/trap_verbs.c.

None of the three was on any list. All were found by writing down what a chapter claimed and then running it — which is the argument for the harness rather than a footnote to it.

A fourth is open, and it is the same defect as item 2 on a path that fix did not cover. Writing docs/14-architecture.md meant describing where the boundary between a script's error and the host's error actually sits, and the answer is: around parsing and around interpret(), but not around scanning. akbasic_runtime_process_line_repl() and akbasic_runtime_process_line_run() both call akbasic_scanner_scan() under a bare PASS (src/runtime.c:825 and :921), so any error the scanner raises leaves step() as an interpreter error. The reachable one is the token ceiling:

$ printf 'PRINT 1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1\n' | ./build/basic
src/scanner.c:add_token:87: 515 (Out Of Bounds) : Line 0 has more than 32 tokens
... eight more frames ...
akbasic terminated on an unhandled error 515 (Out Of Bounds)

Exit status 1, a stack trace on stderr, and the prompt gone — for a line a person typed. An embedding host is handed a context for what is plainly the script's mistake, which is what goal 3 exists to prevent. It should print ? N : PARSE ERROR Line N has more than 32 tokens and stop the run, exactly as a parse error does two lines further down.

The fix is the same ATTEMPT/HANDLE_DEFAULT/akbasic_runtime_error() wrapper already sitting around akbasic_parser_parse() in both functions, moved up to cover the scan. Three call sites to check, because process_line_runstream() has the same bare PASS and its answer may want to differ: a bad line arriving from a file mid-load is not the same situation as one typed at a prompt. Wants a test in tests/scanner_tokens.c asserting the error line rather than the raised status, and one in tests/housekeeping_verbs.c asserting the REPL survives it — the shape item 2's test already has.

A separate one was found while designing optional line numbers, and is fixed. akbasic_runtime_process_line_runstream() filed the raw buffer, line number and all, while akbasic_runtime_load() and DLOAD filed what the scanner had stripped. The if that chose between them tested obj->mode == AKBASIC_MODE_REPL, and nothing reaches that function except the RUNSTREAM arm of step() — so the stripped branch was dead and every program the driver ran from a file was stored with its own number inside source[]. LIST printed 10 10 PRINT "ONE"; DSAVE would have written it that way and HELP echoed it that way.

It went unseen because no .bas in either corpus calls LIST, and because the one test helper that claimed to load "the way RUNSTREAM would" (tests/runtime_verbs.c:30) stored the scanned line — that is, it encoded the correct contract and so could never catch the wrong one. src/runtime.c now files scanned. tests/runtime_verbs.c drives the real sink path and fails with 10 10 PRINT "ONE" when the fix is reverted.

The step-over-a-leading-number allowance in scan_line_labels() (src/runtime.c:1235) and skip_lineno() (src/structtype.c:42) is kept, and both comments now say why: no path inside the library stores a raw line any more, but akbasic_runtime_store_line() is public and a host may hand it one.

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.

The denominator moved a long way and this is not an explanation of why. The line total went from 4076 to 6222 between the previous entry and this one, which is far more than the work since added — so one of the two measurements is counting something the other is not, and which one is right has not been established. Both figures were produced with gcovr --filter 'src/.*' over a clean tree. Recorded rather than quietly overwritten, because a coverage percentage whose denominator nobody can account for is worth exactly as much as the accounting. The ratio is above the gate on either reading.

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. The number above was produced from a coverage tree outside the source directory entirely, after a find . -name '*.gcda' came back empty. 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 2.0.1 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. 2.0.0 is thread safe and an ABI break (libakerror.so.2): __akerr_last_ignored is thread-local and akerr_next_error() returns an owned reference, neither of which fails to link when mismatched. 2.0.1 fixes an exit status that mattered more to this band than to any other — see below.
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.7.0 soname libakgl.so.0.7. 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. 0.3.0 closed every API gap this port had filed — see §7 — so the four workarounds §3 used to list are gone. 0.4.0 was a leak-and-overread release that changed no public struct. 0.5.0 is the first that broke our source as well as our ABI: it namespaced every exported symbol, so akgl_render_bind2d is akgl_render_2d_bind, akgl_sprite_sheet_coords_for_frame is akgl_spritesheet_coords_for_frame, the renderer/camera/window globals carry the prefix, and _akgl_renderer/_akgl_camera are akgl_default_renderer/akgl_default_camera. include/akbasic/akgl.h asserts the floor. 0.6.0 and 0.7.0 broke nothing here — 0.6.0 is three arcade-physics fixes and a physics.max_timestep property this port does not use, and 0.7.0 reports failures libakstdlib's wrappers were already catching and takes libakerror 2.0.1. The floor moved anyway, because deciding for ourselves which of libakgl's minor releases were really compatible is the judgement the soname exists to take away.

An unhandled error in this band used to exit zero, and 512 is the worst possible base for that. libakerror's default unhandled-error handler ended in exit(errctx->status), and a process exit status is one byte — the kernel keeps the low 8 bits and discards the rest. AKBASIC_ERR_BASE is 512, and 512 truncates to 0, so an unhandled AKBASIC_ERR_SYNTAX reported success to a shell, a CI job or a supervisor watching $?. Every other code in the band came out as some unrelated error's number: 515 as 3, 519 as 7.

libakerror 2.0.1 routes the handler through akerr_exit(), which substitutes AKERR_EXIT_STATUS_UNREPRESENTABLE (125) for anything a byte cannot carry. Verified here rather than taken on trust: a probe raising AKBASIC_ERR_DEVICE through FINISH_NORETURN exits 125.

It was latent here, not live, and that is worth stating precisely rather than claiming a narrow escape. src/main.c handles the context itself and returns EXIT_FAILURE, and every test with a top-level ATTEMPT carries a HANDLE_DEFAULT — so nothing in this repository ever reached the defaulted handler. libakgl was not so lucky and found it the hard way: its tests/character.c had been passing while running one of its four tests, because AKGL_ERR_SDL is exactly 256 and exited 0.

The guard is now two #errors in include/akbasic/error.h and two assertions in tests/version_check.c, including one that fails if AKBASIC_ERR_BASE stops truncating to zero — because the day the band moves is the day this note stops being about our base, and a silent change of subject is how a comment becomes a lie.

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, and it is the only substantial thing left. Done: groups G and I, the GET/GETKEY/SCNCLR part of E, group B, multiple statements per line, and array references in parameter lists. Left: groups A, D, F and J, plus RESTORE and RENUMBER out of B and the sprite group H. None needs anything from libakgl except H.

    Two pieces of structural work come before the verbs, and both were found rather than guessed. Neither is a verb and neither can be skipped by the group that needs it:

    • Block skipping works by source line, and group A needs it to work by statement. waitingForCommand skips forward to a verb one line at a time, so a whole loop written on one line — FOR I# = 1 TO 3 : PRINT I# : NEXT I# — never reaches its NEXT. That is inherited structure rather than a slip, and it did not matter until a line could hold more than one statement. DO/LOOP/WHILE/UNTIL are exactly the verbs people write on one line, so group A starts here or it ships something that does not work.
    • READ has no DATA pointer, and RESTORE is the verb that needs one. §4 has the detail; the short version is that READ skips forward to the next DATA reached in execution order, so a DATA line placed before its READ is never found at all and a second READ re-reads the same line. It is a live defect, not only a blocked verb.

    Ordering suggestion, unchanged in spirit from the original: A (after the restructure), then D and J, which are self-contained, then F, which is 21 verbs of aksl_f* and mostly mechanical. H needs a look at libakgl's sprite and actor API before anything is filed.

  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 1217. All done, each with a regression test in the suite that pinned it. Item 16 was the last, and it was never a coding problem — it was blocked on the fidelity constraint §0.1 retired, and took about ten minutes once that went away. AKBASIC_KNOWN_FAILING_TESTS is empty as a result and tests/known_reference_defects.c is gone.

    §6 items 19 and 11 are now open in a way they were not. They were reproduced deliberately and left alone because faithfulness was the goal; it is not any more. Read them as an ordinary defect list. Item 4 (mathPlus mutates self in place, which CommandNEXT relies on) is the one with real blast radius and still wants FOR/NEXT coverage before anybody touches it. Item 5 (binary operators add both of the right operand's numeric fields) is the one worth doing early: it is currently harmless, it makes two mutants in src/value.c unkillable, and it is a landmine for any future value carrying both.

  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.

  7. Use the actual full SDL window for graphics operations. Done, and the premise turned out to be half wrong in a way worth keeping. The documentation did say coordinates are 320x200 whatever the window is — but nothing ever stretched them: with SCALE off a coordinate went straight to akgl_draw_* as a pixel address, so an 800x600 window drew a listing into its top-left corner and left the rest unused. The described transform did not exist.

    akbasic_GraphicsBackend grew an optional size entry point, require_graphics() asks it before every verb that draws so a resized window is honoured between statements, SCALE maps onto the answer instead of onto the constants, and RGR(1) / RGR(2) let a program read the size. 320x200 is now the fallback for a backend that will not answer. Deviation 18 in §5 has the whole of it, including the off-by-one it fixed on the way.

  8. Error codes are undefined for the user. Donedocs/15-error-codes.md, an appendix covering the four error classes the interpreter prints, the eight codes it owns with what raises each, and the codes that reach ER# from errno, libakerror and libakgl underneath it. The table is not asserted: a program in the chapter trips seven of the eight and prints what it got, and docs_examples byte-compares the result.

    It found three things rather than documenting eight. A parse error under an armed TRAP vanished entirely and a trapped parse error reported ER# 0 — both fixed, both recorded above. And VAL reports a dependency's status code where the interpreter has one of its own, which is §6 item 21 and is filed rather than fixed.

  9. Screenshots for graphics operations. Done — eight figures, six in docs/06-graphics.md and two in docs/08-sprites.md. The item said chapter 8; chapter 8 is sprites and chapter 6 is the drawing verbs, so both got them.

    A figure here is output, not an asset, which is the part worth keeping. Each one is generated by running the listing printed immediately above it:

    • tools/screenshot.c is a second SDL host, and a much smaller one than src/frontend_akgl.c — dummy video driver, software renderer, run to completion, SDL_RenderReadPixels, IMG_SavePNG. The pattern is deps/libakgl/tests/draw.c's and this is its third user. It draws no text layer on purpose: a READY in the corner is noise in a figure about BOX, and skipping it means no font has to be resolved.
    • tools/docs_screenshots.sh reads the new screenshot=NAME fence tag straight out of the markdown, so the picture cannot drift from the code beside it without somebody editing one of the two. size=WxH is the second tag, and SCALE's figure uses it — 640x400, because the point being illustrated is a 320x200 listing filling a larger window, and that point cannot be made on a 320x200 surface.
    • Two gates, and they answer different questions. docs_examples checks that a tagged block has an image, in both configurations, so a figure cannot be added and forgotten. docs_screenshots — a CTest, AKGL build only — re-renders every figure and compares byte for byte, so a listing edited without regenerating fails. The first catches a missing picture; only the second catches a stale one, which is the failure this whole arrangement exists against.

    The byte comparison is a deliberate bet that the dummy driver and the software renderer are reproducible, which is the same bet tests/reference/ already makes about golden output. Verified run-to-run and build-to-build here; what is untested is an SDL upgrade that moves one pixel of a diagonal. If that happens, regenerate the figures in the same commit as the bump — do not weaken the test to a size check, which would pass for every wrong picture that happened to be 320x200.

    The PNGs are checked in, because a reader on the forge has no build tree, and docs/images/README.md says loudly that they are generated so nobody hand-edits one. Regenerating is never part of a build: cmake --build build-akgl --target docs_screenshots is a deliberate act, so a make cannot quietly put eight binary diffs in front of whoever ran it.

    Writing them turned up a documentation defect of this file's own, recorded at §5 deviation 16: that entry claimed in bold that BOX fills on a negative angle, while its own body said the fill was filed rather than implemented. Drawing the figure and getting an outline back is what caught it. akbasic_GraphicsBackend::filled_rect is implemented, recorded by the mock, and reached by no verb at all as a result.


9. Defects found by writing a game against it

examples/breakout/sprites/breakout.bas is a complete Breakout — sprite artwork, powerups, a stroke-font HUD — written in this dialect, for this interpreter, by somebody who had not written a line of it before. Eight things came out of that, and they are here for the same reason §8's three are: none of them was on any list, and all of them were found by using the thing rather than by testing it. The corpus reaches none of them.

Ordered by blast radius. Each has a minimal reproduction that fits on a screen; every one was reduced against build/basic, the stdio build, unless it says otherwise.

  1. Every call to a user-defined function leaks value-pool slots, so a program may call one about four thousand times and then die. Done with §6 item 30, and by the same change: the leaking slot was the call scope's parameter, which is a scalar and no longer comes from the pool. Both DEF forms run eight thousand calls in tests/user_functions.c. The original report:

    DEF ADDIT(N#)
      RETURN N# + 1
    R# = 0
    FOR I# = 1 TO 8000
      R# = ADDIT(I#)
    NEXT I#
    
    ? 5 : RUNTIME ERROR Array of 1 elements does not fit in the 0 remaining value slots
    

    It fails between 4000 and 5000 calls, which is AKBASIC_MAX_ARRAY_VALUES (4096, include/akbasic/types.h:29) at one slot per call — the call scope's parameter. Both DEF forms leak; the single-expression DEF SQR1(X#) = X# * X# fails at the same point. A GOSUB doing the same work runs eight thousand times without complaint, which is the comparison that isolates it.

    akbasic_valuepool_take() (src/value.c:142) is a bump allocator — obj->next += count at :160 — and nothing anywhere returns a slot. The only reset is akbasic_valuepool_init(), from src/runtime.c:209 at startup and src/runtime_housekeeping.c:53 for CLR/NEW. Environments are released back to their own pool (§1.4, and §6 item 11 says so), but the value slots the variables in them held are not: src/environment.c:321 takes them through akbasic_variable_init() and the release path has no counterpart.

    The consequence is a hard ceiling on a whole language feature. A game calling one function per frame at 30fps gets a little over two minutes. It is why the game in question spells its pseudo-random generator as a GOSUB over globals rather than the DEF it wants to be.

    Fixing it means giving the pool a free list, or giving each environment a value arena released with it. The second is closer to the house style and to what §1.4 already does for environments; it touches src/value.c, src/variable.c, src/environment.c and include/akbasic/value.h. A test belongs in tests/user_functions.c: call a DEF ten thousand times and then DIM an array.

  2. A BEGIN block that is skipped leaks a scope for every loop inside it, because FOR and DO create their environment at parse time and the skip is decided at evaluate time. Done — the skip now releases what parsing pushed (akbasic_runtime_interpret(), src/runtime.c), taking the second of the two routes below. tests/structure_verbs.c covers both loop forms inside a routine and the forty-skip top-level case.

    It is narrower than it looks, and the golden corpus is why. The first attempt released the scope on any skip, which broke tests/reference/language/flowcontrol/nestedforloopwaitingforcommand.bas: a zero- iteration FOR skips its body by the same mechanism, and there the orphan is load-bearing -- it is what absorbs the inner NEXT so the outer one still finds its FOR. Releasing it turns that case into "NEXT outside the context of FOR". So the release is conditional on the skip being a block skip, which is testable because a skipped block can never contain a live NEXT wait. Both halves are now asserted side by side in tests/structure_verbs.c.

    The original report:

    N# = 0
    LABEL AGAIN
    N# = N# + 1
    IF 1 = 0 THEN BEGIN
      FOR I# = 0 TO 2
        N# = N# + 1000
      NEXT I#
    BEND
    IF N# < 40 THEN GOTO AGAIN
    
    ? 5 : PARSE ERROR Environment pool exhausted at line 5 (32 in use)
    

    Thirty-two skips is AKBASIC_MAX_ENVIRONMENTS (include/akbasic/types.h:31) at one per skip. Inside a subroutine it announces itself on the very first skip and much more confusingly — the orphaned scope sits between the routine and its caller, so the RETURN after the block reports from a routine that plainly was entered by a GOSUB:

    T# = 0
    GOSUB DOIT
    PRINT "came back"
    END
    LABEL DOIT
    IF 1 = 0 THEN BEGIN
      FOR I# = 0 TO 2
        T# = T# + 1
      NEXT I#
    BEND
    RETURN
    
    ? 11 : RUNTIME ERROR RETURN outside the context of GOSUB
    

    That is the form it was found in, and it costs an evening because the error names the one construct that is not at fault.

    The cause is an ordering mismatch and it is exact. akbasic_parse_do() (src/parser_commands.c:266) and akbasic_parse_for() (:849) both call akbasic_runtime_new_environment() while parsing the line. The block skip is tested in akbasic_runtime_interpret_line() (src/runtime.c:767), which runs after the line has been parsed — so a skipped FOR pushes a scope, its execution is skipped, and the NEXT that would pop it is skipped too. BEGIN itself pushes nothing, which is why a nested BEGIN/BEND inside a skipped block is harmless, and a GOSUB inside one is harmless for the same reason.

    Confirmed for both loop forms: DO/LOOP in a skipped block fails identically. Confirmed for both kinds of enclosing routine: a multi-line DEF reports the same thing.

    A fix has to either push the loop's scope at execution rather than at parse, or have the skip release what parsing pushed. Touches src/parser_commands.c, src/runtime.c and tests/structure_verbs.c, which today only exercises blocks whose bodies are plain statements.

  3. In the standalone SDL build, nothing the graphics verbs draw can be seen unless the program shrinks the text area first. Half done. The tee sink now forwards WINDOW (§6 item 31), so sink_window() is reachable and a program can take rows back from the text layer -- which is the half that made this unanswerable. What stands is the default: the text area is the whole window, so a program that does not call WINDOW still cannot see a drawing.

    Whether that default is right is a real question rather than a defect, and it is entangled with what no WINDOW call fixes -- the frontend never clears and SDL double-buffers, so a drawing has to be re-issued every frame, and it has to fit inside one batch to survive a capture (item 5). Both are now documented in chapters 6, 13 and 14. Sprites remain the only thing visible for free, so the trick the game uses is still the right one for a game; it should not be the only way, and it no longer is.

    The original report, whose second bullet is fixed and whose first stands:

    GRAPHIC 1, 1
    COLOR 1, 3
    BOX 1, 100, 300, 700, 500
    PRINT "THE TEXT SHOWS AND THE BOX DOES NOT"
    LABEL SPIN
    GOTO SPIN
    

    The text appears; the box never does. Two mechanisms combine, and each alone would be survivable:

    • akbasic_sink_akgl_render() fills every row of the text area, opaque, every frame (src/sink_akgl.c:564), and the area is the whole window — state->rows = h / cellh at :505. akbasic_frontend_akgl_pump() calls it after akbasic_runtime_run() and before SDL_RenderPresent, so it paints over everything the program drew during those steps. The comment at :564 explains why it must repaint rather than track dirty rows, and that reasoning is sound; what it does not account for is that the rows it owns are all of them.
    • WINDOW would shrink the text area out of the way, and the akgl sink implements it (sink_window, src/sink_akgl.c:413) — but the tee sink in front of it never assigns obj->window (src/sink_tee.c:143-151 assigns clear and conditionally moveto, and stops). So WINDOW refuses with "needs a text device with a character grid" (src/runtime_console.c:179) in the one build that has a character grid.

    The result is that chapter 6 cannot be run in the interpreter it documents. Its figures are right because tools/screenshot.c deliberately omits the text layer — the comment in that file says so — so the harness that proves the chapter is exactly the harness that hides this. docs_screenshots passes and always would.

    Sprites are unaffected: akbasic_sprite_akgl_render() runs after the sink, which is why the game draws its entire screen by SSHAPE-ing what it drew and installing it with SPRSAV. That is a fine trick and it should not be the only way.

    The tee half is three lines and is worth doing whatever is decided about the rest.

  4. Mixed-type arithmetic is decided by the left operand alone, and nothing says so. Done, as documentation, by decision. The behaviour is unchanged: a dialect is allowed to say the left operand wins and this one is consistent about it, and promotion would change the result of every mixed expression in every existing program. What was missing was anybody saying so. Now: a section in docs/03-the-language.md with the two-line demonstration and the two rules that keep a program out of it, a row in docs/13-differences.md naming it as the difference from 7.0 most likely to turn a working listing into a quietly wrong one, and the reasoning on include/akbasic/value.h where the operators are declared.

    tests/value_arithmetic.c pins it in both directions, so promotion is now a decision somebody has to take deliberately rather than a change that could slip in. The original report:

    A# = 3
    PRINT A# * 0.45
    PRINT 0.45 * A#
    
    0
    1.350000
    

    akbasic_value_math_plus() and its siblings branch on self->valuetype (src/value.c:510, :512, :539) and convert the right operand to match, so an integer on the left silently truncates a float on the right. This is inherited, not introduceddeps/basicinterpret/basicvalue.go:190-193 has the same shape — and §6 item 5 already fixed the other half of that expression without changing the branch.

    It may well be intended; a dialect is allowed to say the left operand wins, and this one is consistent about it. The defect is that it is documented nowhere. Chapter 3's "Numbers" does not mention it, chapter 13 does not list it among the differences, and a C128 promotes to float instead. Nothing fails when you get it wrong — the program computes something else and carries on.

    It cost the game two live bugs before either was noticed. SPD% = 5.6 + LEVEL# * 0.45 evaluated to a flat 5.6, so no level ever ran faster than level one; and 0 - BLVX%(B#), the obvious way to reverse a ball, quantised its velocity to whole pixels on every bounce and bled speed out of it. Both read correctly. Neither produced a diagnostic.

    The cheap fix is documentation: a paragraph in chapter 3 and a row in chapter 13. The other fix is promotion, which would be a real change of semantics and wants its own decision.

  5. A drawing that spans a 256-line batch boundary is torn, not merely transient. Done, as documentation. No fix is possible without changing what a host owns -- the budget is the host's and the present is the host's -- so what was needed was for the consequence to be written down beside the advice it qualifies. docs/13-differences.md now says that "redraw it every frame" is not sufficient on its own and carries the measured numbers, docs/06-graphics.md says it where SSHAPE is introduced, and docs/14-architecture.md explains the mechanism from the step loop's side and points at the TI# synchronisation a program has to build.

    Untested, and deliberately: it needs the real frontend, a clock and a timing window, and a test that reproduced it would be reproducing a race. Said here rather than left looking like an oversight. The original report: §5 and chapter 13 record that drawing does not persist across frames. What neither says is that a single uninterrupted sequence of drawing verbs longer than one akbasic_runtime_run() budget comes back half drawn, because the present in the middle of it discards the buffer — so an SSHAPE at the end captures only the statements issued since that present, over whatever the frame before left behind.

    Measured against AKBASIC_FRONTEND_STEPS_PER_FRAME of 256, in the SDL build: after synchronising to a jiffy edge, 110 DRAW statements in a FOR loop (220 executed lines) are captured whole; 125 (250 lines) capture nothing at all; 140 (280 lines) capture the last fifteen. A program cannot see the boundary except by watching TI# change, which is what the game's PACE routine does and what makes the whole thing workable.

    This is worth a paragraph in chapter 13 beside the existing note, because "redraw it every frame" is the advice there and it is not sufficient advice — the redraw also has to fit.

  6. SSHAPE and GSHAPE ignore the subscript on a string array element. Done -- shape_variable() (src/runtime_graphics.c) now resolves the subscript the way SPRSAV does, through a shared akbasic_environment_collect_subscripts() lifted out of src/environment.c so that a verb taking a variable by name resolves one exactly as assignment does. tests/graphics_verbs.c covers the reduction and the case a program actually wants: two shapes captured into two elements and each stamped back by its own.

    The original report, which needed a graphics device and was reduced against build-akgl/basic:

    DIM SH$(4)
    SSHAPE SH$(2), 0, 0, 61, 4
    PRINT "[" + SH$(0) + "] [" + SH$(2) + "]"
    
    [SHAPE:0] []
    

    The handle lands in SH$(0); SH$(2) stays empty. GSHAPE SH$(2) then stamps whatever is in SH$(0). Ordinary assignment and PRINT honour the subscript, so a program that keeps several saved shapes in an array gets every one of them resolving to element zero, silently.

    shape_variable() (src/runtime_graphics.c:625) takes arg->identifier and calls akbasic_environment_get() — it never evaluates the subscript — and both verbs then use a literal zero: src/runtime_graphics.c:688 writes with zerosubscript, :724 reads with it. SPRSAV is the counter-example and the model for the fix: it calls akbasic_runtime_evaluate() on its argument (src/runtime_sprite.c:480) and handles an array element correctly.

    The game keeps six brick stamps in six separate scalars because of this, and the workaround is not obvious from the failure — every brick simply comes out the colour of the last one captured. tests/graphics_verbs.c is where the regression goes.

  7. GRAPHIC CLR is documented and refused. Done -- akbasic_parse_graphic() (src/parser_commands.c) takes CLR as this verb's keyword argument and emits mode 5, which akbasic_cmd_graphic() already treats as exactly this, so both spellings are one statement and the exec handler is unchanged. The documentation was right and the parser was not; tests/parser_commands.c now pins the form the two chapters give.

    The original report: docs/06-graphics.md:65 and docs/11-verb-reference.md:54 both give the form as GRAPHIC mode | CLR.

    GRAPHIC CLR
    
    ? 1 : PARSE ERROR 1 at 'CLR', Expected expression or literal
    

    GRAPHIC parses with akbasic_parse_arglist (src/verbs.c:86), so CLR scans as the CLR verb rather than as a keyword argument. GRAPHIC 5 does the job and is what the game calls. Either the parse handler learns the word or both documents lose it; the second is smaller and the first is what a C128 programmer will type.

    Worth noting because docs_examples cannot catch it: every fenced GRAPHIC CLR in the documentation is prose, not a tagged runnable block.

  8. A DATA line holding a negative number cannot be executed. Done -- akbasic_parse_data() accepts a unary minus over a numeric literal, which is the only shape that was refused. Deliberately narrow: DATA -A# is still a mistake worth naming, and akbasic_leaf_is_literal() keeps meaning what it says because it is used elsewhere. tests/read_data.c covers reading one and walking past the line, which were two different mechanisms and only one of them was ever wrong.

    The original report: §4 settled that "DATA at run time is now a no-op: it is a declaration, and reaching the statement means walking past it." Walking past a negative one raises instead:

    READ A#
    PRINT "READ GAVE " + A#
    DATA -5
    PRINT "REACHED THE LINE AFTER THE DATA"
    
    READ GAVE -5
    ? 3 : PARSE ERROR Expected literal
    

    READ handles the value correctly — the negative comes back intact — so this is only the statement's own argument parse, in akbasic_parse_data() (src/parser_commands.c:934), refusing a leading - where the prescan accepted it. The positive form falls through cleanly.

    Reachable in ordinary code: a table of DATA at the foot of a program is walked into by any routine above it that ends in a fall-through rather than an END, and a table of angles or offsets is exactly where negative numbers live. tests/read_data.c.

  9. A program that wants a picture on the screen has to spend a sprite slot on it, and examples/breakout/sprites/breakout.bas spends two of its eight on the screen itself. Done for the interpreter; the listing has not been converted yet -- that is §6 item 39.

    The drawing verbs now render into a layer the frame composites under the text and the sprites (akbasic_graphics_akgl_begin/_end/_render, src/graphics_akgl.c), so a drawing survives the frame it was made in. With WINDOW already reachable to shrink the text area, the two halves together are what a picture needed: draw it once, uncover it, and it stays. tests/akgl_backends.c asserts both -- a pixel still present a frame later with nothing redrawn, and a pixel below a shrunk text area surviving the text repaint.

    The layer is transparent where nothing was drawn, which is not a detail: it covers the whole window and composites underneath, so an opaque one would black out the frame the moment a program issued a single DRAW. A fresh SDL target texture's contents are undefined, so it is cleared on creation; skipping that put uninitialised memory under the first frame's text and looked like a driver bug.

    The remaining half of item 3 -- whether the text layer should own the whole window by default -- is untouched and is still a real question rather than a defect.

    The original report:

    That listing draws its play field -- walls, sixty stamped bricks, the lettering -- captures the whole 800 by 540 region with SSHAPE, and installs the capture as sprite 2. It does the same for the HUD strip as sprite 1. Its own header calls them "the screen". It reads as a silly thing to do and it is not: it is the only thing that works, and it is working around the combination of item 3 and item 5 rather than choosing anything.

    Neither defect alone would force it:

    • Item 3 says the text layer owns every row of the window by default, so a drawing is painted over before it is presented. That half is now answerable -- WINDOW is reachable since §6 item 31, so a program can take rows back.
    • Item 5 is what WINDOW does not fix. The frontend never clears and SDL double-buffers, so a drawing has to be re-issued every frame, and a re-issue has to fit inside one 256-line batch to survive a present. Breakout's field is sixty GSHAPE stamps plus two BOXes plus a stroke-drawn banner. It does not fit in one batch and never will.

    So the program cannot draw its field every frame, and a drawing it makes once does not survive. A sprite is drawn from state the interpreter keeps, every frame, for nothing -- which is exactly the property the field needs and the only place in the interpreter that has it.

    What it costs, now measured rather than asserted:

    • Two of eight sprite slots. That is the whole reason the bricks are drawn rather than made of artwork, why only one gem can fall at a time, and why chapter 18 opens with a sprite budget table. Every design compromise in that game traces back here.
    • 4.5% of a frame in collision alone. Sprite 2's bounding box covers the entire play field, so every moving sprite overlaps it permanently and the broad-phase reject can never throw those pairs out. tests/collision_perf.c has the row: 211.8 ns a scan for that layout against 54.9 ns for eight sprites that do not overlap. The game pays it on every one of 256 scans a frame to collide with the backdrop.
    • A whole chapter of documentation. Chapter 18 Step 3 is called "Turn a drawing into a sprite" and exists solely to teach the workaround.

    What would fix it: somewhere to draw that persists and is not a sprite. A drawing layer the sink composites beneath the text layer and the sprites, that a program writes once and the frontend does not discard. GRAPHIC already selects a mode; the natural shape is for a drawing mode to mean "this layer is yours and it stays" rather than "this is thrown away at the next present". That is src/sink_akgl.c and src/frontend_akgl.c, and it is a real design decision about what a frame owns rather than a patch -- which is why this is a filed item and not a fix.

    Nothing in libakgl 0.8.0 supplies it. There is no render-to-texture layer and no persistent surface in include/akgl/renderer.h; frame_start clears the target, which is the opposite. If the answer turns out to want one, it is a §7 filing against libakgl rather than something to build here.

    Do not "fix" this by rewriting the game. The listing is correct for the interpreter it was written against, and item 3's own entry says the trick "is still the right one for a game; it should not be the only way". The game changes when the interpreter gives it something better, and chapters 17 and 18 change with it -- see §6 item 39.

A note on what this list is. Items 1, 2, 6, 7 and 8 are defects by any reading. Item 3 is a design consequence nobody chose, and item 9 is what that consequence costs a real program. Item 4 may be intended and is a documentation gap either way. Item 5 sharpens something already recorded. None of them is a complaint about the interpreter, which was a pleasure to write a real program against — they are the kind of thing one real program finds and a test suite written alongside the implementation structurally cannot, because the suite tests the constructs the implementer had in mind and a game reaches for whatever it needs.