Its own SS0-SS9 structure survived the move to the tracker, so 133 citations still resolve and are left alone. Thirteen did not. Six named another repository's TODO.md by a section number: libakstdlib 1.6, 2.2.2 and 2.3, and libakgl's file. Those repositories dropped their numbering entirely, so each now names UPGRADING.md, an issue, or the tracker. Three cited 'TODO.md section 12', which has never existed here -- the defect list is SS6. TODO.md itself caught two others of that class earlier. The CMakeLists comment carried a stale premise with it: eleven defects 'deliberately reproduced and not yet fixed' stopped being the rule when SS0.1 retired the fidelity constraint. Four told a reader to record work in TODO.md; the scanner defect is issue #4, the audio_tables mutation gap is #25, and the UI gaps are libakgl #79 and #80. Verified: cmake --build build && ctest --test-dir build, 112/112. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
188 KiB
Record
Outstanding work is in the issue tracker, not in this file: https://source.starfort.tech/andrew/akbasic/issues
This file was the implementation plan for the Go → C port of deps/basicinterpret, written to be
executed by AI agents rather than read for inspiration. The port is done, so what it is now is
the record: the design decisions that are settled, the deviations from the reference and why each
was taken, the defects that were found and fixed, and the reasoning behind the measurements.
Each open item that moved leaves a line saying what it was and which issue carries it, because an entry explaining why a defect matters is worth keeping beside the work it constrains — but the tracking happens there, not here.
Issues are labelled by kind and blast radius, and milestoned by what they can land in: 0.1.x for
anything that changes no public contract, 0.2.0 for new verbs and observable behaviour changes,
1.0.0 for the design decisions. Everything filed carries status::grooming.
Two gaps this file recorded and deliberately did not file — the missing HUD anchors and a
dismissable dialog — are now libakgl #79 and #80. §7's rule is right that changing a dependency
is that repository's decision; it does not follow that reporting the gap is.
0. Agent protocol
0.1 The Go reference is deprecated. Stop matching it.
deps/basicinterpret is a dead project. It will not be updated, and this interpreter is no
longer required to reproduce its behaviour. Recorded here first because it silently reverses
the premise several sections of this file were written on, and because an agent that reads them
without this will park work that is no longer blocked.
What it changes:
- §6 is now an ordinary defect list. "Port the behaviour first so the port is provably faithful" is retired. Fix them because they are wrong, not when fidelity permits.
- §1.8's message-text contract is now a convention. Improving a message is allowed; it costs a golden file, which is a cost rather than a veto.
- §5's bar drops from "defensible against the golden suite" to defensible on its own merits.
tests/reference/becomes a regression suite rather than a specification. Diverging from it is allowed and must be deliberate and recorded — see its README.
What it does not change:
- The corpus stays and stays green. Forty-one real BASIC programs with known-good output are worth having whatever their provenance, and an unexplained change there is still a red flag.
- The Go source stays readable as documentation. It remains the best answer to "what did the original actually do here", which is a question worth being able to answer even once the answer stops being binding.
- Nothing about the
ak*house rules, which never came from the reference.
Read these before touching anything, in this order:
MAINTENANCE.mdin this repository — project goals, thelibakerrorconvention, the error-code range map, and the dependency versions.deps/libakerror/AGENTS.md— theATTEMPT/CLEANUP/PROCESS/HANDLE/FINISHprotocol.deps/libakerror/UPGRADING.md— 1.0.0's status registry. Required before writing an error code; the mechanism it replaced is gone.deps/libakstdlib's issue tracker — the defects and gaps in the library this port calls into. §1.9 below says which calls are cleared for use; that section is not optional reading, it bans a family of functions the port would otherwise reach for by reflex.deps/libakgl/AGENTS.md— the no-mallocrule and the commit co-author requirement.deps/basicinterpret/README.md— the language reference and the unimplemented list. Still worth reading for the verb set and the semantics; no longer binding, per §0.1.
Rules for working this file:
- Do not mark an item done until its acceptance command passes on a clean out-of-tree build. "It compiles" is not acceptance.
- Do update the tracker in the same commit as the work: close the issue, or replace it with the defect it uncovered. Outstanding items live there; this file holds the record.
- Do add the agent program name, model name and version as a commit co-author. That rule
comes from
libakgland applies here. - Never hand-edit generated output.
build/trees and the generatedakerror.hare off-limits; change the generator. - Never reformat a file you are not otherwise changing.
- When a step is blocked because
libakglcannot supply a capability, do not work around it here. File it againstlibakglin its issue tracker -- what the BASIC verb requires, what theakgl_*entry point should look like, and what tests would cover it -- and note the block in §7 below.
Style, restated so nobody has to go look: C99, 4-space indent, tabs at width 8
(stroustrup), function-body braces in column 0 on their own line, control braces on the same
line, always brace, spaces inside control-flow parens — if ( x == y ) {. Pointer star
binds to the identifier: char *name. Prefix is akbasic_ for functions,
akbasic_TypeName for types, AKBASIC_UPPER_SNAKE for macros. static helpers drop the
prefix. Parameter names must match between header and source.
1. Design decisions already made
These are settled. Do not relitigate them mid-port; if evidence says one is wrong, say so in a commit that changes it deliberately, and update this section.
1.1 Reflection becomes one aligned dispatch table
The Go runtime resolves verbs with reflect.MethodByName("Command" + NAME)
(basicruntime.go:401), functions with "Function" + NAME, and special parse paths with
"ParseCommand" + NAME (basicparser.go:103). C has no reflection and we are not adding
any. Replace all three with one static table in src/verbs.c, sorted by name, searched
with bsearch(3):
/* name token type parse handler exec handler */
{ "AUTO", AKBASIC_TOK_CMDIMM, NULL, cmd_auto },
{ "DATA", AKBASIC_TOK_COMMAND, parse_data, cmd_data },
{ "DEF", AKBASIC_TOK_COMMAND, parse_def, cmd_def },
A NULL parse handler means "parse the rval as a plain expression", which is exactly what
commandByReflection returning (nil, nil) means today. Adding a verb is adding one row plus
two functions. Keep the table column-aligned and one row per verb — it is a table, so it
gets laid out as one.
This also kills the Go scanner's three separate maps (reservedwords, commands,
functions, basicscanner.go:64-67): the token type lives in the same row.
1.2 Strings are fixed-size and live inline
libakstdlib has no string type. libakgl has akgl_String but it is PATH_MAX bytes,
refcounted, and pool-allocated — wrong shape for a value that gets copied on every
assignment, and it would drag a libakgl dependency into the core interpreter.
Define in include/akbasic/types.h:
#define AKBASIC_MAX_STRING_LENGTH 256 /* matches AKBASIC_MAX_LINE_LENGTH */
and give akbasic_Value a char stringval[AKBASIC_MAX_STRING_LENGTH] inline. clone()
becomes a struct assignment. No allocator, no refcount, no lifetime question.
Tradeoff, stated: every akbasic_Value is ~300 bytes, so one environment's
values[AKBASIC_MAX_VALUES] pool is ~19KB, and 32 environments is ~610KB of BSS. That is
fine on a PC and is the price of never calling malloc. If it ever isn't fine, the knob is
AKBASIC_MAX_STRING_LENGTH, not the allocator.
Truncation is an error, not a silent clamp: FAIL_RETURN(e, AKBASIC_ERR_VALUE, ...).
1.3 Maps become fixed-capacity open-addressed tables
Five Go maps need replacing:
| Go site | Purpose | C replacement |
|---|---|---|
BasicScanner.reservedwords/commands/functions |
keyword → token type | the §1.1 static table + bsearch |
BasicEnvironment.variables |
name → *BasicVariable |
akbasic_SymbolTable, capacity AKBASIC_MAX_VARIABLES |
BasicEnvironment.functions |
name → *BasicFunctionDef |
akbasic_SymbolTable, capacity AKBASIC_MAX_FUNCTIONS |
BasicEnvironment.labels |
name → line number | akbasic_SymbolTable, capacity AKBASIC_MAX_LABELS |
One implementation, src/symtab.c, keyed by aksl_strhash_djb2() (already in
libakstdlib) with linear probing and a fixed slot array. Use the existing hash; do not
write another one. Table full is an error, not a resize.
Caveat, recorded upstream: the wrapper sign-extends char, so a
high-bit byte hashes wrong — "\xff\xfe" returns 5859874 where the unsigned char answer is
5868578. BASIC identifiers are 7-bit ASCII (the scanner only accepts IsLetter/IsDigit plus
a type suffix), so this cannot bite the symbol tables. It would bite if anyone later keys
a table on a string literal or a filename. Do not work around it here; it is already filed
upstream.
1.4 Environments come from a pool and are released
Go calls new(BasicEnvironment) at basicruntime.go:121 and basicparser_commands.go:124
and never frees one. A long-running GOSUB or FOR in Go leaks; the GC eventually catches
some of it, and nothing in the tests notices.
C gets HEAP_ENVIRONMENT[AKBASIC_MAX_ENVIRONMENTS] with
akbasic_env_acquire() / akbasic_env_release(), in the shape of akgl_heap_next_*.
akbasic_runtime_prev_environment() must release the environment it pops. Pool
exhaustion is AKBASIC_ERR_ENVIRONMENT, reported with the current line number.
Watch the one place this is not a clean stack: userFunction (basicruntime.go:348) stores
a BasicEnvironment by value inside BasicFunctionDef and re-init()s it on every call.
In C the funcdef holds an akbasic_Environment * acquired at DEF time and reset per call —
it is owned by the funcdef, not the pool's free list, until the funcdef dies.
1.5 Output goes through a text sink backend
Write() and Println() (basicruntime_graphics.go:140,148) mirror every line to stdout
and to an SDL surface. That mirror is the only reason the golden-file suite works. Do not
reproduce it as a hardcoded pair of calls.
Define a record of function pointers, populated by an initializer — the house pattern:
typedef struct akbasic_TextSink
{
void *self;
akerr_ErrorContext AKERR_NOIGNORE *(*write)(struct akbasic_TextSink *self, char *text);
akerr_ErrorContext AKERR_NOIGNORE *(*writeln)(struct akbasic_TextSink *self, char *text);
akerr_ErrorContext AKERR_NOIGNORE *(*readline)(struct akbasic_TextSink *self, char *dest, size_t len);
akerr_ErrorContext AKERR_NOIGNORE *(*clear)(struct akbasic_TextSink *self);
} akbasic_TextSink;
akbasic_sink_init_stdio() is in the library and akbasic_sink_init_akgl() is in the
akgl-backed module. The driver is supposed to pick: a default build selects stdio, while an
AKBASIC_WITH_AKGL build selects the SDL text path and mirrors its output to stdout. It does
not do that yet; §3 records the missing standalone frontend. Cursor arithmetic, wrapping and
scrolling belong to the akgl sink, not to the interpreter.
1.6 The interpreter steps; it does not run
Go's run() (basicruntime.go:682) is a for {} that owns the process until MODE_QUIT.
Goal 3 forbids that: a host game must be able to bound execution.
The library exposes:
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_step(akbasic_Runtime *obj);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_run(akbasic_Runtime *obj, int maxsteps);
_step() does exactly what one iteration of Go's for {} body does and returns.
_run(obj, maxsteps) loops _step() until the mode is AKBASIC_MODE_QUIT or maxsteps
steps have elapsed; maxsteps <= 0 means unbounded, which is what the standalone driver
passes. This is a deliberate restructure, and it must not change a single byte of golden
output.
QUIT sets AKBASIC_MODE_QUIT and returns. Nothing in the library calls exit(),
abort(), or FINISH_NORETURN. FINISH_NORETURN appears exactly once in the tree, in
src/main.c.
1.7 Error codes are absolute, reserved from the 1.0.0 registry
deps/libakerror is at 1.0.0. Read deps/libakerror/UPGRADING.md before writing an error
code; MAINTENANCE.md's error-code section is the condensed version. Three things this changes
from what you may have seen in an older draft or in libakgl:
AKERR_MAX_ERR_VALUEno longer exists. The name registry is sparse and takes anyint. There is nothing for a consumer to size, and no compile definition to set. Anywhere you find one, delete it.__AKERR_ERROR_NAMESno longer exists. The table is private.- Codes are absolute integer constants at 256 or above, never
AKERR_LAST_ERRNO_VALUE + N. The offset scheme is what produced the livelibakglcollision documented inMAINTENANCE.md.
akbasic owns 512–767 per the range map in MAINTENANCE.md. Declare it as an enum, so the
values stay compile-time integer constants (HANDLE expands to case labels, which require
that) and adding a code does not mean renumbering an offset:
#define AKBASIC_OWNER "akbasic"
enum {
AKBASIC_ERR_BASE = 512, /** Start of akbasic's reserved status range */
AKBASIC_ERR_SYNTAX = AKBASIC_ERR_BASE,
/** Parse-time grammar violation */
AKBASIC_ERR_TYPE, /** Incompatible types in an operation */
AKBASIC_ERR_UNDEFINED, /** Reference to an undefined verb, function or label */
AKBASIC_ERR_BOUNDS, /** Array subscript or pool index out of range */
AKBASIC_ERR_ENVIRONMENT, /** Environment pool exhausted or orphaned environment */
AKBASIC_ERR_VALUE, /** A value was malformed, truncated or unconvertible */
AKBASIC_ERR_STATE, /** A verb ran outside the block structure it requires */
AKBASIC_ERR_LIMIT = AKBASIC_ERR_BASE + 256
};
akbasic_init() reserves the whole 256 in one call and then names each code. Both
registry calls return akerr_ErrorContext * and are AKERR_NOIGNORE, so a collision is an
ordinary error — PASS it and let it propagate out of init:
akerr_ErrorContext AKERR_NOIGNORE *akbasic_init(void)
{
PREPARE_ERROR(errctx);
PASS(errctx, akerr_reserve_status_range(AKBASIC_ERR_BASE,
AKBASIC_ERR_LIMIT - AKBASIC_ERR_BASE,
AKBASIC_OWNER));
PASS(errctx, akerr_register_status_name(AKBASIC_OWNER, AKBASIC_ERR_SYNTAX,
"Syntax Error"));
/* ... one per code ... */
SUCCEED_RETURN(errctx);
}
Reserve the whole range in one call — a subset or superset of your own range raises
AKERR_STATUS_RANGE_OVERLAP, not a no-op. Use akerr_register_status_name(), never the
two-argument akerr_name_for_status(status, name) set path: the owned form is the one that
catches a component writing into a range that is not its own, and the two-argument form is
what let libakgl silently clobber libakerror's names.
There is no startup ceiling to assert any more — the BSS-overflow hazard the old draft
defended against was deleted along with AKERR_MAX_ERR_VALUE. What replaces it is the
reservation itself: if akbasic_init() returns an error, something else owns part of 512–767
and the process must not continue as though it does not.
Do not call akerr_init() first. Every registry entry point calls it, and since 1.0.0 it no
longer clears reservations made before it ran.
1.8 Error message text is a convention, no longer a contract
tests/language/array_outofbounds.txt is, verbatim:
? 20 : RUNTIME ERROR Variable index access out of bounds at dimension 0: 4 (max 2)\n\n
The trailing double newline is real: basicError builds a string ending in \n and hands it
to Println, which adds another.
This used to be a hard contract and is now a default. The Go implementation is deprecated
and will not be updated, so the two projects are no longer required to match — see §0.1. What
survives is the practical half: these strings and this newline behaviour are what every
expectation in tests/reference/ was written against, so changing one means changing golden
files, and that is worth doing on purpose rather than by accident. A message that reads
awkwardly may now be improved; do it deliberately, move the expectations in the same commit,
and add a line to §5.
Numeric formatting still matches the reference: integers via %" PRId64 ", floats via %f
(Go's %f and C's %f both give six decimals — tests/reference/language/arithmetic/float.txt
confirms). No reason to change it, which is different from not being allowed to.
1.9 Which libakstdlib calls are cleared for use — the bans are lifted
deps/libakstdlib is at 0.2.0, and that release fixed all six of the confirmed defects
this section was built around, plus seventeen contract gaps. The table of bans that used to
live here is gone because it is no longer true, and leaving it would send an agent around a
workaround for a function that now works.
What changed, in the four that mattered to this port:
| Was banned | Now |
|---|---|
aksl_atoi / _atol / _atoll / _atof |
Every one takes a dest pointer and raises AKERR_VALUE on no digits or trailing junk, ERANGE on overflow — exactly the contract this section demanded |
aksl_list_append / _iterate |
Append no longer truncates to two nodes; iterate no longer skips the first half |
aksl_tree_iterate |
AKERR_ITERATOR_BREAK stops the traversal |
aksl_realpath |
Rewritten; no longer reads uninitialised memory on its error path |
Two signature changes reach this repository. aksl_fread and aksl_fwrite now take a
required size_t *nmemb_out and report a short transfer as AKERR_IO instead of a silent
success — so a DSAVE onto a full disk is now an error a program sees, where before it
reported nothing. src/runtime_commands.c passes the count and discards it, which is correct:
the library does the noticing now.
src/convert.c is gone. It existed only because aksl_ato* could not report a conversion
failure, and its own note said what to do when that changed: "When it grows it, delete
src/convert.c and switch the call sites over." That is done. Six call sites now go straight
to the library — newLiteralInt and newLiteralFloat in src/grammar.c, the line number in
src/scanner.c, FunctionVAL in src/runtime_functions.c, INPUT in
src/runtime_commands.c, and GSHAPE's handle in src/runtime_graphics.c.
Three things that fell out of it, recorded because each was checked rather than assumed:
aksl_strtoll(str, NULL, base, &dest)is the exact replacement, notaksl_atoll, wherever a base is involved. Theato*forms are base 10;src/grammar.cpicks base 8 or 16 from the lexeme's prefix. ANULLendptr is what makes trailing junk an error rather than a stopping point, and that is the whole contract this port needs.- The raised status changed from
AKBASIC_ERR_VALUEtoAKERR_VALUE, and the message text with it —VAL("garbage")now saysno digits in "garbage". §1.8 makes message text part of the acceptance contract, so this was checked against the corpus first: no golden file contained a conversion message, which is also why §1.9 used to ask for one. Two now exist,tests/language/numeric/val_refuses_garbageandoctal_literal, so the next change to that text has to move a golden file. tests/convert.cbecametests/numeric_contract.c. The assertions did not become worthless when the wrapper went away — they became assertions about a contract this port depends on and no longer owns, which is worth pinning exactly because a regression in it would makeVAL("garbage")return 0 silently. Same reasoninglibakstdlib's owntests/test_status_registry.cgives for pinning that it reserves no status range.
One caveat survives the upgrade unchanged: aksl_strhash_djb2 still sign-extends char, so a
high-bit byte hashes differently from the unsigned char answer. BASIC identifiers are 7-bit
ASCII so the symbol tables cannot reach it — see §1.3, which is still accurate.
2. What exists — the core port is complete and green
Phases 0 through 6 of the original plan are done. The interpreter builds clean under
-Wall -Wextra, passes the reference's entire corpus, and passes under ASan and UBSan.
It did reproduce the reference byte for byte, and that claim is retired rather than broken:
§0.1 released it, and one case has since diverged deliberately (§6 item 16, listed in
tests/reference/README.md). Everything else still matches, which is worth knowing but is no
longer a gate.
cmake -S . -B build && cmake --build build --parallel
ctest --test-dir build --output-on-failure # 59/59
cmake -S . -B build-asan -DAKBASIC_SANITIZE=ON # 59/59
cmake -S . -B build-cov -DAKBASIC_COVERAGE=ON # 92.3% line, 96.9% function
| Module | Source | Reference |
|---|---|---|
| Symbol table | src/symtab.c |
the five Go maps |
| Value | src/value.c |
basicvalue.go |
| Variable | src/variable.c |
basicvariable.go |
| Tokens and AST leaves | src/grammar.c |
basicgrammar.go + basicparser.go |
| Dispatch table | src/verbs.c |
the three reflection lookups |
| Scanner | src/scanner.c |
basicscanner.go |
| Parser | src/parser.c, src/parser_commands.c |
basicparser*.go |
| Environment | src/environment.c |
basicenvironment.go |
| Runtime | src/runtime.c |
basicruntime.go minus SDL |
| Verbs and functions | src/runtime_commands.c, src/runtime_functions.c |
basicruntime_{commands,functions}.go |
| Text sink | src/sink_stdio.c |
basicruntime_graphics.go's stdout mirror |
| Stdio driver | src/main.c |
main.go minus its SDL frontend |
| Embedding examples | examples/embed.c, examples/hostvars.c |
(new) |
akbasic_runtime_load(rt, source) was added while writing the README's embedding
section: a host usually holds its script as a string and wants the sink reserved for output,
and the only path that existed — AKBASIC_MODE_RUNSTREAM reading through the sink's
readline — forces a game to point its output device at its source text. examples/embed.c
is the code the README quotes, built by every build and registered as a CTest case so a
signature change breaks the build rather than rotting the document.
The acceptance suite is the reference's own corpus, checked in at tests/reference/. All
41 .bas files are registered as individual CTest cases and byte-compared against their .txt
— including the trailing double newline on an error line (§1.8).
It was driven in place out of deps/basicinterpret until 2026-07-31, on the reasoning that
copying a submodule's corpus guarantees drift. That reasoning was sound and was overruled
deliberately: the Go dependency is being deprecated, and a build that cannot run its own
acceptance suite without cloning the implementation it replaced is not finished. The copy is
byte-identical to basicinterpreter@d76162c, and tests/reference/README.md records the
provenance, the cost of the drift nobody is watching for now, and the rule that those
expectations are never edited to suit this interpreter.
Nothing in the build or the suite needs deps/basicinterpret any more, and that is checked
rather than assumed — both configurations were configured, built and run from scratch with the
submodule moved out of the tree. The Commodore font moved too, to assets/fonts/, which was the
other thing tying the build to it; assets/fonts/PROVENANCE.md carries the licence question that
came with it.
Eighteen unit tests cover the modules the corpus cannot reach on its own.
tests/known_reference_defects.c is registered in AKBASIC_KNOWN_FAILING_TESTS and asserts
the correct contract for six of the defects in §6; when one is fixed CTest reports
"unexpectedly passed", which is the cue to split that assertion out and strike the item.
Two budget numbers moved during implementation and are worth knowing:
AKBASIC_MAX_ARRAY_ELEMENTS (1024) caps one array and AKBASIC_MAX_ARRAY_VALUES (4096) caps
all of them together, drawn from an akbasic_ValuePool the runtime owns. The reference had no
such ceiling because it called make().
3. The akgl-backed sink, devices and standalone frontend — done
-DAKBASIC_WITH_AKGL=ON builds and its suite passes, and the build option now changes what
the executable does: an AKGL build of basic opens a window, draws BASIC output into it,
pumps SDL events, lets you type at it, and still puts every byte on stdout. Four adaptors in
the akbasic_akgl target, which is the only thing here that links SDL, are complete and
tested:
| File | Backs |
|---|---|
src/sink_akgl.c |
the §1.5 text sink, over akgl_text_measure and akgl_text_rendertextat |
src/graphics_akgl.c |
akbasic_GraphicsBackend, over akgl_draw_*, and the SSHAPE surface pool |
src/audio_akgl.c |
akbasic_AudioBackend, over akgl_audio_* |
src/input_akgl.c |
akbasic_InputBackend, over akgl_controller_poll_key |
The character grid comes from akgl_text_measure(font, "A", &w, &h) — the direct equivalent of
the font.SizeUTF8("A") at basicruntime.go:96, and the call this was blocked on until
42b60f7. Wrapping is done on the character grid rather than by handing SDL_ttf a wraplength,
because the cursor has to land somewhere definite: a program that PRINTs a long string and
then PRINTs again expects the second to start on the row after the first ended, and only the
code that placed the characters knows which row that is.
The interpreter owns no window, renderer or event loop. Every initializer takes something
the host already made. Each calls akgl_error_init() first: akgl_game_init() would have,
but we drive subsystems directly and never call it, and a code raised before that registration
carries no name into its stack trace. It is idempotent.
Acceptance: tests/akgl_backends.c, a 128x128 software renderer under the dummy video
driver read back with SDL_RenderReadPixels — the pattern deps/libakgl/tests/draw.c
established, which needs no display and no offscreen harness. Registered as the akgl_backends
CTest case, and only when AKBASIC_WITH_AKGL is on.
The standalone frontend
The standalone executable is itself a host, and that half of the Go frontend is now ported —
src/frontend_akgl.c, in its own akbasic_frontend target. The separation is in the build
graph and not only in a comment: akbasic is the interpreter, akbasic_akgl is the four
adaptors that draw through somebody else's renderer, and akbasic_frontend is the one thing in
the repository that creates a window. A game embedding the interpreter links the first two and
not the third.
| Piece | Where |
|---|---|
| Window, renderer, font, the four adaptors | akbasic_frontend_akgl_init() |
| Sink and devices onto a runtime | akbasic_frontend_akgl_attach() |
| One frame: pump events, draw the text layer, present | akbasic_frontend_akgl_pump() |
| The bounded frame loop | akbasic_frontend_akgl_drive() |
| stdout mirror | src/sink_tee.c, in the core library |
Four things are worth knowing about how it came out.
-
The stdout mirror is a sink, not a second write. §1.5 made the sink boundary precisely so the interpreter would never carry the reference's hardcoded pair of calls, and §3 item 5 said it again.
akbasic_sink_init_tee()composes two sinks into one and takesreadlinefrom whichever of the two was named as the reader — which is the whole difference between the two modes: a program read from a file comes in through the stdio half, and typed lines come in through the akgl half's line editor. It lives in the core library and is tested there (tests/sink_tee.c), because composing two function-pointer records needs no SDL. -
The line editor borrows the host's loop a frame at a time.
readlinehas to wait for a typed line, and §1.6 forbids the library blocking — and a sink that sat on the keyboard with no way to pump events would deadlock the process on its firstINPUT. So the host installs anakbasic_AkglPumpwithakbasic_sink_akgl_set_pump(), and the editor calls it between keystrokes. The loop is still the host's.akbasic_frontend_akgl_pump()has that exact signature and is installed as-is. Without a pump the sink reports EOF, which is what it did before and what a host that wants no editor still gets. -
The frame loop is bounded, and that is what makes the close button work. 256 steps per frame (
AKBASIC_FRONTEND_STEPS_PER_FRAME), then pump and present.10 GOTO 10under an unboundedrun()would own the process forever;tests/akgl_frontend.cruns exactly that and asserts the window close ends it. -
The frame is not cleared. The graphics verbs draw straight to the renderer rather than into a display list, so clearing would wipe every
DRAWat the end of the frame it was issued in. The text layer is drawn over whatever is there, which is also how a C128 stacks its text plane on its bitmap plane.
Acceptance: two things, and the second one is the stronger.
tests/akgl_frontend.c, registered as theakgl_frontendCTest case: a program run through the frontend under the dummy driver, asserting the mirrored stdout byte for byte and reading the renderer back to prove the same characters were drawn in the Commodore font; synthetic key events pushed into SDL's own queue and read back through the frontend's input backend, so every link the host owns is in the path; the line editor's fold to upper case, its backspace and its escape; a whole REPL session typed at the window and ended with a typedQUIT; and the window-close path.- The entire golden corpus is driven through the AKGL binary. A
-DAKBASIC_WITH_AKGL=ONbuild registers the same 41 upstream cases against the same executable, which now opens a window to run them, and all 41 still match byte for byte. That is a much broader statement than any hand-written frontend test: the SDL path changes no observable output anywhere in the corpus.
Three no_device.bas local cases are deliberately not registered in an AKGL build. Their
premise is a driver that was given no devices, which is true of the stdio driver and
deliberately false of this one. The refusal path they cover is asserted directly against the
backend records in tests/devices.c, which both configurations build.
Manual check against a real display, 2026-07-31. The dummy driver cannot prove presentation,
so build-akgl/basic was run against X display :0 on a program that prints a line, draws a
BOX and then loops. xwininfo found a real 800x600 window titled BASIC; the window was
captured with import and measured: the first text row carries glyph pixels (mean 65/255), the
box's left edge at x=100 is a solid white line (mean 255), the box interior is black — it
outlines rather than fills — and an empty corner is black. stdout carried HELLO WORLD at the
same time. QUIT exits cleanly with status 0.
The typing half is no longer manual, and it is no longer a gap. It used to read "somebody
should type at it once", because no key-synthesis tool was installed. xdotool now is, and
tests/akgl_typing.sh is registered as the akgl_typing CTest case: it starts the driver under
a pty, waits for the window with xdotool search --sync, gives it keyboard focus, types a
program containing a string literal and a lower-case one, and polls the mirrored stdout
for what they print.
That case earns its keep twice over. It is the only test that covers the path upstream of SDL
— X11 → SDL composition → libakgl's ring → the editor — and everything else synthesises SDL
events, which is precisely how the missing SDL_StartTextInput() shipped with a green suite.
Confirmed by reverting both halves of that fix and watching it fail with the same symptom that
was reported: READY, and nothing after it.
It needs a real X server, a window manager and xdotool, and it steals keyboard focus for about
fifteen seconds. Absent any of those it reports CTest Skipped rather than failing, which is
what it does in CI. AKBASIC_SKIP_INTERACTIVE=1 skips it deliberately.
The five things that had to be worked around — all gone
Every one was filed against libakgl and every one is resolved in its 0.3.0. What is left here
is the note that they existed, because the pattern is the point: file it upstream, comment the
workaround at its site with the words "filed upstream", delete it when it lands.
| Was | Resolved by |
|---|---|
An embedded libakgl required its vendored SDL, SDL_image, SDL_mixer, SDL_ttf and jansson to be installed, so our CMakeLists.txt declared all five by hand first |
It adds them whether or not it is top-level |
akgl/controller.h did not compile on its own — it used akgl_Actor * and included nothing that declared it |
It includes <akgl/actor.h> |
| There was no way to bind a 2D backend to a renderer you already had, so the six vtable pointers were assigned by hand in two files | akgl_render_bind2d() |
akgl_text_rendertextat() dereferenced an unset draw_texture, so the first PRINT through an unbound backend segfaulted |
It checks, and reports AKERR_NULLPOINTER like the draw entry points |
SOUND's frequency sweep had no equivalent and was refused |
akgl_audio_sweep() |
0.3.0 also arrived with a regression that this repository is the one that found, because it
is the only consumer that embeds libakgl alongside other projects: the commit that made the
vendored dependencies unconditional also made its add_test() shadow unconditional, and CMake
chains command overrides exactly one level deep. Two projects in one tree cannot both shadow
add_test — the second rebinds _add_test to the first and the builtin becomes unreachable to
everybody, so our own registrations recursed to CMake's depth limit. Fixed upstream by putting
the top-level guard back; filed as libakgl defect 27, with the two-line CMake program that
demonstrates the one-level limit.
Still missing from the sink
Nothing that blocks a verb, and as of libakgl 0.3.0 nothing that blocks a character either.
The ring now carries the composed UTF-8 text SDL worked out from each keystroke, so the editor
takes that in preference to the keycode and shifted characters, keyboard layouts, compose keys
and dead keys all work. A double quote can be typed, so a BASIC string literal can be typed,
which was the sharp end of the old limitation — it used to mean a program with a string in it
had to be passed on the command line. Letters are no longer folded to upper case, because there
is no longer any reason to.
The host has to turn text input on, and forgetting to is how this broke once. SDL3 has it
off by default and per-window, so it is akbasic_frontend_akgl_init() that calls
SDL_StartTextInput() — akgl/controller.h says as much. Without it SDL emits no
SDL_EVENT_TEXT_INPUT at all, every keystroke reaches the ring with an empty text, and an
editor that reads that as "not a character" is silently dead: nothing echoes in the window, and
nothing reaches stdout either, because RUN can never be typed.
The editor therefore falls back to the keycode when a keystroke carries no composed text,
folded to upper case. A worse keyboard is a great deal better than no keyboard, and it means a
host that embeds these adaptors and forgets SDL_StartTextInput() gets a usable editor rather
than a dead one. Both halves are asserted in tests/akgl_frontend.c, and both were checked by
reverting each in turn and watching the test fail.
Worth knowing about the test suite that missed this: every other keyboard test pushes
SDL_EVENT_TEXT_INPUT into SDL's queue by hand, which is what a real keyboard produces — once
text input has been started. Synthesising the end of a chain cannot test the beginning of it.
The new test asserts SDL_TextInputActive() directly for that reason, and the whole frontend
suite has been run against a real X11 window as well as the dummy driver.
One limit remains, and it is a choice rather than a gap:
- No cursor movement within a line. Backspace and escape only. The arrow keys stay in the
ring for a script's own
GETloop, which is the more valuable use of them.
A non-ASCII character is accepted by the keyboard and then dropped rather than stored, which is §1.2 rather than the editor: the grid is a byte per cell and a value's string is a fixed 256 bytes, so a multi-byte character has nowhere to go. Dropping it is honest where storing half of it is not.
WINDOW and KEY in group E want a text-window rectangle and a function-key table on top of
this, which is ordinary work here rather than anything blocked.
4. Remaining work: language completion (goal 2)
The work queue is the "What Isn't Implemented" list in deps/basicinterpret/README.md.
Each verb is: table row (§1.1) → parse handler if it needs one → exec handler → unit test →
a new .bas/.txt pair. Both kinds of test; they answer different questions.
Order by what unblocks the most, and by what does not need libakgl to grow first:
| Group | Verbs | Blocked on |
|---|---|---|
DO, LOOP, WHILE, UNTIL, ON, BEGIN, BEND, END |
done — src/runtime_structure.c, tests/structure_verbs.c |
|
NEW, CLR, CONT, SWAP, TRON, TROFF, HELP |
done — src/runtime_housekeeping.c, tests/housekeeping_verbs.c |
|
RESTORE, RENUMBER |
done — src/data.c and src/renumber.c, tests/read_data.c and tests/renumber.c |
|
TRAP, RESUME, ER, ERR |
done — src/runtime_trap.c, tests/trap_verbs.c. ER/EL are ER#/EL#; see §5 |
|
USING, PUDEF, WIDTH, CHAR |
done — src/format.c, src/runtime_format.c, tests/format_verbs.c |
|
WINDOW, KEY, SLEEP, WAIT, TI |
done — src/runtime_console.c, tests/console_verbs.c |
|
done — src/runtime_disk.c, tests/disk_verbs.c. Five refuse for want of a drive and DIRECTORY for want of an upstream wrapper; see §5 |
||
GRAPHIC, DRAW, BOX, CIRCLE, PAINT, COLOR, SCALE, SSHAPE, GSHAPE, LOCATE |
done — src/runtime_graphics.c, tests/graphics_verbs.c |
|
SPRITE, MOVSPR, SPRCOLOR, SPRSAV, COLLISION |
done — src/runtime_sprite.c, src/sprite_akgl.c, tests/sprite_verbs.c. SPRDEF is out of scope; see below |
|
PLAY, SOUND, ENVELOPE, VOL, TEMPO |
done — src/runtime_audio.c, src/play.c, tests/audio_verbs.c |
|
| I′. Audio, still gapped | FILTER |
libakgl has no filter stage and SDL3 supplies no primitive; refused with AKBASIC_ERR_DEVICE. SOUND's sweep arguments used to be here and landed with akgl_audio_sweep in libakgl 0.3.0 |
GET, GETKEY, SCNCLR |
done — src/runtime_input.c, tests/input_verbs.c |
|
SYS, FETCH, STASH |
done — src/runtime_machine.c, tests/machine_verbs.c. SYS is refused by name; see §5 |
RESTORE and RENUMBER are deferred, and neither is a small job.
-
Done. EveryRESTOREneeds a DATA pointer, and there isn't one.DATAstatement is pre-scanned into one flat list before the program runs --src/data.c, called fromakbasic_runtime_set_mode()beside the label prescan -- andREADwalks a cursor along it.RESTOREresets the cursor;RESTORE (line)moves it to the first item on or after that line.It fixed two defects on the way, both of which were consequences of
READnot reading. ADATAline above itsREADis now found, where skipping forward from theREADused to run off the end of the program in silence. And the lines between aREADand itsDATAnow execute, where the skip used to swallow them -- a C128 runs them, becauseREADtakes the next item and execution carries on. The one corpus case has itsDATAimmediately after itsREAD, so neither was observable there.tests/read_data.c.DATAat run time is now a no-op: it is a declaration, and reaching the statement means walking past it. -
Done.RENUMBERhas to rewrite references or it is worse than useless.src/renumber.cbuilds the whole old-to-new map first, then rewrites every line -- including lines before the renumbered region, which can branch into it -- and only then moves them.The rewrite is textual and understands exactly one thing about the rest of the language: where a string literal starts and stops, so
PRINT "GOTO 10"survives.GOTO,GOSUB,RUN,RESTOREandTRAPtargets are rewritten, and so is the comma-separated list afterON X GOTO-- for free, since the targets follow theGOTO.COLLISIONis special-cased because its handler is its second argument.Two deliberate non-rewrites. A target naming a line that does not exist is left alone:
GOTO 9999in a program with no line 9999 is already broken, and inventing a destination would hide that. AndTHEN/ELSEdo not take bare line numbers in this dialect -- the corpus writesTHEN GOTO 100-- so a number afterTHENis an expression, not a target.A renumbering that would land a moved line on a kept one is refused before anything is written, because losing a line to a renumbering is not recoverable.
A program written with labels needs none of this.
GOTO DONEis unaffected by any renumbering, which is the strongest argument forLABELthere is.
LOCATE used to appear in both E and G. It belongs to G alone: in BASIC 7.0 LOCATE moves the
graphics pixel cursor that DRAW starts from, and the text cursor verb is CHAR, which is
already in group D.
Out of scope, and staying that way — the reference marks these as incompatible with a
modern PC and that reasoning stands: BANK (no bank switching), FAST (irrelevant CPU speed
control), MONITOR (no machine-language monitor). SPRDEF joins them on the same
reasoning: it is not a programmable verb but an interactive full-screen sprite editor, driven
by single keystrokes and its own cursor. A program cannot call it usefully and this interpreter
does not own the screen it would take over. The three SPRSAV source forms are what replace it.
The libakgl JSON layer turned out not to be in the way at all. Group H was parked on
"check the sprite/actor API before filing", and the concern was that libakgl's sprites are
described by JSON documents that would be cumbersome to reach through BASIC.
akgl_sprite_load_json() is a thin wrapper over akgl_spritesheet_initialize +
akgl_sprite_initialize + writes to public struct fields, and every field it fills from a
document — frame list, animation speed, loop flags, state-to-sprite map — is something a
Commodore sprite does not have. So src/sprite_akgl.c builds the same four objects directly.
The one field that did survive into the verb syntax is the spritesheet's filename:
SPRSAV "ship.png", 1 goes through akgl_path_relative() and akgl_spritesheet_initialize(),
which is the same pair of calls the JSON loader makes.
Also on the queue, from the reference's own defect list:
-
Multiple statements per lineDone.akbasic_parser_parse()consumes a run ofCOLONtokens before each statement and hands the caller a NULL leaf when nothing followed them, which is how an empty statement — a trailing separator, or::— stays legal rather than becoming an error.tests/parser_commands.ccounts statements per line;tests/language/statements/multiple_per_line.basasserts what actually runs.One thing about it needed a decision rather than a transcription, and it is recorded as deviation 31 in §5: BASIC 7.0 scopes everything after
THENto the condition, soIF C THEN A : Bmust run neitherAnorBwhenCis false. The parser takes exactly one statement per arm, so the rest of the line reaches the statement loop as ordinary statements and the branch has to tell the loop whether to run them.A known limitation, not a defect: a whole
FOR/NEXTon one line (FOR I# = 1 TO 3 : PRINT I# : NEXT I#) does not loop. The block structure is the reference'swaitingForCommandmodel (§1.6), which skips forward by source line to the verb it is waiting for, so aNEXTon the same line as itsFORis never reached. Fixing it means restructuring control flow to work on statements rather than lines, which is a deliberate piece of work and wants its own commit. Group A'sDO/LOOPwill meet the same wall. -
Array references in parameter listsDone, and it turned out to be §6 item 13 a third time rather than a separate defect. An identifier's subscript list moved to.expr, andakbasic_ASTLeafgrew anextfield so an argument list chains through a link of its own. That last part is the real fix: the reference chains arguments through each argument's own.right, and every leaf type that can be an argument already uses.rightfor something, so moving one operand out of the way only shifted the collision along. Covered bytests/language/arrays_in_parameter_lists.basandtests/runtime_evaluate.c.
What a demoscene program wanted and could not have
examples/megademo/megademo.bas was written to push the interpreter to its edges, and these are
the edges it hit. None of them blocked the demo — every one has a workaround and the program
carries all six — but each workaround is a routine every future game will carry too, and that is
the argument for the verb.
Ordered by how much BASIC each one would delete: RND and ASC (#16), the unwritable palette
(#17), GSHAPE's missing blend modes (#18), no way to ask whether PLAY has drained (#19),
PLAY being monophonic (#20), DATA being too small a pipe for assets (#21), and the TI# spin
that starves the audio queue (#22).
The last of those is the one worth reading before writing a game loop. Polling TI# does not
merely waste CPU: the host calls settime() once per runtime_run() batch, so a batch of spin
statements drops the note release rate below what a sixteenth-note tune needs, the queue never
reclaims slots until it drains completely, and the backlog compounds until PLAY queue is full.
Reproduced both ways in isolation. Sleep a frame instead.
5. Deliberate deviations from the reference
Keep this list current. Most of these change structure without changing observable output; the ones that do change output say so and carry the golden file they moved.
The bar has dropped since this list was started. It used to be "defensible against the golden suite", because matching the Go implementation byte for byte was goal 1's headline claim. That implementation is now deprecated (§0.1), so the bar is the ordinary one: defensible on its own merits, recorded here, and tested.
- 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 current stdio-only standalone driver gives them. That is correct for a default build and unfinished for anAKBASIC_WITH_AKGLbuild; see §3. 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. -
BOXcannot fill at all, and the plan for it is to spell fill as a negative angle. 7.0's last argument selects outline or fill and sits after the rotation, which would make a filled box a seven-argument call — one more thanakbasic_cmd_box()collects. Spelling it as a negative rotation instead is the intended answer and is not implemented: today a rotation of zero outlines through the renderer's rectangle and any other rotation, negative included, draws four lines.This entry used to claim in bold that it fills, with the body then saying it was filed rather than implemented — a headline that contradicted its own paragraph, which is exactly how a reader ends up believing a feature exists. Found by writing a documentation figure for
BOXand getting an outline back.PAINTis the fill a program has today.akbasic_GraphicsBackend::filled_rectis dead from the language's side as a direct consequence:src/graphics_akgl.cimplements it andtests/mockdevice.hrecords it, but no BASIC verb reaches it. It is the entry point this fix would call, so it is waiting rather than unused — worth knowing before somebody tidies it away. -
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 own. The mode is stored, an out-of-range one is refused because that is a typo worth catching, and the one observable rule is that mode 0 is text. The mode is readable asRGR(0), which is 7.0's wholeRGRand the one field of ours that is. -
A coordinate reaching a backend is a device pixel, and the whole of the host's window is reachable from BASIC. This used to read "coordinates are BASIC's 320x200 space and scaling to the window is the host's job", on the reasoning that the interpreter cannot ask the renderer how big it is without owning it. That reasoning was wrong twice over, which is what §8 item 9 came to say.
It was wrong about the mechanism: not owning a thing is no bar to asking it, and the backend record is how everything else here asks.
akbasic_GraphicsBackendgrew asizeentry point,require_graphics()calls it before every verb that draws -- so a resized window is honoured between two statements rather than at attach -- and 320x200 became the fallback for a backend that leavessizeNULL. It is the record's one optional entry point, so a host written against the old header keeps working and gets exactly the behaviour it used to get.And it was wrong about the description: nothing ever stretched anything. With
SCALEoff a coordinate was passed straight toakgl_draw_*as a pixel address, so an 800x600 window drew a C128 listing into its top-left 320x200 corner and left the rest unused. The documentation described a coordinate transform that did not exist.What changed observably is
SCALE, which now maps user coordinates onto the device rather than onto the constants, andRGR(1)/RGR(2), which report the drawing surface's width and height so a program can use a window whose size it did not choose. A C128 listing draws in the corner as it always did;SCALE 1, 319, 199gives it the whole window.One fix rode along, in the same line of code and too small to defer.
SCALEmappedxmaxonto the width, which put the user space's far corner one pixel past the surface:SCALE 1, 319, 199thenDRAW 1, 319, 199drew nothing at all. It maps onto the last pixel now, which is what makes that sentence above true rather than nearly true.tests/graphics_verbs.candtests/akgl_backends.c, the latter against a 128x128 target chosen because it is smaller than the old constants -- aSCALEstill dividing by them misses it entirely rather than landing somewhere plausible. -
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 sweep runs once rather than oscillating.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 used to be refused outright — there was no
akgl_audio_*equivalent and faking one would have tied audible pitch to how often the host calls us.libakgl0.3.0 addedakgl_audio_sweep()andSOUND voice, freq, dur, dir, min, stepnow reaches it. What does not survive isdir3, "oscillate":akgl_audio_sweepruns one pass between two endpoints, and a real oscillation needs the mixer to turn around at them. What this changes for a program: adir3 siren rises or falls once instead of warbling.dir1 and 2 are exact, and so is the direction — both the SID andakgl_audio_sweeptake it from which endpoint is higher, so aminabove the starting frequency rises whateverdirsays.stepis converted as a delta rather than a position: it is a register increment, and the register-to-hertz table maps positions, so it goes across as the distance between register 0 and registerstep. A step that converts to zero would never arrive and is raised to one hertz, which is the smallest move that still gets there.A backend whose record has no
sweep— a host written againstlibakgl0.2.0 — still refuses a swept note withAKBASIC_ERR_DEVICEand plays a held one normally.tests/audio_verbs.casserts both halves. -
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.
Deviations in the standalone frontend
Items 1–12 are deviations from ported interpreter code and 13–24 from BASIC 7.0. These four are
deviations from the reference's program: main.go and the SDL half of
basicruntime_graphics.go.
-
The frame loop is bounded and the reference's is not. Go's
run()is afor {}that owns the process untilMODE_QUIT(basicruntime.go:682), which is why the reference's window never answers its close button. Here the driver runsAKBASIC_FRONTEND_STEPS_PER_FRAMEsteps, pumps, presents, and goes round again. What this changes for a program: nothing observable — a bounded run reproduces an unbounded one exactly, and the whole golden corpus is driven through this loop to prove it. What it changes for a user is that the window closes when asked. -
The text layer repaints every row it owns, every frame. Not just the rows that changed.
That was tried — a
drawn[]array marking which rows had carried glyphs, so an untouched row could be skipped — and it is wrong on real hardware.SDL_RenderPresentswaps buffers, so a frame does not inherit the frame before it; it inherits the one two back, and on some backends something undefined. A row erased once is clean in one buffer and still dirty in the other, and presenting alternates between them.That is what a backspace across a line wrap looked like: the first character of the wrapped tail flickering in and out forever after the rest had gone. It reproduces on X11 and never under the dummy driver or a software renderer, which is exactly why the suite was green through two rounds of fixing it.
tests/akgl_frontend.cnow paints stale pixels by hand — which is what a swapped-in buffer hands back — so the case is covered without needing real hardware.There is no cheaper correct answer available: a frame either owns every pixel it presents or it inherits pixels it cannot reason about.
What this costs, and it is more than it used to. The host still does not call
SDL_RenderClear, but the text area is now repainted in full every frame, so anything a graphics verb drew underneath it is erased every frame rather than only where text lands. In the standalone driver the text area is the whole window, soDRAWandPRINTno longer coexist there.And the same buffer swap means the graphics verbs were never reliable across frames anyway, which is worth stating plainly rather than leaving as a surprise: a one-shot
DRAWlands in one buffer and the next present shows the other. It looked fine in a single screenshot and would have flickered exactly as the text did. Making graphics survive needs a persistent surface the frame is composited from, which is real work and its own commit — filed here rather than half-done. -
The cursor is a blinking block. The reference draws an underscore glyph (
drawCursor,basicruntime_graphics.go:33), and so did this until the underscore turned out to sit under the text being typed and make it hard to read. A block occupies the cell after the text instead of the space beneath it.Half a second per blink (
AKBASIC_SINK_CURSOR_BLINK_MS), which is about a Commodore's and slow enough to read under.akbasic_AkglSink.cursorperiodmsset to zero holds it solid, which is what makes a frame deterministic for a test.Two details that are decisions rather than accidents. The clock is SDL's, not the host's: everywhere else the host owns the clock because the library owns no loop and must not block, but this is an adaptor that already links SDL and threading a timestamp through the sink interface to animate a cursor would be a poor trade. And a line that exactly fills a row leaves the cursor one column past the end, because
putchar_atwraps only when the next character arrives — the cursor is drawn at the start of the next row instead, which is where the next character will actually land and where a C128 puts it. -
The window is closed by the host, and that is not
QUIT. Closing the window stops the frontend and leavesobj->modealone; it does not setAKBASIC_MODE_QUIT. The distinction is invisible to the standalone program, which exits either way, and load-bearing for an embedding host: a game that closes its own window has not decided that the script is finished, and a script that ranQUIThas.tests/akgl_frontend.casserts both halves. -
Piped input still works in an AKGL build. With a terminal on stdin the window is the console and lines come from its editor; piped or redirected, they come from the pipe. This is not in the reference, which always reads
os.Stdinin REPL mode. It exists sobasic < program.basbehaves the same in both builds — and so the golden corpus can be driven through the SDL binary, which is where most of the frontend's real coverage comes from.
Deviations that change what a program is allowed to do
-
A verb name with a type suffix is not a variable name.
PRINT$ = 1is refused where the reference accepts it, because its reserved-word check searched the keyword tables with the suffix still attached and therefore never matched (§6 item 16). A real C128 refuses it too, so this is the reference being wrong rather than this interpreter being strict.What it costs a program: a listing that used
INPUT$,LEN#orGOTO%as a variable stops parsing, and the fix is to rename the variable. One case in the reference's own corpus did exactly that; seetests/reference/README.md.
Deviations in statement separation
-
A branch decides who owns the rest of its line. BASIC 7.0 scopes every statement after
THENto the condition, and the reference has no opinion on the matter because it never consumed theCOLONtoken at all. The parser here takes exactly one statement for each arm, so the rest of the line arrives at the statement loop as ordinary top-level statements and something has to say whether to run them. TheBRANCHcase inakbasic_runtime_evaluate()setsruntime->skiprestofline, and the two statement loops stop on it.The rule is not "skip when false", which is the reason this is written down:
Line Condition What runs IF C THEN A : Btrue A,BIF C THEN A : Bfalse nothing IF C THEN A ELSE B : Dtrue AIF C THEN A ELSE B : Dfalse B,DThe remainder always belongs to whichever arm was written last, so it is skipped exactly when that arm is the one not taken — with an
ELSEthe last arm isELSE, without one it isTHEN. That is one line of code and it reads as an oddity without the table above it.
Deviations in the structure and error verbs (groups A and C)
-
Structures are an addition, not a port. BASIC 7.0 has no records at all, so
TYPE/END TYPE,DIM X@ AS T,.and->,PTR TOandPOINT ... ATare invented here. The@suffix was not: the Go reference reservedIDENTIFIER_STRUCTand never used it, andsrc/grammar.crendered such a leaf as"NOT IMPLEMENTED"until this landed.docs/16-structures.mdis the whole feature.Assignment copies and
POINTshares, which is the decision everything else rests on. A structure is a value like every other value here, so a program that never writesPOINTcan never be surprised by aliasing — and the two field operators are kept apart so a reader always knows from the spelling which they are looking at..on a pointer and->on a value are both errors, each naming the other.A declared type is what buys the storage model. An instance has a known slot count and is laid out exactly as an array is, out of the same value pool, so nothing new holds data — only a table of descriptors. It also bounds copy depth statically, since a
TYPEcannot contain itself by value.Three limits are ours and are stated rather than derived: 16 types, 16 fields per type, and four levels of nesting in
PRINT. The last is not a safety bound on copying — copy stops at pointers by construction — but on rendering, which must follow a pointer and would not come back on a cycle. Four rather than eight because the bound has to bite before the 256-byte render buffer does, or a cycle stops because it ran out of room rather than because it was told to.A type name shares a namespace with verbs and labels, since all three are bare words. Refused at declaration with a message that says so, because the parser's own answer was "Expected expression or literal" pointing at the line rather than the problem. Field names follow the same reserved-word rule variable names already do, enforced by the loader's scan —
TO@is a bad field name for exactly the reasonTO#is a bad variable name. -
A host's C struct is the same thing with its bytes somewhere else.
akbasic_host_register_type()puts it in the same table aTYPEfills, so copy,PTR TO,.and->all work across the boundary with no second set of rules — and the language's own copy-versus-POINTdistinction turns out to be exactly the snapshot-versus-share distinction a host needs, so there is one API rather than two.A binding takes a shadow run of slots; a read refreshes from host memory and a write converts back. Conversion refuses rather than truncates — 70000 into an
int16_tnames the field — and a field name's suffix must agree with the C type it describes, refused at registration.It is the only pointer this interpreter holds that it did not allocate, and that is the one thing a host has to think about.
akbasic_host_unbind()exists for it.examples/hoststruct.cis a working host, built and run by every build. -
A whole loop on one line does not loop.
DO : PRINT 1 : LOOPruns once, exactly asFOR I=1 TO 3 : PRINT I : NEXT Idoes. Block skipping walks source lines, which is the reference'swaitingForCommandmodel (§1.6), so aLOOPon the same line as itsDOis never reached as a separate step. Fixing it means making the skip operate on statements rather than lines — a deliberate piece of work, already noted in §4 againstFOR. -
ERandELareER#andEL#. A C128 exposes them as bare reserved names. This dialect cannot: an identifier carries its type in a suffix, and a name without one is a label. They are written into the global scope when a trap fires, soPRINT ER#reads the way the rest of the language does and needs no new syntax.ER#carries thelibakerrorstatus code, not a Commodore error number — 515 for an out-of-bounds subscript rather than C128 error 9. There is no correspondence to reproduce: the errors this interpreter raises are not the errors a 1985 ROM raised.ERR(ER#)gives the name, and that is the part a program should be printing anyway. -
ENDdoes not armCONT. A C128 letsCONTresume afterENDas well as afterSTOP. HereENDmeans the program finished, and continuing from a finished program resumes at whatever line happens to follow — soCONTrefuses instead.STOPstill arms it.
Deviations in the format verbs (group D)
-
PRINT USINGrenders one field per statement. BASIC 7.0 lets a format string hold several fields andPRINT USINGtake a list of values against them. Here the first field is used and any literal text around it is copied through, soPRINT USING "A: ###"; Xworks andPRINT USING "### ###"; X, Ydoes not. Multiple fields needPRINTto take a value list, which it does not yet — a separate piece of work from the field rendering itself, whichsrc/format.cdoes properly.Exponential fields (
^^^^) are not implemented either. Nothing in the language produces a value that needs one, sincePRINTrenders a float with%f. -
WIDTHis emulated with parallel passes. It sets the thickness of drawn lines, and neither the graphics backend record norakgl_draw_linetakes a thickness — so aWIDTH 2line is drawn twice, offset one pixel along whichever axis the line does not mostly run along. Visually right at the only other value the verb accepts; a general thickness would want a real perpendicular offset and an API that takes one. -
CHARignores its colour argument, and needs a sink that has a cursor. The text sink draws in one colour, chosen by the host when it built the sink, so a per-call colour would have to become part of the sink interface. The argument is accepted and ignored rather than refused, because refusing it would make every publishedCHARline a syntax error.Positioning is a new optional
movetoon the sink record. A stdio sink leaves it NULL andCHARrefuses by name — a terminal's cursor is not this library's to move and a pipe has no cursor at all — soCHARworks in an AKGL build and says why it cannot in a stdio one.
Deviations in the console verbs (group E)
-
SLEEPandWAIThold the step loop; they do not block. §1.6 forbids blocking, so a waiting program is one that does not advance —akbasic_runtime_step()still returns, a boundedakbasic_runtime_run()still comes back on time, and an embedded game keeps its frame rate while a script sleeps.SLEEPwith no host clock does nothing at all rather than waiting forever. A deadline computed from a clock that never advances is never reached, and the first version of this hung the test suite proving it. -
TIandTI$areTI#andTI$. Same reason asER#/EL#: no bare variable names. They are ordinary globals refreshed once per step from the host's clock, rather than pseudo-variables, because this dialect has no mechanism for a name that computes itself. A host that never sets a clock gets a stopped clock rather than a wrong one. -
WAITpolls ordinary process memory. On a C128 the byte is a hardware register an interrupt is changing. Here nothing changes it but the host, another thread, or aPOKEfrom aTRAPhandler — so a program that waits on a byte nobody writes waits forever, which is exactly what the same program does on a C128 with the wrong address. Read through avolatilepointer, because the whole point is that something outside this program writes it. -
KEYstores its macros and nothing expands them. The definitions are kept and bareKEYlists them, which is the half that is this library's business. Expanding a macro when a function key is pressed belongs to whatever owns the keyboard — the frontend's line editor — and the input backend delivers keycodes rather than editor commands.
Deviations in the machine verbs (group J)
-
FETCHandSTASHare the same byte copy. On a C128 they differ by which side is the RAM Expansion Unit —STASHwrites out to it,FETCHreads back. There is no expansion unit and there are no banks, so both arememmovebetween two addresses and saying so is better than inventing a distinction.memmoverather thanmemcpybecause a program shifting a buffer along by a few bytes overlaps its own source, which is a normal ask.Addresses are real process addresses, which is the decision
POKE,PEEKandPOINTERalready made. There is no bounds check and there cannot be one: a wrong address is a segmentation fault, not an error message. -
SYSis refused by name. It calls machine code at an address. There is no 6502 and no ROM to call, so there is nothing to jump to and nothing sensible to emulate — and jumping to a real address in this process would be a way to crash it on purpose. It parses first and refuses second, so a listing containingSYSstill loads and still lists; only running it fails, and it says why.Same reasoning as
BANK,FASTandMONITOR, with one difference: those have no table row at all, andSYShas a handler because it is common enough in published listings to deserve a specific message rather than "Unknown command".
Deviations in the disk verbs (group F)
-
There is no 1541, so the verbs split three ways. The ones that mean something on a filesystem are implemented against
aksl_f*—DOPEN,DCLOSE,APPEND,RECORD,SCRATCH,RENAME,COPY,CONCAT,BSAVE,BLOAD,VERIFY. The ones that are spellings of verbs already here are aliases:SAVEisDSAVE,LOADisDLOAD,CATALOGisDIRECTORY,DVERIFYisVERIFY.HEADER,COLLECT,BACKUPandBOOTare refused by name, with the reason. Each operates on a physical disk — formatting one, validating its block allocation map, duplicating it, booting from it — and a filesystem has no equivalent that is not a lie.HEADERwould have to mean "delete everything in this directory", which is a spectacularly bad thing to do to somebody who typed a C128 verb.DCLEARis the exception: resetting a drive also closes its channels, and closing the channels is real, so that is what it does. -
DIRECTORYis refused for want of an upstream wrapper.libakstdlibhas noaksl_opendir, and this project's rule is that a missing capability is filed upstream rather than worked around — so it is filed againstlibakstdliband the verb says so. Callingopendir(3)here would mean reporting througherrnoin a file where everything else reports through anakerr_ErrorContext *. -
PRINT #andINPUT #need the space before the#. A C128 writesPRINT#1,A$. The scanner reads#as a type suffix, soPRINT#comes out as an identifier namedPRINTwith an integer suffix — and a verb name carrying a suffix is refused (deviation 30). With a space the#is its own token and the parse handler reads it. -
RECORDcounts lines, not fixed-length records. A C128's relative file has a record length fixed when the file was created; a filesystem file has none. So a record is a line andRECORDrewinds and reads forward to it, which is slower than a seek and is the only definition that does not invent a record length the file does not have. -
BLOADrequires a length. A C128 reads until the file ends. Here the address is a real process address, so a file longer than the caller expected would write past whatever it was pointed at with no way to notice. Refusing to guess is the only safe answer.
Deviations in conditions
-
A lone
=is equality inside a condition.IF A# = 2 THENworks, which it did not before: the scanner reads==as EQUAL and a single=as ASSIGNMENT, because at that point it cannot know whether it is looking at a statement or a condition, and the reference never resolved the ambiguity anywhere. A condition is the one context where it has an answer — BASIC has no assignment expression — soakbasic_Parser::comparingis set around the condition and the relation parser offers ASSIGNMENT as a comparison operator only while it is.==keeps working, and the whole checked-in corpus is written in it.Outside a condition
=has to stay an assignment, which is what the flag is for: the first attempt put ASSIGNMENT in the operator list unconditionally, andFOR I# = 1 TO 5promptly stopped initializing its counter. -
A condition is a whole expression, so
ANDandORwork in one.IF A# = 5 AND B# = 3 THENused to report "Incomplete IF statement". The reference parses a single relation afterIF, and a relation sits belowANDandORin the grammar chain, so theANDwas never consumed and theTHENcheck found the wrong token.akbasic_parse_if()callsakbasic_parser_expression()now. -
Truth is nonzero, not "a boolean holding true".
akbasic_value_is_truthy()is what a branch tests. Commodore BASIC has no boolean type: a comparison yields -1 or 0,ANDandORare the bitwise operators, andIF A THENis legal for any numeric A. The reference testsboolvalue == -1regardless of the value's type, soIF A# = 1 OR B# = 2 THEN— whoseORyields an integer — was silently always false, and so wasIF A# THEN.The bitwise operators accept a truth value as an operand for the same reason: -1 is every bit set precisely so that
ANDandORdouble as the logical pair. A string is still never true; a C128 raises a type mismatch there, which is a stricter answer this could adopt later.
Deviations in the sprite verbs (group H)
-
Labels are filed before the program runs, not as each
LABELexecutes.akbasic_runtime_scan_labels()walks the stored source on every entry intoAKBASIC_MODE_RUNand files everyLABEL <name>it finds, textually, without parsing the lines around it. The reference resolves a label only once itsLABELstatement has run, soGOTOandGOSUBreached backwards and never forwards.This was not a nice-to-have. An interrupt handler by definition sits on a line normal flow does not fall into, so
COLLISION 1, BUMPEDcould not be made to work at all under the old rule — neither resolving at arm time nor at fire time helps when theLABELline is never executed. ForwardGOTOis the improvement that came with it, covered bytests/language/statements/label_forward.bas.LABELstill executes and still files itself, so a name that appears twice resolves to the last one in the source until one of them runs. The scan is textual on purpose: parsing every line up front would raise on lines the program would never have reached. -
Sprite coordinates are device pixels, not the VIC-II's 0–511 by 0–255.
MOVSPRtakes the same coordinate spaceDRAWdoes. A C128's sprite coordinates are the raster's, offset so that (24, 50) is the top-left of the visible screen; reproducing that would give the language two coordinate systems and makeMOVSPR 1, 0, 0put a sprite off-screen. Consistent with deviation 18, which is where "the same spaceDRAWuses" is defined — and it moved, so this one moved with it.SCALEdeliberately does not apply to sprites. It is a graphics-verb transform and the sprite verbs do not call it, which was true before deviation 18 was rewritten and is worth writing down now that the two spaces can differ: withSCALE 1, 319, 199on, aDRAWat 160,100 and aMOVSPRto 160,100 land in different places. Arguably they should agree; not changed here because it is a decision about whatSCALEmeans rather than a slip, and it wants its own commit. -
MOVSPR's speed unit is ours. BASIC 7.0 documents speed 0–15 with 15 fastest and says nothing about what a unit is worth. Here one unit isAKBASIC_SPRITE_SPEED_PIXELS_PER_SECOND(5) pixels per second, so speed 15 crosses the 320-pixel screen in about four seconds. Paced offakbasic_runtime_settime()fromakbasic_runtime_step(), exactly as thePLAYqueue is, so a host that never sets a clock gets a still picture rather than a hang — same trade as deviation 20. -
SPRSAVtakes an integer array where a C128 takes a string, and takes a file path as well. Three source forms, one of which is the C128's:Form Source SPRSAV A$, na region SSHAPEsaved. The C128 documentsSPRSAV's string as theSSHAPEdata format at a fixed 24×21, so sharing the shape pool is faithful rather than a shortcutSPRSAV A#, n63 elements of a DIMmed integer array — the DATA-driven path a type-in listing uses SPRSAV "ship.png", nan image file The array form exists because a string here cannot hold a sprite. A value carries a NUL-terminated
char[256](§1.2) and a pattern is 63 raw bytes including zeros, so the C128's own spelling is unrepresentable.DIM P#(63)is exactly the right size.The file form is a deliberate addition, not a fallback. A string source is a path unless it starts with
SHAPE:, which is the prefixSSHAPEmints, so the two never collide. It resolves throughakgl_path_relative()— the working directory first, then the directory the running program was loaded from — and loads throughakgl_spritesheet_initialize(), which are the same two callsakgl_sprite_load_json()makes for a spritesheet. A sprite loaded this way takes the image's own size rather than being forced to 24×21; a modern PC has no reason to throw away art that is not sprite-shaped.akbasic_runtime_set_source_path()is what tells the interpreter where the program came from. Group F's disk verbs will want the same thing.The reverse direction is refused.
SPRSAV n, A$on a C128 copies a sprite out; here that would mean writing an image file, which is a disk operation, and group F is unimplemented as a whole. Refused by name rather than half-built. -
COLLISIONimplements type 1 only, and types 2 and 3 are refused by name. Sprite-to-background needs the whole render target read back and compared against each sprite every frame; a light pen has no meaning on a machine with no light pen. Both raiseAKBASIC_ERR_DEVICEwith the reason rather than being accepted and never firing.Collision is bounding-box, not pixel. A C128's VIC-II collides on set pixels, so two sprites whose boxes overlap but whose art does not are reported as colliding here and would not be there. Pixel-exact collision would mean keeping every sprite's unpacked bitmap and testing the overlap rectangle a pixel at a time; the box test is four comparisons.
akgl_collide_rectangles()is deliberately not used — it has a documented corner-containment defect that misses a plus-shaped overlap, and nothing in libakgl calls it. -
A collision handler is entered between source lines, and must end in
RETURN.akbasic_runtime_service_interrupts()runs at the top ofakbasic_runtime_step()and injects what amounts to aGOSUBthe program did not write. Between lines is the only safe place: a handler entered mid-statement would have to return into the middle of a line and the parser keeps no state that could resume there. That is the same granularity block skipping already works at (§1.6).An interrupt does not interrupt an interrupt. A collision that is still true while its own handler runs would otherwise re-enter on the next line until the environment pool was gone. The event is not lost — it is taken as soon as the handler's
RETURNlands. -
Sprite priority is recorded and not honoured.
SPRITE n,,,1sets the bit,RSPRITEreads it back, and nothing draws differently. A C128 draws a low-priority sprite behind the bitmap plane; here the text layer, the drawing surface and the sprites share one render target and sprites are composited on top of it every frame, so there is nothing to go behind. Honouring it would mean a separate composited surface per plane — which is the same piece of work deviation 19 already needs. -
Multicolour mode is recorded and not honoured. Same shape as priority:
SPRITE's seventh argument andSPRCOLOR's two registers are kept,RSPRITEandRSPCOLORread them back, and no sprite is drawn in multicolour. Multicolour packs two bitmap bits per pixel and selects between the sprite's own colour and the two shared registers; everySPRSAVsource form here carries one bit per pixel or a full-colour image, so there is no second bit to select with. The argument positions are kept honest for the day a multicolour pattern format exists. -
A sprite is a real
libakglactor, and it is drawn by a renderfunc of ours. Each of the eight becomes aSpriteSheet+Sprite+Character+Actorregistered inAKGL_REGISTRY_ACTOR, so an embedding game sees BASIC's sprites alongside its own (goal 3). Butakgl_actor_render()is not what draws them. There were two reasons and libakgl 0.5.0 fixed one: the default used to compute its destination height from the sprite's width, drawing a 24×21 Commodore sprite as a 24×24 square, and it now uses the height — libakgl defect 26, filed from here and closed there.The reason that remains is the one that keeps this file. An actor carries a single scalar
scaleapplied to both axes, which cannot expressSPRITE's separate x- and y-expand bits, soMOVSPR's twice-as-wide-same-height is unrepresentable. libakgl records it as open and names this interpreter as the caller that needs it; the fix is a design choice over there —scale_x/scale_ybesidescale, or replacingscaleand taking the ABI break while the major is still 0 — rather than a patch. Until thensrc/sprite_akgl.cinstalls its ownrenderfunc, which is libakgl's own extension point for exactly this.Worth keeping as a pattern: both defects were found by using the library for something it had not been used for, and both were filed rather than worked around silently. One came back fixed.
-
Line numbers are optional in a loaded program, and a blank line is not a program line. This one moved a golden file. A line with no number used to be filed under the loader's cursor unchanged — that is, on top of the line before it — so two unnumbered lines in a row silently lost the first, and a blank line erased whatever preceded it. The reference does the same, and
tests/reference/language/arithmetic/integer.basis the proof: fourPRINTstatements, an expectation with three values, and a trailing blank line that erased40 PRINT 4 - 2before the program ran. The expectation is now4 4 2 2andtests/reference/README.mdrecords it.In its place:
akbasic_runtime_file_line()(src/runtime.c) is the one implementation of the rule, shared byakbasic_runtime_load(), RUNSTREAM andDLOAD. A numbered line is filed under its number and moves the cursor; an unnumbered one takes the slot after it; blank lines are skipped by all three. A collision involving an assigned number is refused withAKBASIC_ERR_BOUNDSrather than overwriting.akbasic_SourceLinegrew anumberedflag so deviation 64 can tell the two apart.The prompt is deliberately untouched. A line typed without a number is direct mode and runs now; that is the only thing separating program text from a statement at a REPL, and it is why this is a loading feature rather than a language-wide one.
AUTOremains the way to enter program text without typing numbers.Consequence worth stating: an unnumbered program is capped at 9998 lines, the cap that already existed, and its assigned numbers are increment-1 — so
LISTandDSAVEshow a listing with no gaps to insert into.RENUMBERbeforeDSAVEgives the gaps back, and marks every line numbered.Not changed: two lines that both carry the same written number still silently keep the last, as they always have. That is a separate decision with its own corpus risk and it is not this one.
-
Branching by number to a line the program did not number is refused before it runs. The other half of deviation 63, and the reason
akbasic_SourceLinecarries a flag rather than the loader just picking slots quietly. In a script written without line numbersGOTO 100finds the hundredth line and branches there: plausible, silent and wrong, which is the worst thing to hand somebody at run time.akbasic_runtime_check_targets()is a fourth prescan, run beside the label,DATAandTYPEones on every entry intoAKBASIC_MODE_RUN— the earliest the check can be made and the only place all four ways a program arrives pass through. It refuses withAKBASIC_ERR_SYNTAX, naming the line and saying what to do instead:? 1 : PARSE ERROR Line 1: branch to line 4, which the program did not number. Branch by LABEL, or RENUMBER firstTwo things are deliberately not refused. A target naming an empty line, for the same reason
RENUMBERleaves one alone —GOTO 9999in a program with no line 9999 is already broken and inventing a rule about it would hide that. And a target in a fully numbered program, which is every program that existed before this, so nothing checked in changed.It shares
RENUMBER's walk rather than repeating it.src/renumber.cgrew anakbasic_TargetWalk— aselfpointer and avisitfunction — andrewrite_line()now takes one.RENUMBER's visitor substitutes the number a line moved to; the check's substitutes the number unchanged and raises if it names an assigned line. One walk, so the two cannot disagree about what a branch target is, which they would have within a release.Worth knowing: the check points
environment->linenoat the line it is walking, so the? N :prefix names the offending line. The other three prescans do not, so a malformedTYPEor a badDATAitem still reports whichever line the loader stopped on — usually the last line of the program. They work around it by puttingLine %d:in the message text, which is why the message above says the number twice. Worth fixing inakbasic_runtime_set_mode()'s wrapper rather than in four places; not fixed here.
6. Reference defects — fix them; fidelity is no longer a reason not to
These are real bugs in deps/basicinterpret, reproduced faithfully during the port.
The rule that governed this section is gone. It used to read "port the behaviour first so the golden suite passes and the port is provably faithful, then fix each one deliberately" — and faithfulness was the reason several of these are still here. The Go implementation is deprecated and will not be updated (§0.1), so there is no longer anything to be faithful to. Each of these is now an ordinary defect, to be judged on whether the fix is right rather than on whether it matches.
What has not changed is the working method: one fix per commit, with a test that asserts the
correct contract, and a golden file moved in the same commit if the fix changes output.
AKBASIC_KNOWN_FAILING_TESTS is empty now and is kept declared for the next defect that has to
be reproduced before it can be fixed.
-
Never ported. There is nobasicvariable.go:176—toString()testslen(self.values) == 0and then indexesself.values[0].akbasic_variable_to_string: rendering a value isakbasic_value_to_string(), which switches on the value type and has no count test to invert. The buggy function had no counterpart to reproduce. -
Fixed in the port.basicvariable.go:108—setBooleanbuilds a value withvaluetype: TYPE_STRING.akbasic_value_set_bool()setsAKBASIC_TYPE_BOOLEAN, andtests/value_compare.creads a boolean back through every comparison operator. -
Fixed in the port.basicenvironment.go:157—stopWaiting(command)ignorescommand.akbasic_environment_stop_waiting()walks the scope chain and clears the first environment actually waiting for that verb, so an inner block can no longer clear an outer block's wait. A miss is tolerated rather than raised, which is the one part of the suggested fix not taken:EXITand the end of aDEFbody both call it speculatively. -
Fixed. It clones like every other operator now, sobasicvalue.go:181—mathPlusmutatesselfin place whenself.mutableis true.A# + 1cannot modifyA#.The gate on this item was
FOR/NEXTcoverage, becauseNEXT's increment relied on the mutation to advance the counter.tests/for_next.cis that coverage --STEP, a negative step, a float counter, a body that assigns to the counter,EXIT, and nesting, none of which the golden corpus reaches -- andakbasic_cmd_next()now writes the incremented value back withakbasic_variable_set_subscript(). Removing that write-back does not fail the suite, it hangs it: the counter stops advancing and the loop never ends. -
Fixed.basicvalue.go:191and siblings — every binary operator adds both of the right-hand operand's numeric fields.rval_as_int()andrval_as_float()select on the operand's type instead of summingintval + (int64_t)floatval.It was filed as a landmine with no consequence today, and that is why it needed a test written against the value API rather than against the language: no BASIC program can build a value carrying both fields, and the old code passes every other test in the tree.
tests/value_arithmetic.cconstructs one directly. -
Moot.basicvalue.go, all comparisons —destis cloned fromself, so the resulting boolean inheritsself.name.BasicValue.namewas dropped as dead during the port along withBasicEnvironment.update(), its only reader — deviation 9 above. There is no name for a comparison result to inherit. -
Fixed in the port.basicruntime_commands.go:484—CommandIFdereferencesexpr.rightafter the loop guaranteed it isnil, so the "Malformed IF statement" check is unreachable.akbasic_parse_if()matches theTHENtoken and compares its lexeme directly, so the check runs on everyIF. -
Fixed, and then fixed again — see item 18, which is what clearing the wait left behind.basicruntime_commands.go:669—CommandEXITpops the environment withoutstopWaiting. -
Not ported as dead code. The pair is live here and is the storage model:basicruntime.go:149—newVariable()andBasicRuntime.variables[MAX_VARIABLES]are dead.akbasic_environment_create()takes a slot fromakbasic_runtime_new_variable(), because a pool is what replaced Go's per-environment map (§1.3). The reference's versions were dead; these are not. -
Fixed. Base 10 unless prefixedbasicgrammar.go:224—newLiteralIntselects base 8 whenever the lexeme starts with0.0x; a leading zero in a listing is padding, not a radix.PRINT 010prints 10 andPRINT 08parses. Nothing in the upstream corpus had a leading-zero literal, which is why nothing there noticed. Regression tests intests/grammar_leaves.candtests/language/numeric/octal_literal.bas, which was rewritten from pinning the defect to pinning the fix. -
Fixed in the port. They come from a fixed pool andbasicruntime.go:121/basicparser_commands.go:124— environments are never freed.akbasic_runtime_prev_environment()releases each one — deviation 3 above. An unreleased scope shows up as pool exhaustion rather than as unbounded growth, which is