Files
akbasic/TODO.md
Andrew Kesterson 9d99d0a67e Draw: implement the BASIC 7.0 graphics verbs
GRAPHIC, COLOR, DRAW, BOX, CIRCLE, PAINT, SCALE, SSHAPE, GSHAPE and LOCATE, all
against the akbasic_GraphicsBackend record rather than akgl_draw_* directly, so
src/runtime_graphics.c includes no SDL and the whole group is testable in a build
with no SDL on the machine.

The reference lists every one of these as unimplemented, so the semantics come
from Commodore BASIC 7.0 rather than from a port, and four places where a modern
renderer cannot do what a C128 did are recorded in TODO.md section 5 rather than
silently substituted:

- CIRCLE is drawn as a polygon of inc-degree segments and akgl_draw_circle is
  deliberately unused. 7.0's CIRCLE takes two radii, an arc range and a rotation,
  so the primitive could serve only the fully-defaulted call, and a shape that
  changed character depending on whether the radii happened to be equal would be
  worse than one uniformly a polygon.
- SSHAPE puts a SHAPE:<n> handle in the string variable rather than the pixels,
  because a value's string is a fixed 256 bytes and a region is a device surface.
  GSHAPE refuses a string without that prefix instead of parsing whatever digits
  it finds and pasting an unrelated slot.
- BOX fills on a negative angle; 7.0 puts the fill flag after the rotation, which
  would make a filled box a seventh argument.
- GRAPHIC stores its mode and honours only the one consequence that means
  anything here -- mode 0 is text -- while still refusing an out-of-range mode,
  since that is a typo worth catching.

PAINT surfaces the flood fill's AKERR_OUTOFBOUNDS as an error rather than
success. The device gives up when its span stack runs out having filled *part* of
the region, and a program that cannot tell that happened cannot recover from it.
Note the shape of that handler: HANDLE sets handled = true on the context, so a
FAIL_RETURN from inside the HANDLE block hands the caller something already
marked handled, whose FINISH_LOGIC then declines to pass it up and releases it --
the error disappears and PAINT reports success. Flag inside the block, raise
after FINISH.

COLOR, LOCATE and SCALE need no device on purpose, so a program can set itself up
before a host has lent it a renderer.

Adds a second golden corpus under tests/language/. The corpus in
deps/basicinterpret is a submodule and nothing here may add files to it, but
goal 2's new verbs still need the .bas/.txt half of their coverage. Registered
under local_ so a failure names which corpus it came from. What it can cover is
limited -- these verbs draw rather than print -- so the behaviour that reaches a
device is asserted against tests/mockdevice.h instead.

65/65 ctest, clean under -Wall -Wextra, doxygen clean.

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

48 KiB
Raw Blame History

TODO

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

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


0. Agent protocol

Read these before touching anything, in this order:

  1. CLAUDE.md in this repository — project goals, the libakerror convention, the error-code range map, and the dependency versions.
  2. deps/libakerror/AGENTS.md — the ATTEMPT/CLEANUP/PROCESS/HANDLE/FINISH protocol.
  3. deps/libakerror/UPGRADING.md — 1.0.0's status registry. Required before writing an error code; the mechanism it replaced is gone.
  4. deps/libakstdlib/TODO.md §2.1 — six confirmed defects in the library this port calls into. §1.9 below says which calls are cleared for use; that section is not optional reading, it bans a family of functions the port would otherwise reach for by reflex.
  5. deps/libakgl/AGENTS.md — the no-malloc rule and the commit co-author requirement.
  6. deps/basicinterpret/README.md — the language reference and the unimplemented list.

Rules for working this file:

  • Do not mark an item done until its acceptance command passes on a clean out-of-tree build. "It compiles" is not acceptance.
  • Do update this file in the same commit as the work: strike the item, or replace it with the defect it uncovered. This file holds outstanding items only.
  • Do add the agent program name, model name and version as a commit co-author. That rule comes from libakgl and applies here.
  • Never hand-edit generated output. build/ trees and the generated akerror.h are off-limits; change the generator.
  • Never reformat a file you are not otherwise changing.
  • When a step is blocked because libakgl cannot supply a capability, do not work around it here. File it in deps/libakgl/TODO.md in the numbered prose style of that file's "Carried over" section and note the block in §7 below.

Style, restated so nobody has to go look: C99, 4-space indent, tabs at width 8 (stroustrup), function-body braces in column 0 on their own line, control braces on the same line, always brace, spaces inside control-flow parens — if ( x == y ) {. Pointer star binds to the identifier: char *name. Prefix is akbasic_ for functions, akbasic_TypeName for types, AKBASIC_UPPER_SNAKE for macros. static helpers drop the prefix. Parameter names must match between header and source.


1. Design decisions already made

These are settled. Do not relitigate them mid-port; if evidence says one is wrong, say so in a commit that changes it deliberately, and update this section.

1.1 Reflection becomes one aligned dispatch table

The Go runtime resolves verbs with reflect.MethodByName("Command" + NAME) (basicruntime.go:401), functions with "Function" + NAME, and special parse paths with "ParseCommand" + NAME (basicparser.go:103). C has no reflection and we are not adding any. Replace all three with one static table in src/verbs.c, sorted by name, searched with bsearch(3):

/* name        token type              parse handler          exec handler        */
{ "AUTO",      AKBASIC_TOK_CMDIMM,     NULL,                  cmd_auto      },
{ "DATA",      AKBASIC_TOK_COMMAND,    parse_data,            cmd_data      },
{ "DEF",       AKBASIC_TOK_COMMAND,    parse_def,             cmd_def       },

A NULL parse handler means "parse the rval as a plain expression", which is exactly what commandByReflection returning (nil, nil) means today. Adding a verb is adding one row plus two functions. Keep the table column-aligned and one row per verb — it is a table, so it gets laid out as one.

This also kills the Go scanner's three separate maps (reservedwords, commands, functions, basicscanner.go:64-67): the token type lives in the same row.

1.2 Strings are fixed-size and live inline

libakstdlib has no string type. libakgl has akgl_String but it is PATH_MAX bytes, refcounted, and pool-allocated — wrong shape for a value that gets copied on every assignment, and it would drag a libakgl dependency into the core interpreter.

Define in include/akbasic/types.h:

#define AKBASIC_MAX_STRING_LENGTH    256    /* matches AKBASIC_MAX_LINE_LENGTH */

and give akbasic_Value a char stringval[AKBASIC_MAX_STRING_LENGTH] inline. clone() becomes a struct assignment. No allocator, no refcount, no lifetime question.

Tradeoff, stated: every akbasic_Value is ~300 bytes, so one environment's values[AKBASIC_MAX_VALUES] pool is ~19KB, and 32 environments is ~610KB of BSS. That is fine on a PC and is the price of never calling malloc. If it ever isn't fine, the knob is AKBASIC_MAX_STRING_LENGTH, not the allocator.

Truncation is an error, not a silent clamp: FAIL_RETURN(e, AKBASIC_ERR_VALUE, ...).

1.3 Maps become fixed-capacity open-addressed tables

Five Go maps need replacing:

Go site Purpose C replacement
BasicScanner.reservedwords/commands/functions keyword → token type the §1.1 static table + bsearch
BasicEnvironment.variables name → *BasicVariable akbasic_SymbolTable, capacity AKBASIC_MAX_VARIABLES
BasicEnvironment.functions name → *BasicFunctionDef akbasic_SymbolTable, capacity AKBASIC_MAX_FUNCTIONS
BasicEnvironment.labels name → line number akbasic_SymbolTable, capacity AKBASIC_MAX_LABELS

One implementation, src/symtab.c, keyed by aksl_strhash_djb2() (already in libakstdlib) with linear probing and a fixed slot array. Use the existing hash; do not write another one. Table full is an error, not a resize.

Caveat, from deps/libakstdlib/TODO.md §1.6/§2.2.6: the wrapper sign-extends char, so a high-bit byte hashes wrong — "\xff\xfe" returns 5859874 where the unsigned char answer is 5868578. BASIC identifiers are 7-bit ASCII (the scanner only accepts IsLetter/IsDigit plus a type suffix), so this cannot bite the symbol tables. It would bite if anyone later keys a table on a string literal or a filename. Do not work around it here; it is already filed upstream.

1.4 Environments come from a pool and are released

Go calls new(BasicEnvironment) at basicruntime.go:121 and basicparser_commands.go:124 and never frees one. A long-running GOSUB or FOR in Go leaks; the GC eventually catches some of it, and nothing in the tests notices.

C gets HEAP_ENVIRONMENT[AKBASIC_MAX_ENVIRONMENTS] with akbasic_env_acquire() / akbasic_env_release(), in the shape of akgl_heap_next_*. akbasic_runtime_prev_environment() must release the environment it pops. Pool exhaustion is AKBASIC_ERR_ENVIRONMENT, reported with the current line number.

Watch the one place this is not a clean stack: userFunction (basicruntime.go:348) stores a BasicEnvironment by value inside BasicFunctionDef and re-init()s it on every call. In C the funcdef holds an akbasic_Environment * acquired at DEF time and reset per call — it is owned by the funcdef, not the pool's free list, until the funcdef dies.

1.5 Output goes through a text sink backend

Write() and Println() (basicruntime_graphics.go:140,148) mirror every line to stdout and to an SDL surface. That mirror is the only reason the golden-file suite works. Do not reproduce it as a hardcoded pair of calls.

Define a record of function pointers, populated by an initializer — the house pattern:

typedef struct akbasic_TextSink
{
    void *self;
    akerr_ErrorContext AKERR_NOIGNORE *(*write)(struct akbasic_TextSink *self, char *text);
    akerr_ErrorContext AKERR_NOIGNORE *(*writeln)(struct akbasic_TextSink *self, char *text);
    akerr_ErrorContext AKERR_NOIGNORE *(*readline)(struct akbasic_TextSink *self, char *dest, size_t len);
    akerr_ErrorContext AKERR_NOIGNORE *(*clear)(struct akbasic_TextSink *self);
} akbasic_TextSink;

akbasic_sink_init_stdio() in the library, akbasic_sink_init_akgl() in the akgl-backed module (phase 9). The driver picks. Cursor arithmetic, wrapping and scrolling belong to the akgl sink, not to the interpreter.

1.6 The interpreter steps; it does not run

Go's run() (basicruntime.go:682) is a for {} that owns the process until MODE_QUIT. Goal 3 forbids that: a host game must be able to bound execution.

The library exposes:

akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_step(akbasic_Runtime *obj);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_run(akbasic_Runtime *obj, int maxsteps);

_step() does exactly what one iteration of Go's for {} body does and returns. _run(obj, maxsteps) loops _step() until the mode is AKBASIC_MODE_QUIT or maxsteps steps have elapsed; maxsteps <= 0 means unbounded, which is what the standalone driver passes. This is a deliberate restructure, and it must not change a single byte of golden output.

QUIT sets AKBASIC_MODE_QUIT and returns. Nothing in the library calls exit(), abort(), or FINISH_NORETURN. FINISH_NORETURN appears exactly once in the tree, in src/main.c.

1.7 Error codes are absolute, reserved from the 1.0.0 registry

deps/libakerror is at 1.0.0. Read deps/libakerror/UPGRADING.md before writing an error code; CLAUDE.md's error-code section is the condensed version. Three things this changes from what you may have seen in an older draft or in libakgl:

  • AKERR_MAX_ERR_VALUE no longer exists. The name registry is sparse and takes any int. There is nothing for a consumer to size, and no compile definition to set. Anywhere you find one, delete it.
  • __AKERR_ERROR_NAMES no longer exists. The table is private.
  • Codes are absolute integer constants at 256 or above, never AKERR_LAST_ERRNO_VALUE + N. The offset scheme is what produced the live libakgl collision documented in CLAUDE.md.

akbasic owns 512767 per the range map in CLAUDE.md. Declare it as an enum, so the values stay compile-time integer constants (HANDLE expands to case labels, which require that) and adding a code does not mean renumbering an offset:

#define AKBASIC_OWNER "akbasic"

enum {
    AKBASIC_ERR_BASE        = 512,   /** Start of akbasic's reserved status range */
    AKBASIC_ERR_SYNTAX      = AKBASIC_ERR_BASE,
                                     /** Parse-time grammar violation */
    AKBASIC_ERR_TYPE,                /** Incompatible types in an operation */
    AKBASIC_ERR_UNDEFINED,           /** Reference to an undefined verb, function or label */
    AKBASIC_ERR_BOUNDS,              /** Array subscript or pool index out of range */
    AKBASIC_ERR_ENVIRONMENT,         /** Environment pool exhausted or orphaned environment */
    AKBASIC_ERR_VALUE,               /** A value was malformed, truncated or unconvertible */
    AKBASIC_ERR_STATE,               /** A verb ran outside the block structure it requires */
    AKBASIC_ERR_LIMIT       = AKBASIC_ERR_BASE + 256
};

akbasic_init() reserves the whole 256 in one call and then names each code. Both registry calls return akerr_ErrorContext * and are AKERR_NOIGNORE, so a collision is an ordinary error — PASS it and let it propagate out of init:

akerr_ErrorContext AKERR_NOIGNORE *akbasic_init(void)
{
    PREPARE_ERROR(errctx);

    PASS(errctx, akerr_reserve_status_range(AKBASIC_ERR_BASE,
					    AKBASIC_ERR_LIMIT - AKBASIC_ERR_BASE,
					    AKBASIC_OWNER));
    PASS(errctx, akerr_register_status_name(AKBASIC_OWNER, AKBASIC_ERR_SYNTAX,
					    "Syntax Error"));
    /* ... one per code ... */
    SUCCEED_RETURN(errctx);
}

Reserve the whole range in one call — a subset or superset of your own range raises AKERR_STATUS_RANGE_OVERLAP, not a no-op. Use akerr_register_status_name(), never the two-argument akerr_name_for_status(status, name) set path: the owned form is the one that catches a component writing into a range that is not its own, and the two-argument form is what let libakgl silently clobber libakerror's names.

There is no startup ceiling to assert any more — the BSS-overflow hazard the old draft defended against was deleted along with AKERR_MAX_ERR_VALUE. What replaces it is the reservation itself: if akbasic_init() returns an error, something else owns part of 512767 and the process must not continue as though it does not.

Do not call akerr_init() first. Every registry entry point calls it, and since 1.0.0 it no longer clears reservations made before it ran.

1.8 Error message text is part of the acceptance contract

tests/language/array_outofbounds.txt is, verbatim:

? 20 : RUNTIME ERROR Variable index access out of bounds at dimension 0: 4 (max 2)\n\n

The trailing double newline is real: basicError builds a string ending in \n and hands it to Println, which adds another. Reproduce the message strings and the newline behaviour byte for byte or the golden suite fails for reasons that have nothing to do with the interpreter. Where a Go message reads awkwardly, keep it awkward and note it here.

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

1.9 Which libakstdlib calls are cleared for use, and which are not

deps/libakstdlib is at 0.1.0 and its TODO.md §2.1 lists six confirmed defects. Four carry a known-failing test, so a green ctest over there does not mean what you would assume. Three of the six sit directly in the path of this port. Check this table before reaching for anything in akstdlib.h:

Call Verdict Why
aksl_fopen / _fread / _fwrite / _fclose use DLOAD/DSAVE go through these, never bare libc. aksl_fopen does not NULL-check pathname/mode (§2.2.2) — validate before calling.
aksl_malloc / _free do not use We allocate nothing. Pools only.
aksl_memset / _memcpy use No open defects.
aksl_printf / _fprintf / _sprintf use, under protest The stdio text sink needs them. All three call va_start with no matching va_end on any path (§2.1.4) — undefined behaviour, not ours to fix here, but do not add a fourth wrapper in the same shape.
aksl_atoi / _atol / _atoll / _atof BANNED — see below Cannot report a conversion failure at all (§2.1.5).
aksl_realpath do not use Cannot express a buffer size, never NULL-checks resolved_path, and its own error path reads uninitialised memory (§2.1.6). Nothing in the port needs it.
aksl_strhash_djb2 use See §1.3 for the high-bit caveat, which does not apply to identifiers.
aksl_list_* do not use aksl_list_append silently truncates to a two-node chain and aksl_list_iterate skips the first half of the list — both confirmed (§2.1.1, §2.1.2). The symbol tables in §1.3 are open-addressed and need none of this.
aksl_tree_iterate do not use AKERR_ITERATOR_BREAK does not stop the traversal (§2.1.3). Nothing in the port needs a tree.

The aksl_ato* ban is load-bearing, so here is the whole argument. atoi("not a number") returns success with 0, and atoi("99999999999999999999") returns success with a wrapped value. The Go reference checks the conversion error at every one of these sites and turns it into a BASIC error:

Go site What it does on bad input
basicgrammar.go:224 newLiteralIntstrconv.ParseInt returns err, which the parser reports
basicgrammar.go newLiteralFloatstrconv.ParseFloat returns err
basicscanner.go matchNumberstrconv.Atoi PARSE error, "INTEGER CONVERSION ON '%s'"
basicruntime_functions.go:742 FunctionVAL converts a string at runtime and must be able to fail

Port those onto aksl_ato* and every one of them silently succeeds with 0. That is not a cosmetic loss: it converts four diagnosable errors into wrong answers, and VAL("garbage") starts returning 0.000000 instead of raising. The existing golden files would not catch it — tests/language/functions/val.txt only covers "32", "123.456" and "-256", all valid.

Do this instead: write one local pair of converters in src/convert.c (akbasic_str_to_int64, akbasic_str_to_double) over strtoll/strtod, with errno = 0 before the call, an endptr check for both "no digits consumed" and "trailing junk", and a range check — raising AKBASIC_ERR_VALUE and ERANGE respectively. That is the contract libakstdlib §2.1.5 says the aksl_ato* family should have. When it grows it, delete src/convert.c and switch the call sites over; until then, do not paper over the gap by calling the broken function and hoping.

Acceptance for src/convert.c: tests/convert.c — valid decimal, valid hex, empty string, pure garbage, trailing junk, and an overflowing literal, each asserting the raised status. Add a .bas/.txt pair for VAL on a non-numeric string in the same commit; there is no golden coverage for it today.


2. What exists — the port is complete and green

Phases 0 through 6 of the original plan are done. The interpreter builds clean under -Wall -Wextra, reproduces the reference byte for byte, and passes under ASan and UBSan.

cmake -S . -B build && cmake --build build --parallel
ctest --test-dir build --output-on-failure          # 59/59
cmake -S . -B build-asan -DAKBASIC_SANITIZE=ON      # 59/59
cmake -S . -B build-cov  -DAKBASIC_COVERAGE=ON      # 92.3% line, 96.9% function
Module Source Reference
Strict numeric conversion src/convert.c (new; see §1.9)
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
Driver src/main.c main.go
Embedding examples examples/embed.c, examples/hostvars.c (new)

akbasic_runtime_load(rt, source) was added while writing the README's embedding section: a host usually holds its script as a string and wants the sink reserved for output, and the only path that existed — AKBASIC_MODE_RUNSTREAM reading through the sink's readline — forces a game to point its output device at its source text. examples/embed.c is the code the README quotes, built by every build and registered as a CTest case so a signature change breaks the build rather than rotting the document.

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

Eighteen unit tests cover the modules the corpus cannot reach on its own. tests/known_reference_defects.c is registered in AKBASIC_KNOWN_FAILING_TESTS and asserts the correct contract for six of the defects in §6; when one is fixed CTest reports "unexpectedly passed", which is the cue to split that assertion out and strike the item.

Two budget numbers moved during implementation and are worth knowing: AKBASIC_MAX_ARRAY_ELEMENTS (1024) caps one array and AKBASIC_MAX_ARRAY_VALUES (4096) caps all of them together, drawn from an akbasic_ValuePool the runtime owns. The reference had no such ceiling because it called make().


3. Remaining work: the akgl text sink

Unblocked as of libakgl 42b60f7 — the text-measurement call this waited on now exists.

src/sink_akgl.c. Implement the §1.5 vtable against akgl_text_loadfont, akgl_text_rendertextat and akgl_text_measure. Owns the cursor, the wrap, and the scroll — everything in basicruntime_graphics.go except Write/Println, which are now the sink interface itself.

The character grid comes from akgl_text_measure(font, "A", &w, &h), which is the direct equivalent of the font.SizeUTF8("A") at basicruntime.go:96 that the reference derives maxCharsW/maxCharsH from. For wrapping, akgl_text_measure_wrapped takes the same wraplength argument akgl_text_rendertextat already does, so the measurement and the draw cannot disagree about where a line breaks.

The interpreter does not own the window, the renderer, or the game loop. The sink draws through whatever renderer the host already initialized. akbasic_sink_init_akgl() takes the renderer; it does not create one.

Call akgl_error_init() before anything else in libakgl. New in libakgl 0.1.0 (deps/libakgl/src/error.c): it reserves the 256260 status band and registers a name for each AKGL_ERR_* code. akgl_game_init() calls it first, but we drive subsystems directly and never call akgl_game_init(), so it is ours to call. Skip it and every AKGL_ERR_* that reaches a stack trace prints "Unknown Error" — which is exactly the defect the upstream commit found in game.c, where an SDL failure five lines ahead of the old registration site was guaranteed to be unnamed. It is idempotent, so calling it from both akbasic_sink_init_akgl() and a host that already did is harmless. PASS its result; a range collision is an initialization failure, not a warning.

Still to check before starting: -DAKBASIC_WITH_AKGL=ON has never been configured in this repository, because until now there was nothing to build against. It needs libakgl's own submodules present (git submodule update --init --recursive pulls SDL and friends), and the akbasic_akgl target in CMakeLists.txt has therefore never been compiled. Expect to fix something there on the first attempt.

Acceptance: tests/sink_akgl.c against the offscreen renderer harness described in deps/libakgl/TODO.md under "Remaining work". If that harness still does not exist, a known-failing test plus a libakgl TODO entry saying so.


4. Remaining work: language completion (goal 2)

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

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

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

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

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

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

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

5. Deliberate deviations from the reference

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

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

Deviations in the verbs the reference never implemented

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

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

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

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

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

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

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


6. Reference defects — ported faithfully, fix them deliberately

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

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

Found during the port

Five more, none of which the reference's own corpus catches. Each is reproduced faithfully, pinned by an assertion in the relevant unit test, and asserted correctly in tests/known_reference_defects.c.

  1. basicparser.go:329subtraction() returns after one operator where addition() loops, so 1 - 2 - 3 computes 1 - 2 and abandons the rest of the line. The remaining - 3 is left in the token stream and the statement loop picks it up as a second statement, which evaluates and is discarded. Consequence: a chain of two or more subtractions silently produces the wrong answer. This is the highest-impact item on the list — it is a wrong result, not a refused one. The same shape appears in exponent(), so 2 ^ 3 ^ 2 has it too. Fix: make both loop the way addition() does. Pinned in tests/parser_expressions.c.

  2. basicparser.go:565 — a unary-minus argument inflates a function's arity count. The counter walks the .right chain, and a unary leaf keeps its operand on .right, so ABS(-9) is counted as two arguments and refused with "function ABS takes 1 arguments, received 2". Consequence: no builtin can be called with a negative literal. The corpus hides it — sgn.bas assigns -1 to a variable first. Fix: count only the top-level argument chain, not whatever a leaf hangs off .right. Pinned in tests/runtime_evaluate.c.

  3. basicscanner.go:272matchNextChar returns without setting a token type when it cannot peek past the end of the line, so a comparison operator in the final column is silently dropped. A# = produces one token, not two. Consequence: a line ending in a bare <, > or = loses it, and the parse error that follows points at the wrong place. Fix: set falsetype before returning on the peek failure. Pinned in tests/scanner_tokens.c.

  4. basicscanner.go:318 — hex literals do not survive the scanner. matchNumber allows x through but not the hex digits after it, so 0xff lexes as 0x and ff is scanned as a separate identifier. Consequence: the base-16 branch in newLiteralInt (basicgrammar.go:224) is unreachable, and the README's hex support does not exist. Fix: once x has been seen, accept [0-9a-fA-F]. Pinned in tests/scanner_tokens.c.

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

Ours, not the reference's

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

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

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

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

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


7. Filing gaps against libakgl

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

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

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

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

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

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

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

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


8. Status

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

Gate Result
ctest 61/61 — 41 golden cases, 17 unit tests, 2 embedding examples, 1 known-failing
Golden corpus 41/41 byte-exact, driven in place from the submodule
ASan + UBSan 61/61
Line coverage 92.3% (2823/3058) — above the 90% gate
Function coverage 96.9% (221/228)
Warnings none under -Wall -Wextra
doxygen Doxyfile clean; 114/114 public declarations documented
Mutation (src/convert.c, src/symtab.c) see .gitea/workflows/ci.yaml for the current score and gate

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

Dependency baseline:

Submodule Version Notes
deps/libakerror 1.0.0 Private ownership-enforced status registry. akbasic reserves 512767 in akbasic_error_register(). Does not namespace its coverage target when embedded — worked around in our CMakeLists.txt.
deps/libakstdlib 0.1.0 soname libakstdlib.so.0.1. AKSL_VERSION_CHECK() asserted in tests/version_check.c. Its aksl_ato* family is banned here — see §1.9.
deps/libakgl 0.1.0, pinned at commit 42b60f7 Migrated to the 1.0.0 registry and owns 256260. Not yet linked: AKBASIC_WITH_AKGL defaults OFF and src/sink_akgl.c does not exist. The version number cannot carry this requirement — see below.

The libakgl requirement is pinned by commit, not by version, and that is a defect upstream. 42b60f7 added 22 public symbols across four headers — akgl_draw_* (8), akgl_audio_* (10), akgl_controller_poll_key/_flush_keys, and akgl_text_measure/ _measure_wrapped — and left project(akgl VERSION 0.1.0) and the libakgl.so.0.1 soname unchanged. By that repository's own stated rule, "0.1 and 0.2 are different ABIs", this should have been 0.2.0. Two consequences land here:

  • A binary compiled against the new headers links happily against an installed libakgl.so.0.1 built from the old tree, and fails at symbol resolution rather than at configure time.
  • AKGL_VERSION_AT_LEAST(0, 1, 0) is true for both trees, so nothing here can feature-test for the new API. The submodule pointer is the only thing that expresses the requirement.

Filed in deps/libakgl/TODO.md. When it is fixed, add the floor to the find_package call and say so here.

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/convert.c and src/symtab.c, about four minutes between them, scoring 77.8% against a gate of 65. 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. §3 — the akgl text sink. No longer blocked: libakgl 42b60f7 landed akgl_text_measure, so this is now ordinary work. Note that -DAKBASIC_WITH_AKGL=ON has never been configured here, so the akbasic_akgl target is unproven.

  2. §4 — the language completion work queue. Nothing in it is blocked on libakgl any more: groups G, I and part of E were, and libakgl 42b60f7 closed all three. Multiple statements per line (the COLON token exists and nothing consumes it) should still come first, because DO/LOOP reads badly without it.

  3. §6 items 1217 — the defects the port uncovered. Item 12 is the one that produces a wrong answer rather than a refused one, and should be fixed first.

  4. Mutation survivors in src/value.c. The harness is in place (scripts/mutation_test.py, the mutation CMake target, and a CI job on src/convert.c), and a partial run over src/value.c turned up test gaps worth closing. These are not equivalent mutants; each is a real bug of that shape the suite would not notice:

    • AKBASIC_MAX_STRING_LENGTH - 1+ 1 and → - 0 survive at src/value.c:47-48, in set_string's strncpy and its NUL terminator. Nothing in the suite writes a maximum-length string, so the off-by-one that would truncate or overrun goes unseen. One test that round-trips a 255-character string kills all five.
    • memset(obj, 0, ...)memset(obj, 1, ...) and its deletion survive in akbasic_valuepool_init, as does obj->next = 0= 1. Nothing asserts a freshly initialised pool is actually empty and zeroed.

    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/convert.c instead and the whole-tree run is a local cmake --build build --target mutation.