A fifth push job, akgl_build: the text sink and the graphics, audio and input backends, plus the akgl_backends suite that drives them against a real software renderer under the dummy video and audio drivers and reads the pixels back. It stays a separate job rather than a flag on cmake_build because AKBASIC_WITH_AKGL is off by default and that default is the claim being made: the interpreter and its whole 70-case suite build and pass on a machine with no SDL. Keeping the two jobs apart proves that on every push instead of asserting it. Deferring this assumed it needed submodules: recursive and a long build. Both guesses were wrong, and measurement is the only reason we know: - 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". Six submodules initialised non-recursively take about 25 seconds instead. - The build is roughly a minute of CPU across 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 reports "Using system freetype library" and links libfreetype.so.6; without them it would reach for deps/SDL_ttf/external/freetype, which the checkout deliberately does not clone. Two apt packages against two more submodules. Every step was run verbatim from a clean clone before being written down -- checkout, submodule init, configure, build and test -- rather than inferred from the working tree. 71/71. Also corrects three counts elsewhere in the file that had gone stale: the suite is 70 cases rather than 61, line coverage is 93.5% rather than 92.3%, and branch coverage reads 18% rather than 17%. Those numbers are assertions about the gate, so a wrong one is worse than none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
57 KiB
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:
CLAUDE.mdin this repository — project goals, thelibakerrorconvention, the error-code range map, and the dependency versions.deps/libakerror/AGENTS.md— theATTEMPT/CLEANUP/PROCESS/HANDLE/FINISHprotocol.deps/libakerror/UPGRADING.md— 1.0.0's status registry. Required before writing an error code; the mechanism it replaced is gone.deps/libakstdlib/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.deps/libakgl/AGENTS.md— the no-mallocrule and the commit co-author requirement.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
libakgland applies here. - Never hand-edit generated output.
build/trees and the generatedakerror.hare off-limits; change the generator. - Never reformat a file you are not otherwise changing.
- When a step is blocked because
libakglcannot supply a capability, do not work around it here. File it indeps/libakgl/TODO.mdin 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_VALUEno longer exists. The name registry is sparse and takes anyint. There is nothing for a consumer to size, and no compile definition to set. Anywhere you find one, delete it.__AKERR_ERROR_NAMESno longer exists. The table is private.- Codes are absolute integer constants at 256 or above, never
AKERR_LAST_ERRNO_VALUE + N. The offset scheme is what produced the livelibakglcollision documented inCLAUDE.md.
akbasic owns 512–767 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 512–767
and the process must not continue as though it does not.
Do not call akerr_init() first. Every registry entry point calls it, and since 1.0.0 it no
longer clears reservations made before it ran.
1.8 Error message text is 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 newLiteralInt → strconv.ParseInt |
returns err, which the parser reports |
basicgrammar.go newLiteralFloat → strconv.ParseFloat |
returns err |
basicscanner.go matchNumber → strconv.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. The akgl-backed sink and devices — done
-DAKBASIC_WITH_AKGL=ON builds and its suite passes. Four adaptors in the akbasic_akgl
target, which is the only thing here that links SDL:
| 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.
Four things that had to be worked around to get there
All four are libakgl's and all four are filed in deps/libakgl/TODO.md. Each workaround is
commented at its site with the words "filed upstream" so it can be found and deleted later.
-
An embedded
libakgldemands its dependencies be installed. It builds its vendored SDL, SDL_image, SDL_mixer, SDL_ttf and jansson only when it is top-level; embedded, it goes down afind_packagepath and fails on a machine that has none of them — while the submodules sit right there indeps/libakgl/deps. Every lookup is guarded withif(NOT TARGET ...), so ourCMakeLists.txtadds those five subdirectories beforeadd_subdirectory(deps/libakgl)and the vendored copies get used after all. Same trick and same ordering requirementakerror::akerrorandakstdlib::akstdlibalready need. -
akgl/controller.hdoes not compile on its own. It declares two handler function pointers taking anakgl_Actor *and includes nothing that declares the type.src/input_akgl.cincludesakgl/actor.hahead of it. -
There is no way to attach a 2D backend to a renderer you already have.
akgl_render_init2d()installs the six vtable pointers, but it also creates its own window from the game properties and writes to thecameraglobal, so it belongs to theakgl_game_init()path — which is exactly the path an embedding host is not on.tests/akgl_backends.cassigns the six pointers by hand. What is wanted upstream is the vtable half ofinit2don its own. -
akgl_text_rendertextat()segfaults on a backend whose vtable is empty. It reaches throughrenderer->draw_texturewithout checking it, so the firstPRINTthrough the sink dereferences NULL. That is the same class of defect 42b60f7's own commit added a draw test for — "a backend that exists but was never given anSDL_Renderer" — and the text path still has it.
Still missing from the sink
readline reports end of input rather than reading anything: a drawn text layer is not a
source of lines, and INPUT through a graphics sink wants a line editor built on the keystroke
ring. EOF rather than an error is the contract sink.h states, so INPUT handles it already.
That editor is the next piece of work here, and it is what WINDOW and KEY in group E want
as well.
4. Remaining work: language completion (goal 2)
The work queue is the "What Isn't Implemented" list in deps/basicinterpret/README.md.
Each verb is: table row (§1.1) → parse handler if it needs one → exec handler → unit test →
a new .bas/.txt pair. Both kinds of test; they answer different questions.
Order by what unblocks the most, and by what does not need libakgl to grow first:
| Group | Verbs | Blocked on |
|---|---|---|
| A. Structure | DO, LOOP, WHILE, UNTIL, ON, BEGIN, BEND, END |
nothing — reuses waitingForCommand |
| B. Housekeeping | NEW, CLR, CONT, RESTORE, RENUMBER, SWAP, TRON, TROFF, HELP |
nothing |
| C. Errors | TRAP, RESUME, ER, ERR |
needs a BASIC-visible error object |
| D. Strings/format | USING, PUDEF, WIDTH, CHAR |
nothing |
| E. Console | WINDOW, KEY, SLEEP, WAIT, TI |
the sink, or the clock akbasic_runtime_settime() now carries |
| F. Disk | DOPEN, DCLOSE, APPEND, RECORD, HEADER, COLLECT, BACKUP, COPY, CONCAT, RENAME, SCRATCH, DIRECTORY, CATALOG, DCLEAR, DVERIFY, SAVE, LOAD, VERIFY, BLOAD, BSAVE, BOOT |
aksl_f* only; no new akgl |
GRAPHIC, DRAW, BOX, CIRCLE, PAINT, COLOR, SCALE, SSHAPE, GSHAPE, LOCATE |
done — src/runtime_graphics.c, tests/graphics_verbs.c |
|
| H. Sprites | SPRITE, MOVSPR, SPRCOLOR, SPRDEF, SPRSAV, COLLISION |
libakgl sprite/actor API — check before filing |
PLAY, SOUND, ENVELOPE, VOL, TEMPO |
done — src/runtime_audio.c, src/play.c, tests/audio_verbs.c |
|
| I′. Audio, still gapped | FILTER, SOUND's sweep arguments |
libakgl has neither; both refused with AKBASIC_ERR_DEVICE, both filed in §7 |
GET, GETKEY, SCNCLR |
done — src/runtime_input.c, tests/input_verbs.c |
|
| J. Machine | SYS, FETCH, STASH, POKE/PEEK variants |
nothing |
LOCATE used to appear in both E and G. It belongs to G alone: in BASIC 7.0 LOCATE moves the
graphics pixel cursor that DRAW starts from, and the text cursor verb is CHAR, which is
already in group D.
Out of scope, and staying that way — the reference marks these as incompatible with a
modern PC and that reasoning stands: BANK (no bank switching), FAST (irrelevant CPU speed
control), MONITOR (no machine-language monitor).
Also on the queue, from the reference's own defect list:
- Multiple statements per line (
10 PRINT A$ : REM ...). TheCOLONtoken exists (basicscanner.go:40) and nothing consumes it. This changes the parser's statement loop and should be done before group A, becauseDO/LOOPbodies read badly without it. - Array references in parameter lists (
READ A$(0), B#) currently fail to parse. Fix inargumentList.
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.
- Reflection → static dispatch table (§1.1).
- Go maps → fixed open-addressed tables (§1.3).
- Unbounded
new(BasicEnvironment)→ pool with release (§1.4). - Hardcoded stdout+SDL mirror → text sink backend (§1.5).
run()owns the process →step()/ boundedrun()(§1.6).panic()→FAIL_RETURN; no library call terminates the process (§1.6).debug.PrintStack()on parse error → theakerrstack trace only (§3.1 of the original plan).BasicEnvironment.eval_clone_identifiersdeleted as dead state (§4.2 of the original plan).BasicValue.nameandBasicEnvironment.update()deleted as dead: nothing ever writes the field a non-empty string, andupdate()— its only reader — has no callers.- The
DEF-statement bootstrap is gone. The reference declares every builtin by running a BASIC program ofDEFlines 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, andMOD,SPCandSTRare ordinary native handlers. This removes the need to run the interpreter before the interpreter is ready. - 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.
CommandEXITclears the pendingNEXTwait before popping. The reference does not, which leaves the parent waiting for aNEXTthat 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 1–12 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.
-
Hardware verbs reach a device through a backend record, and refuse when there is none.
akbasic_GraphicsBackend,_AudioBackendand_InputBackendare records of function pointers on the runtime; all three may beNULL, which is what the standalone driver gives them. A verb that needs a device it was not given raisesAKBASIC_ERR_DEVICEnaming itself.COLOR,LOCATE,SCALE,ENVELOPE,TEMPOandVOLdeliberately do not require one — they change interpreter state, so a program can configure itself before the host lends it a renderer. -
CIRCLEis a polygon, always. BASIC 7.0'sCIRCLEtakes two radii, a start and end angle, a rotation and a degree increment, which makes it an inc-degree polygon by definition.akgl_draw_circleexists 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. -
SSHAPEstores 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 getsSHAPE:<n>and the surface stays in a fixed pool on the backend.GSHAPEaccepts 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, andLENof it is not its size. Nothing in BASIC does any of those to a shape string except a program deliberately poking at it. -
BOXfills 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. -
GRAPHICrecords 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. -
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.
SCALEmaps user coordinates onto that space; withSCALEoff they pass through. -
PLAYdoes not block. On a C128PLAYholds the program until the last note ends. §1.6 forbids that outright, soPLAYparses the string into a fixed queue and returns;akbasic_runtime_step()releases one note at a time against whatever time the host last passed toakbasic_runtime_settime(). What this changes for a program: the statement after aPLAYruns immediately rather than a bar later, so a listing that relied onPLAYfor timing will race ahead. A program that wants to wait should test the queue rather than assume the verb waits for it. -
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 offCLOCK_MONOTONIC. Left unset it reads zero forever, so every duration has already expired and the queue drains as fast asstep()is called. Audible, deterministic, and never a hang, which is the right way for that to fail. -
PLAY'sM(measure) is a no-op andSOUND's frequency sweep is refused.Msynchronises the three voices on a C128; with one sequential queue there is nothing to synchronise, and a listing full of them should still play, so it is accepted and ignored. The sweep is the opposite call:SOUND'sdir/min/steparguments have noakgl_audio_*equivalent, and the only way to fake one is to re-issue tones fromstep()— which would tie audible pitch to how often the host happens to call us, a tune that changes key with the frame rate. Filed upstream asakgl_audio_sweep; see §7. -
TEMPO's constant is a calibration choice, not a transcription. BASIC 7.0 documentsTEMPOas a relative duration from 1 to 255 defaulting to 8, and does not publish what a whole note actually lasts.src/audio_tables.cuses 16000 ms atTEMPO1, 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. -
GETKEYholds the step loop rather than blocking. A C128 sits on the keyboard insideGETKEYuntil somebody types. §1.6 forbids the library blocking, so the verb sets a flag andakbasic_runtime_step()declines to advance the program until a key arrives. Every step still returns and a boundedakbasic_runtime_run()still comes back, so a host keeps its frame rate; the program simply does not move past theGETKEY, which is the part a program author cares about. ThePLAYqueue is serviced before this check on purpose — music should keep playing while a program waits for a keypress. Withdrawing the input device while aGETKEYis holding releases it rather than wedging the script forever. -
GETandGETKEYinto a numeric variable yield the key code. BASIC 7.0'sGETtakes 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.
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.
basicvariable.go:176—toString()testslen(self.values) == 0and then indexesself.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:PRINTof a scalar through this path is wrong. Fix:if ( count == 1 ).basicvariable.go:108—setBooleanbuilds a value withvaluetype: TYPE_STRING. Consequence: a boolean stored in a variable reads back as a string with an emptystringval. Fix:TYPE_BOOLEAN.basicenvironment.go:157—stopWaiting(command)ignorescommandand clears the wait unconditionally. Consequence: an inner block can clear an outer block's wait. Fix: compare before clearing, and error on a mismatch.basicvalue.go:181—mathPlusmutatesselfin place whenself.mutableis true, while every other operator always clones. Consequence:A# + 1can modifyA#depending on where the value came from. This one is high blast radius; it interacts witheval_clone_identifiersand withCommandNEXT's increment (basicruntime_commands.go:663), which relies on the mutation. Do not fix it beforeFOR/NEXThas full test coverage.basicvalue.go:191and 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.basicvalue.go, all comparisons —destis cloned fromself, so the resulting boolean inheritsself.name. Consequence: a comparison result can be written back to the wrong variable throughenvironment.update().basicruntime_commands.go:484—CommandIFwalksexpr.rightto the end of the chain looking for aTHENcommand, then dereferencesexpr.rightafter the loop guaranteed it isnil. Consequence: the "Malformed IF statement" check is unreachable and nestedIF ... THEN ... ELSEis unverified.basicruntime_commands.go:669—CommandEXITpops the environment withoutstopWaiting, leaving the parent waiting on aNEXTthat will never come.basicruntime.go:149—newVariable()andBasicRuntime.variables[MAX_VARIABLES]are dead; variables live in the environment's map. Do not port them.basicgrammar.go:224—newLiteralIntselects base 8 whenever the lexeme starts with0. Consequence:PRINT 08is a parse error andPRINT 010prints 8. Commodore BASIC has no octal literals. Fix: base 10 unless prefixed0x.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, sotests/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.
-
basicparser.go:329—subtraction()returns after one operator whereaddition()loops, so1 - 2 - 3computes1 - 2and abandons the rest of the line. The remaining- 3is 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 inexponent(), so2 ^ 3 ^ 2has it too. Fix: make both loop the wayaddition()does. Pinned intests/parser_expressions.c. -
basicparser.go:565— a unary-minus argument inflates a function's arity count. The counter walks the.rightchain, and a unary leaf keeps its operand on.right, soABS(-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.basassigns-1to a variable first. Fix: count only the top-level argument chain, not whatever a leaf hangs off.right. Pinned intests/runtime_evaluate.c. -
basicscanner.go:272—matchNextCharreturns 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: setfalsetypebefore returning on the peek failure. Pinned intests/scanner_tokens.c. -
basicscanner.go:318— hex literals do not survive the scanner.matchNumberallowsxthrough but not the hex digits after it, so0xfflexes as0xandffis scanned as a separate identifier. Consequence: the base-16 branch innewLiteralInt(basicgrammar.go:224) is unreachable, and the README's hex support does not exist. Fix: oncexhas been seen, accept[0-9a-fA-F]. Pinned intests/scanner_tokens.c. -
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, soPRINT$misses every table and becomes an ordinary string variable. Consequence: the diagnostic is dead code andPRINT$ = 1is accepted. Fix: strip the suffix before the lookup. Pinned intests/scanner_tokens.c.
Ours, not the reference's
-
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 itsrun()never returned.Exchanging variables with an embedding host works and is documented in
README.md: the host callsakbasic_environment_get()to find or create a variable andakbasic_variable_set_*/_get_subscriptto move values across. Seeding beforeakbasic_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 aFORorGOSUBscope, soobj->environmentis 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 gets0immediately after it. - Reaching for the root explicitly does not help.
akbasic_environment_getonly auto-creates whenobj->runtime->environment == obj(src/environment.c:242), so with a child active it returnsNULLthroughdestwithout raising, and an unchecked host dereferences it.
examples/hostvars.cdemonstrates 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 walksobj->environmentto the root and creates there unconditionally, and point the README at it instead of atakbasic_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 aFORbody and assert the script still sees it afterNEXT; the same from inside aGOSUB; and the NULL-without-error path, which should become an impossible state. - A script suspended part-way through a bounded
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.
Six items are filed upstream and outstanding, at deps/libakgl/TODO.md items 5-9 of "API
gaps blocking akbasic" and item 14 of "Defects". Four are workarounds this repository carries
today, each commented at its site with the words "filed upstream" so it can be found and
deleted: the embedded-dependency CMake trick in our CMakeLists.txt, the akgl/actor.h
include in src/input_akgl.c, and the hand-populated vtable and its NULL-deref hazard in
tests/akgl_backends.c. §3 describes all four. The fifth is SOUND's frequency sweep and the
sixth is the missing version bump recorded in §8.
One further gap is outstanding, and upstream named it itself. The commit that added the audio API
says plainly what it does not cover: "Still missing for a complete BASIC sound vocabulary:
FILTER (SDL3 has no filter primitive; this would need writing), TEMPO and the PLAY
note-string parser, both of which belong in the interpreter rather than here."
TEMPOand thePLAYparser are ours, not a gap. They are group I work in this repository and nothing upstream is waiting on.FILTERis a real gap. BASIC 7.0'sFILTER freq, lp, bp, hp, ressets 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 anakgl_audio_filter()exists,FILTERis accepted by the parser and refused at execution withAKBASIC_ERR_DEVICErather than silently ignored — a program that asks for a low-pass and gets an unfiltered square wave has been lied to.
A second gap turned up while implementing group I, and is filed alongside it:
SOUNDhas no frequency sweep. BASIC 7.0'sSOUND voice, freq, dur, dir, min, stepramps the pitch fromfreqtowardmininstepincrements per tick, in the directiondirselects.akgl_audio_tone(voice, hz, ms)holds one pitch for one duration and there is no entry point that changes pitch over the life of a note. The wanted API is roughlyakgl_audio_sweep(int voice, float32_t from_hz, float32_t to_hz, float32_t step_hz, uint32_t ms), advanced on the mixer's own frame counter — the same place the phase is already derived from, and the reason it belongs there rather than here. Faking it in the interpreter means re-issuing tones fromakbasic_runtime_step(), which ties audible pitch to how often a host calls us: a tune that changes key with the frame rate. Refused withAKBASIC_ERR_DEVICEuntil it lands. Tests would driveakgl_audio_mixby hand and assert the frame at which the pitch has moved, exactly astests/audio.calready does for the envelope.
When the next gap turns up, file it there rather than working around it here.
8. Status
The port is done. The C interpreter reproduces the Go reference byte for byte across its entire test corpus and passes clean under ASan and UBSan.
| Gate | Result |
|---|---|
ctest |
70/70 — 41 upstream golden cases, 5 local ones, 21 unit tests, 2 embedding examples, 1 known-failing |
ctest with -DAKBASIC_WITH_AKGL=ON |
71/71 — the above plus akgl_backends; the akgl_build CI job |
| Golden corpus | 41/41 byte-exact, driven in place from the submodule |
| ASan + UBSan | 70/70 |
| Line coverage | 93.5% (3631/3884) — above the 90% gate |
| Function coverage | 97.8% (269/275) |
| Warnings | none under -Wall -Wextra |
doxygen Doxyfile |
clean |
Mutation (src/convert.c, src/symtab.c) |
see .gitea/workflows/ci.yaml for the current score and gate |
Coverage of the verb groups added for goal 2, since they are the new surface: src/audio_tables.c
and src/graphics_tables.c 100%, src/runtime_input.c 100%, src/runtime_graphics.c and
src/runtime_audio.c 98%, src/play.c 87%. The akbasic_akgl target is not in the coverage
figure — it is not built in a default configuration.
A note on reading that number, because it was misread once while producing it. A stale
build-cov/ left in the source directory from an earlier session was silently folded into the
report by gcovr --root ., which produced the previous run's 92.3% for a tree that had grown
by 800 lines. That is libakgl defect #13 happening here rather than there, and build*/ being
gitignored is exactly what makes it invisible. Keep build trees out of the source directory,
and if a coverage number looks suspiciously unchanged, find . -name '*.gcda' before believing
it.
Branch coverage reads 18.0% and is not a target, for the reason libakgl/TODO.md and
libakstdlib/TODO.md both give: the akerror control-flow macros expand into large branch trees
at every call site, most of them unreachable in normal operation. Track line and function
coverage.
Dependency baseline:
| Submodule | Version | Notes |
|---|---|---|
deps/libakerror |
1.0.0 | Private ownership-enforced status registry. akbasic reserves 512–767 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 256–260. 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. 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.1built 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, notrecursive. The build needslibakerror,libakstdlibandbasicinterpret; it does not needlibakgl, which is guarded behindAKBASIC_WITH_AKGLand defaults OFF — and recursing into it would clone SDL, SDL_image, SDL_mixer, SDL_ttf and jansson for a target that is never configured. Thedocsjob needs no submodules at all. - The push-path
mutation_testjob is bounded tosrc/convert.candsrc/symtab.c, about four minutes between them, scoring 77.8% against a gate of 65.src/value.cis 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 byrelease.yaml, or locally withcmake --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:
-
§4 — the language completion work queue. Groups G and I and the
GET/GETKEY/SCNCLRpart of E are done. Of what is left, multiple statements per line should come first: theCOLONtoken exists and nothing consumes it, andDO/LOOPin group A reads badly without it. Then groups A, B, D, F and J, none of which need anything fromlibakgl. -
A line editor for the akgl sink.
readlinereports EOF, soINPUTdoes not work through a drawn text layer. It wants the keystroke ring plus a cursor, and it is also whatWINDOWandKEYin group E need. See §3. -
CI does not coverDone — the-DAKBASIC_WITH_AKGL=ON.akgl_buildjob 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: recursiveis not needed and costs 461 MB. It descends intoSDL_image/external,SDL_mixer/externalandSDL_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-devandlibharfbuzz-dev: SDL_ttf prefers the system copies and would otherwise reach forexternal/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.
-
§6 items 12–17 — 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.
-
Mutation survivors in
src/value.c. The harness is in place (scripts/mutation_test.py, themutationCMake target, and a CI job onsrc/convert.c), and a partial run oversrc/value.cturned 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→+ 1and →- 0survive atsrc/value.c:47-48, inset_string'sstrncpyand 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 inakbasic_valuepool_init, as doesobj->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_intandrval_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.ctakes about 70 minutes to mutate in full (368 mutants, ~11s each, because almost everything links against it), which is why CI runssrc/convert.cinstead and the whole-tree run is a localcmake --build build --target mutation.