Split the documentation by who reads it
Some checks failed
akbasic CI Build / cmake_build (push) Successful in 3m2s
akbasic CI Build / sanitizers (push) Successful in 3m51s
akbasic CI Build / coverage (push) Failing after 3m24s
akbasic CI Build / akgl_build (push) Failing after 20s
akbasic CI Build / mutation_test (push) Has been cancelled

README.md was 577 lines and answered four different questions at once: what
the project is, how to build it, every verb and function in the language, and
how to maintain the test harness. The verb and function lists had already been
written a second time in docs/11 and docs/12, which is how a list of that size
goes stale -- there is no way to notice the two have drifted apart.

README.md is now 150 lines and holds only what somebody evaluating the project
needs: what it is, the quickstart, why it was rewritten in C, the five rules
embedding imposes on the design, the two ways to use it, and where everything
else lives. Technical detail goes to docs/, maintenance to MAINTENANCE.md.

The akbasic_TextSink struct moved to docs/10-embedding.md rather than being
deleted. It was the corpus's only `c excerpt=` block -- the check that caught
the stale struct two commits ago -- so dropping it with the README would have
quietly retired a test. docs/10 also stopped claiming README.md carries the
full API surface and the pool limits, which the trim made false.

CLAUDE.md went from 458 lines to 62, because almost none of it was
agent-specific. The project goals, the Go reference and its architecture, the
dependency version and ABI rules, the four ways an embedded build collides,
the libakerror convention, the error-code range map and the style rules are
all things a maintainer needs, and they are now in MAINTENANCE.md with one
copy to keep true. CLAUDE.md points there and keeps only the rules no test
enforces: tests in the same commit asserting the correct contract, file a
missing dependency capability upstream, do not edit generated output or
tests/reference/, co-author your commits.

Four claims did not survive the move, having gone stale where nothing could
notice:

  - "The repository is currently empty apart from its submodules -- no
    commits, no source tree, no build files." There are 43 commits.
  - libakgl's target_compile_definitions(akerror PUBLIC AKERR_MAX_ERR_VALUE)
    at deps/libakgl/CMakeLists.txt:44, described as inert but present. It is
    gone; only a historical mention in a comment remains.
  - "akbasic_init() claims 512-767." There is no akbasic_init. It is
    akbasic_error_register(), called from akbasic_runtime_init().
  - Time-relative phrasing ("libakgl hit two of them in the last week").

Five places pointed at CLAUDE.md for the range map or the file-it-upstream
rule and now point at MAINTENANCE.md: include/akbasic/error.h,
src/runtime_disk.c and three entries in TODO.md. Both source changes are
comments. deps/libakgl/TODO.md cites it too and is left alone; it is a
submodule, and the rule it quotes is still reachable from CLAUDE.md.

ctest is green at 95 of 95, docs_examples included: 36 programs, 9
transcripts, 44 output comparisons, 3 C snippets, 1 excerpt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 22:59:43 -04:00
parent 342e4c07da
commit 80b76ad467
7 changed files with 747 additions and 1107 deletions

662
README.md
View File

@@ -1,576 +1,150 @@
This BASIC is styled after [Commodore BASIC 7.0](http://www.jbrain.com/pub/cbm/manuals/128/C128PRG.pdf) and the [Dartmouth BASIC from 1964](https://www.dartmouth.edu/basicfifty/basic.html). It is a C rewrite of [basicinterpreter](https://source.starfort.tech/andrew/basicinterpreter), which was itself built from the instructions for the Java implementation of Lox in [craftinginterpreters.com](https://craftinginterpreters.com) before striking off on its own. The Go version is vendored at `deps/basicinterpret` and is the behavioural specification: when a question about semantics comes up, the answer lives in that code. Its acceptance corpus is **checked in here** at [`tests/reference/`](tests/reference/README.md) and runs on every build, so nothing about building or testing this project needs that submodule any more.
# akbasic
A BASIC interpreter written in C, styled after [Commodore BASIC 7.0](http://www.jbrain.com/pub/cbm/manuals/128/C128PRG.pdf)
and the [Dartmouth BASIC of 1964](https://www.dartmouth.edu/basicfifty/basic.html). It runs a
`.bas` file, it gives you a prompt, and — the point of the exercise — it links into a C program
as a scripting engine.
It is a rewrite of [basicinterpreter](https://source.starfort.tech/andrew/basicinterpreter), a Go
implementation that started from the Java Lox instructions in
[craftinginterpreters.com](https://craftinginterpreters.com) and then struck off on its own. That
project is deprecated. It is vendored here as the behavioural spec to read when a question about
semantics comes up, and its acceptance corpus is checked in at
[`tests/reference/`](tests/reference/README.md) and runs on every build — so nothing about
building or testing this project needs it.
## Quickstart
```sh norun
git submodule update --init --recursive
cmake -S . -B build
cmake --build build --parallel
# To use the interactive REPL
./build/basic
# To run a basic file from the command line
./build/basic tests/reference/language/functions.bas
# The test suite: unit tests plus the Go version's own corpus, byte-compared
ctest --test-dir build --output-on-failure
# API documentation, into build/docs/html
doxygen Doxyfile
# With the libakgl-backed sink, devices and SDL frontend. Off by default, because
# this pulls in SDL3 and the whole interpreter builds and tests without it.
cmake -S . -B build-akgl -DAKBASIC_WITH_AKGL=ON
cmake --build build-akgl --parallel
ctest --test-dir build-akgl --output-on-failure
# That build of `basic` is a different program: it opens an 800x600 window, draws
# BASIC output into it in the Commodore font, and still puts every byte on stdout.
./build-akgl/basic tests/reference/language/functions.bas
```
### Optional tools, and what you lose without them
Everything above works with cmake and a C compiler. Three tools are optional, and each one buys
a specific test rather than general convenience:
| Tool | Buys | Without it |
|---|---|---|
| `xdotool` + `script`(1) | `akgl_typing` — types at a real focused SDL window with a real keyboard | The test reports **Skipped**. Nothing fails |
| `gcovr` | the `coverage` target and its 90%-of-lines gate | `-DAKBASIC_COVERAGE=ON` will not configure |
| `python3` | `scripts/mutation_test.py` and the `mutation` target | The target is not created |
**`akgl_typing` is worth installing `xdotool` for.** It is the only test that exercises the path
a person actually uses — X11 delivers a key press to a focused window, SDL composes it, libakgl
rings it, the editor echoes it, the interpreter runs it. Every other keyboard test synthesises
SDL events, which tests the code *downstream* of SDL and cannot test the code upstream of it.
That gap shipped a bug: the frontend never called `SDL_StartTextInput()`, so SDL emitted no
text-input events at all, the line editor dropped every keystroke, and the whole suite stayed
green because it was pushing those events itself.
It needs a real X server and **it steals keyboard focus for about fifteen seconds**. Set
`AKBASIC_SKIP_INTERACTIVE=1` to skip it while you are using the machine. It skips itself
automatically when there is no display, no `xdotool`, or no window manager answering — CTest
reports `Skipped` rather than a failure, because none of those means the answer is no.
There are two workflows. **`.gitea/workflows/ci.yaml`** runs on every push: the suite,
ASan+UBSan, coverage gated at 90% of lines, and mutation testing over two files. The coverage
report is uploaded as a `code-coverage` artifact.
**`.gitea/workflows/release.yaml`** is manual (`workflow_dispatch`) and is what a release runs.
It builds the API documentation and uploads it as `api-documentation`, and it mutates the
*whole* `src/` tree — 3675 mutants, hours of runner time, which is why it is not on the push
path. It takes two optional inputs: a mutation threshold, and a space-separated file list to
narrow the run.
Mutation testing is worth a word, because it is the only gate that checks the error-handling
control flow at all. `libakerror`'s `ATTEMPT`/`CATCH`/`PASS` macros expand at their call sites,
so gcov attributes them to the caller and line coverage cannot see them. `scripts/mutation_test.py`
breaks the library many small ways and checks that the suite notices:
```sh norun
cmake --build build --target mutation # the whole src/ tree; slow, hours
python3 scripts/mutation_test.py --target src/value.c --list
python3 scripts/mutation_test.py --target src/value.c --threshold 70
./build/basic # the REPL
./build/basic tests/reference/language/functions.bas # run a program
```
It earns its keep. Writing this suite, it found that nothing exercised a maximum-length string
or symbol-table key, so every `MAX - 1` off-by-one in a `strncpy` would have gone unnoticed;
and that `errno` was never asserted to be cleared before a `strtoll`, which is what stops a
stale `ERANGE` from failing a perfectly valid conversion.
```basic
10 FOR I# = 1 TO 3
20 PRINT "HELLO " + I#
30 NEXT I#
```
Every example in `README.md` and in `docs/` is executed by the suite, and its output
compared byte for byte, as the CTest case `docs_examples`. Documentation goes stale because
the *code* moved, not because somebody edited a chapter, so it runs on every build rather
than on a docs path filter. It found four wrong examples the day it was added — two
transcripts carrying a leading space `PRINT` does not emit, a `struct` in this file that had
grown two members hours earlier, and a refusal message quoted with the wrong wording. The
fence-tag convention it reads is documented in [`MAINTENANCE.md`](MAINTENANCE.md).
```output
HELLO 1
HELLO 2
HELLO 3
```
The `Doxyfile` is configured the way `libakgl`'s is, including
`WARN_AS_ERROR = FAIL_ON_WARNINGS` — a doc block that documents some of a function's
parameters but not all of them fails the run, so `doxygen Doxyfile` is a gate rather than a
convenience. Every one of the 114 public declarations under `include/akbasic/` carries a
`@brief`, a `@param` per parameter, a `@return` and its `@throws`.
Two things in that program are not Commodore BASIC and will catch you out immediately: variables
carry a **type suffix** (`I#` is an integer), and **`+` concatenates a string with a number**.
[Chapter 3](docs/03-the-language.md) explains both; [Chapter 13](docs/13-differences.md) is the
whole list of what differs from a C128.
# Why rewrite it in C?
Graphics, sound and sprites need the SDL build, which is off by default because the interpreter
and its entire test suite build on a machine with no SDL installed at all:
```sh norun
cmake -S . -B build-akgl -DAKBASIC_WITH_AKGL=ON
cmake --build build-akgl --parallel
```
That `basic` is a different program: it opens a window, draws BASIC output into it in the
Commodore font, and still puts every byte on stdout.
## Why rewrite it in C?
Three reasons, in the order they matter.
The interpreter is meant to end up *inside* [libakgl](https://source.starfort.tech/andrew/libakgl) as a scripting engine for game authors, and libakgl is C. Embedding a Go runtime in a C game is not a thing anybody should do to themselves.
The Go version was already written against static pools and explicit state structs — `[MAX_SOURCE_LINES]BasicSourceLine`, a fixed variable pool, a 32-leaf ceiling per line — so it ports across almost directly. It reads like C that happens to be spelled in Go. Rewriting it in the idiom of `libakerror` and `libakstdlib` was less work than it sounds.
And the port is a good excuse to find out what the original actually does, as opposed to what it looks like it does. It found five defects nobody knew about. See "What Isn't Implemented / Isn't Working", below.
# What Works?
Everything the Go version does, and by now a little more. All 41 `.bas` files of the reference's own corpus produce the expected output, including error messages and the trailing blank line after one. Each is a separate CTest case, so a failure names the file.
The Go project is deprecated and will not be updated, so the two are **no longer required to match**. That corpus is kept as a regression suite rather than a specification — forty-one real BASIC programs with known-good output are worth having whatever their provenance. Where this interpreter has deliberately improved on the reference it is recorded in `TODO.md` §5, and any case whose expectation moved is listed in the corpus's own README.
That corpus lives at [`tests/reference/`](tests/reference/README.md), copied byte for byte out of the Go project. Its README records where it came from and the rule for changing it: **diverge deliberately, never by accident.** A failure there means this interpreter's behaviour changed — nine times in ten that is a bug you just introduced, and the tenth time it is an improvement that should move the expectation, say why, and add a line to `TODO.md` §5.
## The guide
A full multi-chapter guide lives in [`docs/`](docs/README.md), organised the way the
C128 Programmer's Reference Guide is: the language, then each hardware area, then a
reference section for every verb and function. If you already write BASIC 7.0, start
with [Chapter 13](docs/13-differences.md) — it is the list of everything that behaves
differently here, and why.
## Case Sensitivity
The old computers BASIC was originally written on only had CAPITAL LETTER KEYS on their keyboards. Modern keyboards have the indescribable luxury of upper and lower case. In this basic, verbs and function names are case insensitive. Variable names are case sensitive.
## Variables
* `A#` Integer variables
* `A%` Float variables
* `A$` String variables. Strings support addition operations with other types.
* `LET` is supported but optional
* Variables are strongly typed
Note that `%` means *float* here, which inverts the Commodore convention where `%` is integer. That is what the Go version does and it is not being changed out from under anybody.
## Arrays
* `DIM IDENTIFIER(DIMENSION[, ...])` allows for provisioning of multiple dimensional arrays
* `DIM A$(3)` results in a single dimensional array of strings with 3 elements
* `PRINT A$(2)` accesses the last element in an array and returns it to the verb
* `LEN(A#)` on an array returns its total element count
* Arrays are strongly typed
## Expressions
* `+`
* `-`
* `^`
* `*` (also works on strings)
* `/`
* `< <= <> == >= >` less than, less than equal, not equal, equal, greater equal, greater than
Expressions can be grouped with `()` arbitrarily deeply. Currently the interpreter has a limit of 32 tokens and leaves per line. In effect this means about 16 operations in a single line.
## Commands (Verbs)
The following commands/verbs are implemented:
* `AUTO n` : Turn automatic line numbering on/off at increments of `n`
* `COLLISION t [, line-or-label]` : Call a subroutine when sprites collide. Type 1 only;
omitting the target disarms it. The handler is entered between source lines and must end in
`RETURN`, exactly as a `GOSUB` body does. Types 2 (sprite-to-background) and 3 (light pen)
are refused by name
* `REM` : everything after this is a comment
* `DATA LITERAL[, ...]`: Define a series of literal values that can be read by a preceding `READ` verb
* `DEF FN(X, ...) = expression` : Define a function with arguments that performs a given expression. See also "Subroutines", below.
* `DELETE [n-n]`: Delete some portion of the lines in the current program
* `DELETE`: Delete ALL lines in the program
* `DELETE n-n`: Delete lines between `n` and `n` (inclusive)
* `DELETE -n`: Delete lines from 0 to `n`
* `DELETE n`: Delete lines from `n` to the end of the program
* `DLOAD FILENAME`: Load the BASIC program in the file FILENAME (string literal or string variable) into memory
* `DSAVE FILENAME`: Save the current BASIC program in memory to the file specified by FILENAME (string literal or string variable)
* `EXIT`: Exit a loop before it would normally finish
* `FOR` : Iterate over a range of values and perform (statement) or block each time.
```basic
10 FOR I# = 1 TO 5
20 PRINT I#
30 NEXT I#
40 FOR J# = 1 TO 5 STEP 2
50 PRINT J#
60 NEXT J#
```
```output
1
2
3
4
5
1
3
5
```
* `CLR`: Drop every variable and function definition, keeping the program
* `CONT`: Resume a program stopped by `STOP`, from where it stopped. Refused if nothing stopped
* `GOTO n`: Go to line n in the program
* `GOSUB n`: Go to line n in the program and return here when `RETURN` is found
* `HELP`: Re-list the line the last error happened on
* `IF (comparison) THEN (statement) [ELSE (statement)]` : Conditional branching. Everything after
`THEN` on the line belongs to the condition, and everything after an `ELSE` belongs to the
`ELSE` — the same scoping a C128 uses
* `INPUT "PROMPT STRING" VARIABLE`: Read input from the user and store it in the named variable
* `LABEL IDENTIFIER`: Place a label at the current line number. Labels are constant integer identifiers that can be used in expressions like variables (including GOTO) but which cannot be assigned to. Labels do not have a type suffix (`$`, `#` or `%`).
* `LIST [n-n]`: List all or a portion of the lines in the current program, with the same range forms as `DELETE`
* `NEW`: Erase the program *and* every variable, and take the line counter home
* `POKE ADDRESS, VALUE`: Poke the single byte VALUE into the ADDRESS
* `PRINT (expression)`
* `QUIT` : Exit the interpreter
* `READ IDENTIFIER[, ...]` : Fill the named variables with data from a subsequent DATA statement
* `RETURN` : return from `GOSUB` to the point where it was called
* `RUN [n]`: Run the program currently in memory, optionally starting at line `n`
* `STOP`: Stop program execution at the current point. `CONT` resumes from there
* `MOVSPR n, ...` : Move a sprite. Four forms, and the separators are what tell them apart:
* `MOVSPR n, x, y` — put it at (x, y) in BASIC's 320x200 space
* `MOVSPR n, +x, -y` — move it *by* that much; a signed coordinate is an offset
* `MOVSPR n, d ; a` — move it `d` pixels along bearing `a`, in degrees clockwise from
straight up. Distance first, then angle
* `MOVSPR n, a # s` — set it moving along bearing `a` at speed `s` (0-15, 0 stops). Nothing
moves on the statement itself: the sprite moves as the program runs, paced off the host's
clock
* `SPRCOLOR [c1] [, c2]` : Set the two colour registers every multicolour sprite shares. Either
may be omitted, which leaves that register alone
* `SPRITE n [,on] [,colour] [,priority] [,xexpand] [,yexpand] [,multicolour]` : Configure a
sprite. Only the number is required, and an omitted argument leaves that attribute alone
* `SPRSAV source, n` : Give sprite `n` an image. Three kinds of source:
* `SPRSAV "ship.png", n` — an image file. Tried against the working directory first and then
against the directory the running program was loaded from, so a program stored beside its
art works from anywhere. The sprite takes the image's own size
* `SPRSAV A$, n` — a region `SSHAPE` saved
* `SPRSAV A#, n` — 63 elements of a DIMmed integer array, three bytes per row and twenty-one
rows, most significant bit leftmost. This is the `DATA`-driven form; it takes an integer
array rather than a string because a string here cannot hold a zero byte
* `SWAP A, B`: Exchange two variables of the same type, arrays and their dimensions included
* `TRON` / `TROFF`: Turn line tracing on and off. A traced program prints `[10][20]` inline
ahead of each line, as a C128 does
Statements are separated by colons, so a line can hold several: `10 A# = 1 : PRINT A#`. An empty
statement is legal, so a trailing colon and a run of them are both fine.
## Functions
The following functions are implemented
* `ABS(x#|x%)`: Return the absolute value of the float or integer argument
* `ATN(x#|x%)`: Return the arctangent of the float or integer argument. Input and output are in radians.
* `BUMP(1)`: Return a bitmask of which sprites have collided since the last call — bit 0 is sprite 1. **Reading clears it**, which is what makes "has anything hit me since I last looked" answerable at all. Type 1 (sprite-to-sprite) only
* `CHR(x#)`: Return the character value of the UTF-8 unicode codepoint in x#. Returns as a string.
* `COS(x#|x%)`: Return the cosine of the float or integer argument. Input and output are in radians.
* `HEX(x#)`: Return the string representation of the integer number in x#
* `INSTR(X$, Y$)`: Return the index of `Y$` within `X$` (-1 if not present)
* `LEN(var$)`: Return the length of the object `var$` (either a string or an array)
* `LEFT(X$, Y#)`: Return the leftmost Y# characters of the string in X$. Y# is clamped to LEN(X$).
* `LOG(X#|X%)`: Return the natural logarithm of X#|X%
* `MID(var$, start, length)` : Return a substring from `var$`
* `MOD(x#, y#)`: Return the modulus of ( x / y). Only works on integers.
* `PEEK(X)`: Return the value of the BYTE at the memory location of integer X and return it as an integer
* `POINTER(X)`: Return the address in memory for the value of the variable identified in X. This is the direct integer, float or string value stored, it is not a reference to the internal variable structure.
* `POINTERVAR(X)` : Return the address in memory of the variable X. This is the address of the internal `akbasic_Variable` structure, which includes additional metadata about the variable, in addition to the value. For a pointer directly to the value, use `POINTER`.
* `RAD(X#|X%)`: Convert degrees to radians
* `RSPCOLOR(F#)`: Return one of `SPRCOLOR`'s two shared registers — 1 or 2
* `RSPPOS(N#, F#)`: Return sprite `N#`'s x (0), y (1) or speed (2)
* `RSPRITE(N#, F#)`: Return one of sprite `N#`'s `SPRITE` settings, in `SPRITE`'s own argument order: enabled (0), colour (1), priority (2), x-expand (3), y-expand (4), multicolour (5)
* `RIGHT(X$, Y#)`: Return the rightmost Y# characters of the string in X$. Y# is clamped to LEN(X$).
* `SGN(X#)`: Returns the sign of X# (-1 for negative, 1 for positive, 0 if 0).
* `SHL(X#, Y#)`: Returns the value of X# shifted left Y# bits
* `SHR(X#, Y#)`: Returns the value of X# shifted right Y# bits
* `SIN(X#|X%)`: Returns the sine of the float or integer argument. Input and output are radians.
* `SPC(X#)`: Returns a string of X# spaces. This is included for compatibility, you can also use `(" " * X)` to multiply strings.
* `STR(X#)`: Returns the string representation of X.
* `TAN(X#|X%)`: Returns the tangent of the float or integer variable X. Input and output are in radians.
* `VAL(X$)`: Returns the float value of the number in X$
* `XOR(X#, Y#)`: Performs a bitwise exclusive OR on the two integer arguments
Unlike the Go version, none of these are bootstrapped by running a BASIC program of `DEF` statements through the interpreter at startup. A builtin's name, arity and handler are one row in the dispatch table in `src/verbs.c`, which means the interpreter no longer has to be running before the interpreter is ready.
## Subroutines
In addition to `DEF`, `GOTO` and `GOSUB`, this BASIC also implements subroutines that accept arguments, return a value, and can be called as functions. Example
```basic
10 DEF ADDTWO(A#, B#)
20 C# = A# + B#
30 RETURN C#
40 D# = ADDTWO(3, 5)
50 PRINT D#
```
```output
8
```
Subroutines must be defined before they are called. Subroutines share the global variable scope with the rest of the program.
# Embedding the interpreter
The whole point of the rewrite. `libakbasic` is a static library with a thin `src/main.c` driver on top; the REPL, argv handling and `QUIT` belong to the driver, not to the library. A host program links the library and keeps control.
Four rules the library holds to, because a game engine cannot tolerate a scripting language that breaks any of them:
* **Nothing terminates the process.** No `exit()`, no `abort()`, no `panic`. Errors come back as `akerr_ErrorContext *`. `FINISH_NORETURN` never appears in the library at all — only in a `main()`, which today means the driver's and the embedding example's.
* **Nothing calls `malloc`.** Every object comes from a fixed pool inside `akbasic_Runtime`. Exhausting one is a diagnosable error, not a crash and not a slow leak.
* **No file-scope mutable state.** Interpreter state lives in an `akbasic_Runtime` you own. Two of them in one process do not interfere.
* **The host owns the loop.** `akbasic_runtime_run(rt, n)` executes at most `n` source lines and returns. A script with an infinite loop costs you `n` lines per frame and nothing else.
A complete, compiled, runnable example is in [`examples/embed.c`](examples/embed.c) — it is built by every build and registered as a test, so it cannot rot. The shape is:
```c wrap=hostvars
#include <akerror.h>
#include <akbasic/runtime.h>
#include <akbasic/sink.h>
/*
* The runtime carries every pool the interpreter owns -- a few megabytes -- so
* it goes in static storage or inside your own game state, never on the stack.
*/
static akbasic_Runtime SCRIPT;
static const char *PROGRAM =
"10 PRINT \"COUNTING:\"\n"
"20 FOR I# = 1 TO 5\n"
"30 PRINT I# * I#\n"
"40 NEXT I#\n";
akerr_ErrorContext AKERR_NOIGNORE *script_start(akbasic_TextSink *sink)
{
PREPARE_ERROR(errctx);
PASS(errctx, akbasic_runtime_init(&SCRIPT, sink));
PASS(errctx, akbasic_runtime_load(&SCRIPT, PROGRAM));
PASS(errctx, akbasic_runtime_start(&SCRIPT, AKBASIC_MODE_RUN));
SUCCEED_RETURN(errctx);
}
/* Call this once per frame. Eight source lines, then back to your renderer. */
akerr_ErrorContext AKERR_NOIGNORE *script_tick(void)
{
PREPARE_ERROR(errctx);
if ( SCRIPT.mode == AKBASIC_MODE_QUIT ) {
SUCCEED_RETURN(errctx);
}
PASS(errctx, akbasic_runtime_run(&SCRIPT, 8));
SUCCEED_RETURN(errctx);
}
```
## Exchanging variables with the host
Yes — in both directions, for integers, floats and strings, using the same variable pool the
script itself uses. There is no marshalling layer and no copy: the host and the script are
looking at the same `akbasic_Value`.
`akbasic_runtime_global()` finds a variable by name and creates it if it does not exist. The
type comes from the name's suffix, exactly as it does for BASIC code, so `HP#` is an integer
and `NAME$` is a string. Every scalar is really a one-element array, which is why the subscript
list is `{0}` with a count of 1.
```c wrap=hostvars
/* host -> script, before the script starts */
akerr_ErrorContext AKERR_NOIGNORE *host_set_int(akbasic_Runtime *obj, const char *name, int64_t value)
{
PREPARE_ERROR(errctx);
akbasic_Variable *variable = NULL;
int64_t subscript[1] = { 0 };
PASS(errctx, akbasic_runtime_global(obj, name, &variable));
PASS(errctx, akbasic_variable_set_integer(variable, value, subscript, 1));
SUCCEED_RETURN(errctx);
}
/* script -> host, after it stops */
akerr_ErrorContext AKERR_NOIGNORE *host_get_int(akbasic_Runtime *obj, const char *name, int64_t *dest)
{
PREPARE_ERROR(errctx);
akbasic_Variable *variable = NULL;
akbasic_Value *value = NULL;
int64_t subscript[1] = { 0 };
PASS(errctx, akbasic_runtime_global(obj, name, &variable));
PASS(errctx, akbasic_variable_get_subscript(variable, subscript, 1, &value));
FAIL_NONZERO_RETURN(errctx, (value->valuetype != AKBASIC_TYPE_INTEGER), AKBASIC_ERR_TYPE,
"%s is not an integer", name);
*dest = value->intval;
SUCCEED_RETURN(errctx);
}
```
Used like this, with `akbasic_variable_set_string` and `set_float` as the other two:
```c wrap=hostcalls
CATCH(errctx, akbasic_runtime_load(&SCRIPT, PROGRAM));
CATCH(errctx, host_set_int(&SCRIPT, "HP#", 100)); /* seed */
CATCH(errctx, host_set_int(&SCRIPT, "LEVEL#", 7));
CATCH(errctx, host_set_string(&SCRIPT, "NAME$", "LINK"));
CATCH(errctx, akbasic_runtime_start(&SCRIPT, AKBASIC_MODE_RUN));
CATCH(errctx, akbasic_runtime_run(&SCRIPT, 0));
CATCH(errctx, host_get_int(&SCRIPT, "SCORE#", &score)); /* collect */
```
The full thing, including the string and float forms, is in
[`examples/hostvars.c`](examples/hostvars.c). It is built and run by every build.
### Which scope it lands in, and why you should not care
`akbasic_runtime_global()` always resolves against the script's outermost scope, so seeding
before `akbasic_runtime_start()`, reading after the script stops, and creating a variable while
a script is suspended part-way through a bounded `akbasic_runtime_run()` all do the same,
obvious thing.
That is worth one paragraph of history, because the obvious-looking alternative is a trap.
`akbasic_environment_get()` resolves against `obj->environment` — whatever scope is *active* —
and a suspended script is usually inside a `FOR` or `GOSUB` body. A variable created through it
lands in that body and dies when the body pops: the script reads it correctly inside the loop
and gets `0` the moment the loop ends, with nothing raised anywhere. Reaching for the root
explicitly does not help either, because only the active environment auto-creates, so
`akbasic_environment_get(root, "NEW#", &var)` with a child active hands back `NULL` and no
error and an unchecked host dereferences it.
Both of those are still true of `akbasic_environment_get()`, which is the right behaviour for
what the *interpreter* uses it for. They are simply not your problem: use
`akbasic_runtime_global()`.
[`examples/hostvars.c`](examples/hostvars.c) shows the difference rather than describing it,
and `tests/hostvars.c` asserts it — a global created from inside a `FOR` body, and from inside
a `GOSUB`, and still readable by the script afterwards.
## Where the output goes
`PRINT` writes through an `akbasic_TextSink`, which is a record of function pointers plus whatever state you hang off `self`:
```c excerpt=include/akbasic/sink.h
typedef struct akbasic_TextSink
{
void *self;
akerr_ErrorContext AKERR_NOIGNORE *(*write)(struct akbasic_TextSink *self, const char *text);
akerr_ErrorContext AKERR_NOIGNORE *(*writeln)(struct akbasic_TextSink *self, const char *text);
akerr_ErrorContext AKERR_NOIGNORE *(*readline)(struct akbasic_TextSink *self, char *dest, size_t len, bool *eof);
akerr_ErrorContext AKERR_NOIGNORE *(*clear)(struct akbasic_TextSink *self);
akerr_ErrorContext AKERR_NOIGNORE *(*moveto)(struct akbasic_TextSink *self, int col, int row);
akerr_ErrorContext AKERR_NOIGNORE *(*window)(struct akbasic_TextSink *self, int left, int top, int right, int bottom);
} akbasic_TextSink;
```
`akbasic_sink_init_stdio()` ships with the library and is what the driver and the golden-file suite use. A game supplies its own and draws into a text layer. The interpreter never owns a window, a renderer or an event loop, and `readline` is expected to set `*eof` rather than block — that is how `INPUT` behaves sanely inside a frame.
`akbasic_sink_init_tee()` also ships with the library and composes two sinks into one: writes go to both, and `readline` comes from whichever of the two you name as the reader. That is how the SDL build puts `PRINT` in a window *and* on stdout without the interpreter carrying a second write. It needs no SDL, so a game can use it to log a script's output to a file while it draws.
## Error codes
The library reserves status values **512767** with `libakerror`'s registry, under the owner string `"akbasic"`. `akbasic_runtime_init()` claims the range for you and is idempotent, so calling it twice is harmless and calling it after your own initialization is fine. If something else in the process already owns part of that band, `init` fails loudly rather than silently aliasing your error codes onto somebody else's.
For reference, the coordinated map across this dependency stack is `libakerror` 0255, `libakgl` 256260, and `akbasic` 512767.
## Linking
The interpreter is meant to end up *inside*
[libakgl](https://source.starfort.tech/andrew/libakgl) as a scripting engine for game authors,
and libakgl is C. Embedding a Go runtime in a C game is not a thing anybody should do to
themselves.
The Go version was already written against static pools and explicit state structs — a fixed
source table, a fixed variable pool, a 32-leaf ceiling per line — so it ports across almost
directly. It reads like C that happens to be spelled in Go.
And the port is a good excuse to find out what the original actually does, as opposed to what it
looks like it does. It found five defects nobody knew about.
## Design philosophy
A game engine cannot tolerate a scripting language that surprises it. Five rules follow from
that, and between them they explain most of what looks unusual in this interpreter:
* **Nothing in the library terminates the process.** No `exit()`, no `abort()`, no `panic`.
Errors come back as `akerr_ErrorContext *` for the host to handle. `FINISH_NORETURN` appears
only in a `main()`.
* **Nothing calls `malloc`.** Every object comes from a fixed pool inside `akbasic_Runtime`.
Exhausting one is a diagnosable error, not a crash and not a slow leak.
* **No file-scope mutable state.** Interpreter state lives in an `akbasic_Runtime` you own. Two
of them in one process do not interfere.
* **The host owns the loop.** `akbasic_runtime_run(rt, n)` executes at most `n` steps and
returns. A script containing `10 GOTO 10` costs you `n` steps per frame and nothing else.
* **Hardware is a record of function pointers.** Graphics, audio, input and sprites attach as
backends, and any of them may be `NULL` — that is how a host withholds a capability, and how a
host that renders some other way never links libakgl at all.
Nothing is silently ignored, either. A verb that needs a device it was not given, or a capability
nothing underneath can supply, refuses by name and says why.
## Two ways to use it
**As a program.** `basic` is a REPL and a script runner, and the standalone driver owns the
things a library has no business owning: argv, `QUIT`, and the window in the SDL build.
[The guide](docs/README.md) is written for this reader.
**As a library.** A host links `akbasic::akbasic`, hands the interpreter a script, and steps it a
frame at a time:
```cmake
add_subdirectory(deps/akbasic EXCLUDE_FROM_ALL)
target_link_libraries(YOUR_GAME PRIVATE akbasic::akbasic)
```
`akbasic` links `akerror::akerror` and `akstdlib::akstdlib` publicly, so you inherit both. If your project already declares those targets, declare them *before* adding this one — the same rule that applies to `libakgl`.
Host and script exchange variables through the same pool the script itself uses — no marshalling
layer and no copy. `PRINT` goes through an `akbasic_TextSink` the host supplies, so a game draws
BASIC output into its own text layer. [`examples/embed.c`](examples/embed.c) and
[`examples/hostvars.c`](examples/hostvars.c) are complete and runnable, and both are built and
run by every build, so they cannot rot. [Chapter 10](docs/10-embedding.md) walks through the API.
`libakgl` itself is **not** a dependency of the core library. `-DAKBASIC_WITH_AKGL=ON` builds two additional targets, and the split between them is the one that matters if you are embedding this:
## What state it is in
| Target | What it is | Link it? |
|---|---|---|
| `akbasic` | The interpreter. No SDL, no window, nothing that terminates the process. | Always. |
| `akbasic_akgl` | The akgl-backed text sink and the graphics, sound and input backends. Every one takes a renderer, a font or a device **you** already created. | If you want the interpreter drawing through your `libakgl` renderer. |
| `akbasic_frontend` | The standalone program's host: it creates the window, opens the font, pumps SDL events and owns the frame loop. | **No.** You are the host. This exists so `basic` can be one. |
Everything the Go version does, and by now a good deal more. All 41 `.bas` files of the
reference's corpus produce the expected output, including error messages, each as a separate
CTest case so a failure names the file.
Both are off by default, which is why the interpreter and its whole test suite build on a machine with no SDL.
What is still missing, what is refused deliberately, and the eleven defects inherited from the Go
version are catalogued in [`TODO.md`](TODO.md) and summarised for a BASIC programmer in
[Chapter 13](docs/13-differences.md).
The graphics, sound, console and sprite verbs reach hardware through records of function pointers on the runtime — `akbasic_GraphicsBackend`, `akbasic_AudioBackend`, `akbasic_InputBackend` and `akbasic_SpriteBackend`, attached with `akbasic_runtime_set_devices()`. All four may be `NULL`, which is what a default build of the standalone driver gives them: a verb that needs a device it was not given raises rather than crashing, and a `PRINT`-only program never notices. An SDL build attaches all four. A host that renders some other way supplies its own records and never links `libakgl` at all.
## Where everything else lives
**Sprites are real `libakgl` actors.** Each of BASIC's eight becomes a `SpriteSheet`, a `Sprite`, a `Character` and an `Actor` registered in `AKGL_REGISTRY_ACTOR`, so a host game sees a script's sprites alongside its own and can do anything to them it can do to an actor. `akbasic_sprite_akgl_render()` draws them; a host that never calls it gets a script whose sprite state is correct and whose sprites are never drawn. None of it goes through libakgl's sprite JSON — a Commodore sprite has no frame list, no animation speed and no state map — except for `SPRSAV "ship.png", n`, which resolves and loads through exactly the calls the JSON loader makes.
| | |
|---|---|
| [`docs/`](docs/README.md) | The guide: thirteen chapters, the language then each hardware area then a reference section for every verb and function |
| [`MAINTENANCE.md`](MAINTENANCE.md) | For contributors and maintainers: the documentation-example harness, the three test lists, mutation testing, error-code allocation, style |
| [`TODO.md`](TODO.md) | Outstanding defects, with file, line and consequence |
| [`tests/reference/README.md`](tests/reference/README.md) | Where the golden corpus came from, and the rule for changing it |
If you drive the interpreter without `akbasic_frontend_akgl_init()`, sprites need three things from you that `akgl_game_init()` would otherwise have done: `akgl_heap_init()`, `akgl_registry_init()`, and a `camera` pointing somewhere. `akgl_actor_render()` dereferences that global without checking it. `src/frontend_akgl.c` is the worked example.
API documentation builds with `doxygen Doxyfile`, into `build/docs/html`.
Two things about those verbs that differ from a real C128, because both would otherwise surprise you. `PLAY` does not block — it queues its notes and `akbasic_runtime_step()` releases them against whatever time you last passed to `akbasic_runtime_settime()`, so the statement after a `PLAY` runs immediately. And `GETKEY` holds the program without blocking you: the step still returns, it simply does not advance until a key arrives.
## Dependencies
## Limits
Everything is a submodule; `git submodule update --init --recursive` gets all of it. There is
nothing to install first.
Everything is bounded and pre-declared. The numbers are in `include/akbasic/types.h` and are the Go version's, plus three the Go version did not need because it called `make()`:
| Constant | Value | What it bounds |
|---|---|---|
| `AKBASIC_MAX_LEAVES` / `_TOKENS` | 32 | AST nodes and tokens per source line (~16 operations) |
| `AKBASIC_MAX_VALUES` | 64 | Intermediate values per line |
| `AKBASIC_MAX_VARIABLES` | 128 | Variables per scope |
| `AKBASIC_MAX_SOURCE_LINES` | 9999 | Program length |
| `AKBASIC_MAX_LINE_LENGTH` | 256 | Characters per line |
| `AKBASIC_MAX_STRING_LENGTH` | 256 | Characters in a string value |
| `AKBASIC_MAX_ENVIRONMENTS` | 32 | Nesting depth of `FOR` and `GOSUB` |
| `AKBASIC_MAX_ARRAY_ELEMENTS` | 1024 | Elements in one array |
| `AKBASIC_MAX_ARRAY_VALUES` | 4096 | Array elements across all variables |
Raising any of them costs BSS and nothing else. An `akbasic_Runtime` is presently **10.1MB**, and it is worth knowing where that goes before you reach for a knob: 4.1MB is the environment pool, 2.5MB the 9999-line source table, 2.3MB the function-definition pool and 1.2MB the array value pool. `AKBASIC_MAX_ENVIRONMENTS` is the expensive one — each environment carries its own token, leaf and value arrays — so halving it to 16 saves twice what halving the source table does.
# What Isn't Implemented / Isn't Working
## Defects inherited from the Go version
These are reproduced deliberately, not fixed. The Go version's test corpus is the acceptance suite, so a silent correction here is a behaviour change that would show up as a failing golden file. Each is catalogued in [`TODO.md`](TODO.md) section 6 with the file and line, and `tests/known_reference_defects.c` asserts the **correct** contract for six of them under `AKBASIC_KNOWN_FAILING_TESTS` — when one is fixed, CTest reports "unexpectedly passed", which is the cue to move it.
Five of them the port found; nobody knew about these before:
* **`1 - 2 - 3` computes `1 - 2`.** Subtraction stops after one operator where addition loops, so the rest of the line is abandoned in the token stream. This is the bad one — it is a *wrong answer*, not a refused one. `2 ^ 3 ^ 2` has the same shape.
* **A negative literal cannot be passed to a builtin.** `ABS(-9)` is rejected with "function ABS takes 1 arguments, received 2", because the arity counter walks the same `.right` pointer a unary-minus leaf keeps its operand on. Assign to a variable first, as the Go version's own `sgn.bas` test quietly does.
* **A comparison operator in a line's final column is dropped.** `A# =` produces one token, not two, because the scanner cannot peek past the end of the line and gives up without recording the operator.
* **Hex literals do not work.** `0xff` lexes as `0x` followed by an identifier `ff`. The base-16 branch in the literal parser is unreachable, so the hex support the Go README implies has never existed.
* **`PRINT$` is accepted as a variable name.** The "Reserved word in variable name" check compares the lexeme *including* its type suffix against the keyword table, so it never matches and never fires.
Plus six more the original already carried: a leading `0` selects base 8, so `PRINT 010` prints 8 and `PRINT 08` will not parse; `setBoolean` tags the value it builds `TYPE_STRING`; `stopWaiting` ignores its argument, so an inner block can clear an outer block's pending wait; `toString` on a variable has its emptiness test inverted; `EXIT` pops a loop without clearing that wait; and `mathPlus` mutates its left operand in place where every other operator clones. That last one is load-bearing rather than merely wrong — `NEXT` relies on it to advance the loop counter, so it cannot be fixed on its own.
## Not implemented
This list was checked against the dispatch table in `src/verbs.c` when it was last edited. It
had drifted badly before that -- it named a third of the language as missing after it had
landed -- so check it the same way rather than trusting it.
* `APPEND`, `BACKUP`, `BLOAD`, `BOOT`, `BSAVE`
* `BEGIN`, `BEND`, `DO`, `LOOP`, `WHILE`, `UNTIL`, `END`, `ON`. You can do the same thing with `IF` and `GOTO`.
* `CATALOG`, `CHAR`, `CLOSE`, `CMD`, `COLLECT`, `CONCAT`, `COPY`
* `DCLEAR`, `DCLOSE`, `DIRECTORY`, `DOPEN`, `DVERIFY`
* `ER`, `ERR`, `TRAP`, `RESUME`
* `FETCH`, `STASH`, `SYS`
* `HEADER`, `KEY`, `LOAD`, `SAVE`, `SCRATCH`, `RENAME`, `VERIFY`
* `PUDEF`, `RENUMBER`, `RESTORE`
* `SLEEP`, `TI`, `WAIT`, `WIDTH`, `WINDOW`, `USING`
* `SPRDEF`
* The I/O-channel variants (`GETIO`, `INPUTIO`, `OPENIO`, `PRINTIO`, `RECORDIO`)
`FILTER` parses and is then refused, which is a third state and deliberate. It sets the SID's
filter cutoff, band switches and resonance; `akgl_audio_*` synthesises raw waveforms and mixes
them, there is no filter stage to configure, and SDL3 supplies no primitive to build one from.
Until an `akgl_audio_filter()` exists it refuses at execution rather than being silently
ignored -- a program that asks for a low-pass and gets an unfiltered square wave has been lied
to.
It is the only verb still blocked on a missing `libakgl` capability. Four others were -- text
measurement, immediate-mode drawing, audio, and a non-blocking keystroke read -- and rather
than work around them here they were filed in
[`deps/libakgl/TODO.md`](deps/libakgl/TODO.md) under "API gaps blocking akbasic". All four have
since landed upstream (`akgl_text_measure`, the `akgl_draw_*` family, `akgl_audio_*`, and
`akgl_controller_poll_key`), so what is left is akbasic-side work.
Building the sprite verbs turned up three more, none of which blocks a verb. An actor is drawn
square regardless of its sprite's height (libakgl's own defect 26) and an actor cannot be scaled
per axis (filed from here); both are worked around by installing a `renderfunc` of ours on each
actor. The third — an error context leaked per relative asset path resolved — was found and
fixed upstream in 0.4.0 while this was being written.
## Deliberately out of scope
* `BANK` - the modern PC memory layout is incompatible with the idea of bank switching
* `FAST` - Irrelevant on modern PC CPUs
* `MONITOR` - there is no machine-language monitor to drop into
* `SPRDEF` - an interactive full-screen sprite editor driven by single keystrokes, not a
programmable verb. A program cannot call it usefully and this interpreter does not own the
screen it would take over. `SPRSAV`'s three source forms are what replace it
# Dependencies
* [libakerror](https://source.starfort.tech/andrew/libakerror) 1.0.0 — TRY/CATCH-style error contexts. Every function that can fail returns one.
* [libakstdlib](https://source.starfort.tech/andrew/libakstdlib) 0.2.0 — libc wrappers that report through `libakerror`.
* [libakgl](https://source.starfort.tech/andrew/libakgl) 0.4.0 — **optional**, only for `-DAKBASIC_WITH_AKGL=ON`. Pulls in SDL3. 0.3.0 closed every API gap this project had filed against it; 0.4.0 is a leak-and-overread release. It changed no public struct, but the soname carries `MAJOR.MINOR` while the major is 0, so rebuild rather than relink.
* [basicinterpret](https://source.starfort.tech/andrew/basicinterpret) — the Go original. Vendored as the behavioural spec for questions about semantics, and for nothing else: its acceptance corpus and its Commodore font are checked in here now, so **the build and the test suite do not need it**. Not linked, not built, and safe to omit.
Everything is a submodule; `git submodule update --init --recursive` gets all of it. There is nothing to install first.
String-to-number conversion goes straight to `libakstdlib`, which raises on no digits, on trailing junk and on overflow — so `VAL("garbage")` is an error rather than a silent `0`. This repository carried its own `src/convert.c` wrapper until `libakstdlib` 0.2.0 grew that contract; `tests/numeric_contract.c` now pins it from this side, because a regression in it would make four things quietly return zero.
* [libakerror](https://source.starfort.tech/andrew/libakerror) 1.0.0 — TRY/CATCH-style error
contexts. Every function that can fail returns one.
* [libakstdlib](https://source.starfort.tech/andrew/libakstdlib) 0.2.0 — libc wrappers that
report through `libakerror`. String-to-number conversion goes straight to it, which is why
`VAL("garbage")` is an error rather than a silent `0`.
* [libakgl](https://source.starfort.tech/andrew/libakgl) 0.4.0 — **optional**, only for
`-DAKBASIC_WITH_AKGL=ON`. Pulls in SDL3. Its soname carries `MAJOR.MINOR` while the major is 0,
so rebuild rather than relink.
* [basicinterpret](https://source.starfort.tech/andrew/basicinterpret) — the Go original.
Not linked, not built, and safe to omit.