Bumps all three ak* submodules to their current main, applies what their
upgrade notes require, and retires the two workarounds they make obsolete.
libakerror 2.0.1 -> 2.0.2 (63 commits). Two of its fixes were this
repository's own filed issues, and both workarounds are gone:
- It namespaces its embedded `coverage` target now, the same
CMAKE_SOURCE_DIR test it already applied to `mutation` (its issue #15).
The add_custom_target() shadow that renamed it on the way past could
only ever fire on a name the dependency has stopped using, so it is
deleted rather than left as dead code.
- It installs akerrorConfigVersion.cmake at SameMajorVersion (its issue
#16). MAINTENANCE.md said to add a `1.0` floor to our find_dependency
calls when this landed; the floor is now `2.0`, and we have no
find_dependency calls to add it to, so the paragraph says that instead
of an instruction nobody can follow.
Its IGNORE context also changed shape: `__akerr_last_ignored` was an extern
pointer, and is now a per-translation-unit `static akerr_last_ignored` holding
a copy, so the pool slot can be released. Nothing here referenced the symbol,
but it costs us 1.35 MiB of thread-local storage -- 38 TUs x 37,296 bytes,
measured as the entire TLS segment of build/basic, where 2.0.1 produced no TLS
segment at all -- plus 84 -Wunused-variable warnings. Filed upstream as
libakerror issue #37 and recorded in MAINTENANCE.md rather than patched here,
because patching a submodule forks it.
libakstdlib gains directory and file-metadata wrappers with no version bump.
aksl_snprintf keeps its `int *count` -- an intermediate commit removed it and
the merge put it back -- but now reports the required length on truncation
rather than 0. Every call site here reads it only after a successful return,
so nothing moved.
The directory wrappers close the gap DIRECTORY was refused for (libakstdlib
issue #10). The verb is still unwritten, so it still refuses, but it no longer
blames a wrapper that exists: the message is "DIRECTORY is not implemented
yet" and tests/disk_verbs.c asserts both that it says so and that it does not
name libakstdlib. What writing it would need is akbasic issue #55.
libakgl moves to the current main at 0.9.0. It registers libccd and tg as
submodules, so a tree that only ran `git submodule update --init --recursive`
before the bump needs it again or the configure fails on a missing
libccd/src/ccd/config.h.cmake.in.
Verified: 114/114 default, 116/116 under -DAKBASIC_WITH_AKGL=ON, docs_examples
green in both. libakerror's UPGRADING.md documents a 2.0.3 that project()
never stamped, so the version tables read 2.0.2 -- libakerror issue #38.
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
Co-Authored-By: Claude Code (Claude Opus 5, claude-opus-5[1m]) <noreply@anthropic.com>
52 KiB
Maintaining akbasic
README.md is for people evaluating the interpreter. docs/ is for people writing BASIC in
it. This file is for whoever has to change the thing: what the project is trying to be, how
the build fits together, and the conventions — the ones enforced by a test, and the ones
enforced by nothing at all and therefore written down here.
CLAUDE.md is for agents, and it is deliberately thin: it points here. Everything an agent
is told about this project is in this file, so there is one copy to keep true.
One thing is deliberately not here. How the interpreter actually works — the step loop,
the four modes, the pools, the scopes that double as block state, the two kinds of error,
and how to debug all of it — is docs/14-architecture.md. This
file is the conventions for changing the thing; that chapter is the thing.
What this project is
akbasic is a C rewrite of the Go BASIC interpreter in deps/basicinterpret, written in
the idiom of the ak* C libraries it builds on.
| Submodule | Language | Role |
|---|---|---|
deps/basicinterpret |
Go | The implementation that was rewritten; the behavioural spec for questions about semantics |
deps/libakerror |
C | TRY/CATCH-style error contexts — the substrate every other ak* library is built on |
deps/libakstdlib |
C | libc and data-structure wrappers that report through libakerror |
deps/libakgl |
C | SDL3-based game/graphics library (sprites, text, tilemaps, actors, heap) — supplies all multimedia |
The goals, in priority order
-
Port Go → C. Reproduce the reference interpreter in C:
akerr_ErrorContext *returns everywhere, fixed-size pools instead of dynamic allocation, library-prefixed symbols, one-file CTest executables. The Go code is already written against static pools and explicit state structs, so it ports fairly directly — resist "improving" it intomalloc-based data structures. SDL2/SDL2_ttf calls in the Go version becomelibakglcalls (akgl_text_*, the renderer, the registry), not raw SDL3 calls. -
Finish the language. The full Dartmouth BASIC and Commodore 128 BASIC 7.0 verb and function set.
deps/basicinterpret/README.mdends with the original's list of what was unimplemented, and that list was the work queue. Every group of it is done; what remains is in the issue tracker and is summarised for a BASIC programmer indocs/13-differences.md. A few entries are deliberately out of scope on a modern PC —BANK,FAST,MONITOR, andSPRDEF, which is an interactive editor rather than a programmable verb. Keep that reasoning rather than reviving them. -
Be embeddable. The end state is the interpreter linking into
libakglas a scripting engine for game authors. That constrains the design from the start:- The interpreter is a library target with a thin
src/main.cdriver on top. The REPL,QUITand argv handling belong to the driver, not the library. - Interpreter state lives in explicit structs passed by pointer. No file-scope mutable globals.
- Nothing in the library may terminate the process. Errors propagate out as
akerr_ErrorContext *for the host game to handle;FINISH_NORETURNbelongs only in amain(). - The interpreter owns no window, renderer or game loop. It draws through whatever
akglrenderer the host already initialized, and a host must be able to bound execution rather than surrender control to arun()that never returns.
- The interpreter is a library target with a thin
Missing capabilities get filed upstream, not worked around
When libakgl — or libakstdlib — cannot supply something a verb needs, do not work
around it here. Open an issue in that repository's tracker on
https://source.starfort.tech describing the missing API: what the BASIC verb requires, what
the akgl_* or aksl_* entry point should look like, and what tests would cover it.
Recording it in this repository instead is not filing it — two akgl_ui gaps sat in
TODO.md for a release on the reasoning that changing a submodule is that repository's
decision, which is true of changing it and not of reporting it. Follow the prose-paragraph style of the entries already
there. Growing the dependency to serve the interpreter is a wanted outcome, not a detour.
It works. Five gaps were filed this way and all five landed upstream: text measurement,
immediate-mode drawing, audio and a non-blocking keystroke read became akgl_text_measure,
the akgl_draw_* family, akgl_audio_* and akgl_controller_poll_key; and the
directory-reading wrapper DIRECTORY was waiting on became aksl_opendir, aksl_readdir,
aksl_closedir and aksl_rewinddir — libakstdlib issue #10, in the revision this tree
pins.
That leaves the two refusals in different positions, and the difference is worth keeping
straight. FILTER is the one verb still blocked on a gap — there is no filter stage in
akgl_audio_* to configure. DIRECTORY is no longer blocked on anything; it is simply
unwritten, and its refusal says so rather than naming a wrapper that now exists. Both refuse
at execution and say so, rather than being silently ignored: a program that asks for a
low-pass filter and gets an unfiltered square wave has been lied to.
The Go reference
deps/basicinterpret is a Commodore BASIC 7.0 / Dartmouth BASIC dialect in Go with SDL2 +
SDL2_ttf. It is the behavioural spec: when a question about semantics comes up — "what does
READ do when it runs off the end of DATA?" — the answer lives in that code.
That project is deprecated and this interpreter is no longer required to match it.
TODO.md §0.1 retires the byte-for-byte fidelity constraint that several sections of that
file were originally written on. The corpus stays green as a regression suite; the Go
source stays readable as documentation of what the original did. Neither is binding.
It is not a build or test dependency. Both configurations have been configured, built and
run from scratch with it moved out of the tree. Its acceptance corpus is checked in at
tests/language/ and its Commodore font at assets/fonts/.
cd deps/basicinterpret
make # builds ./basic (CGO_ENABLED=1, needs SDL2 + SDL2_ttf dev packages)
./basic # interactive REPL
./basic tests/language/functions.bas
make tests # or: bash ./test.sh
Its architecture, and the two decisions that shaped ours
Scanner → parser → tree-walking runtime, one source line at a time:
basicscanner.go— tokenizes a line intoBasicTokens. Verbs and function names are case-insensitive; variable names are case-sensitive.basicparser.go/basicparser_commands.go— recursive-descent parser producingBasicASTLeafnodes;basicgrammar.godefines the leaf types. Commands get their own parse paths.basicruntime.go— owns the source ([MAX_SOURCE_LINES]BasicSourceLine), the variable pool, the scanner/parser, the environment stack and the SDL window.commandByReflection()dispatches a verb to a method by name, so adding a verb there was mostly a matter of adding a correctly-named method. We use a sorted dispatch table instead — see below.basicenvironment.go— a scope and the per-line working state: variables, functions, labels, plus the in-flight state ofIF/FOR/GOSUB/READ. Environments chain viaparent.basicvalue.go/basicvariable.go— the strongly-typed value and the named slot. Type is carried by the identifier suffix:#integer,%float,$string.basicruntime_graphics.go— output goes through an SDL text surface with a cursor, wrapped text, scrolling and a print buffer.Write()andPrintln()mirror every line to stdout, and that mirror is the only reason a golden-file suite is possible at all. Ourakbasic_TextSink, andakbasic_sink_init_tee()in particular, is the equivalent.
- Everything is fixed-size and statically allocated.
main.goset the budget:MAX_LEAVES/MAX_TOKENS32 per environment,MAX_VALUES64,MAX_VARIABLES128,MAX_SOURCE_LINES9999,MAX_LINE_LENGTH256,MAX_ARRAY_DEPTH64. The 32-leaf ceiling is why a line is limited to roughly 16 operations. Ours are ininclude/akbasic/types.hand are the same numbers plus three the Go version did not need because it calledmake(). waitingForCommanddrives block structure. Rather than building nested blocks in the AST, the environment records a verb it is skipping forward to (NEXT,RETURN, …) and suppresses execution of intervening lines until it sees it. Loops and branches evaluate their condition at the bottom of the structure. We reproduce this, and it is the reason a wholeFOR ... NEXTwritten on one line does not loop — block skipping walks source lines.
Runtime modes are MODE_REPL, MODE_RUN, MODE_RUNSTREAM (piped input) and MODE_QUIT;
BASIC_TRUE is -1 and BASIC_FALSE is 0, per Commodore convention. All of that carried
over.
Building
git submodule update --init --recursive
cmake -S . -B build
cmake --build build --parallel
ctest --test-dir build --output-on-failure
Build everything from the submodules, in one tree
libakgl vendors its own copies of libakerror and libakstdlib (plus SDL3, SDL3_image,
SDL3_mixer, SDL3_ttf, jansson and semver) under deps/libakgl/deps/. Its CMakeLists.txt
guards every dependency with if(NOT TARGET ...), so a top-level build must define
akerror::akerror and akstdlib::akstdlib from deps/libakerror and deps/libakstdlib
before add_subdirectory(deps/libakgl), or the targets are declared twice.
That order is load-bearing for a second reason: deps/libakerror is at 2.0.2, whose 2.0.0
was a source and ABI break carrying an soname (libakerror.so.2). libakstdlib and libakgl
must be compiled against that header, not a 1.x one, and an installed libakerror.so.1 must
not be picked up. The break is quiet if you get it wrong: the context behind IGNORE became
thread-local and akerr_next_error() now returns a context that already holds a reference, so
a mixed build leaks pool slots or frees one twice rather than failing to link.
Dependency versions and what they promise
| Submodule | Version | soname | ABI rule | Version API |
|---|---|---|---|---|
deps/libakerror |
2.0.2 | libakerror.so.2 |
major only | none — no version macro; include/akbasic/error.h feature-tests AKERR_THREAD_SAFE and AKERR_EXIT_STATUS_UNREPRESENTABLE instead |
deps/libakstdlib |
0.2.0 | libakstdlib.so.0.2 |
MAJOR.MINOR while major is 0 |
AKSL_VERSION_*, aksl_version(), AKSL_VERSION_CHECK() |
deps/libakgl |
0.9.0 | libakgl.so.0.9 |
MAJOR.MINOR while major is 0 |
AKGL_VERSION*, akgl_version(), AKGL_VERSION_AT_LEAST() |
The libakerror row is what project(akerror VERSION ...) declares, and it disagrees with
that library's own release notes, which carry a "Release 2.0.3" section describing CI-only
changes. The version the build stamps into the soname and into
akerrorConfigVersion.cmake is 2.0.2; nothing consumers can observe says 2.0.3. Read the
table as the ABI and the notes as the changelog, and do not "correct" this row to 2.0.3
without the upstream project() bump to go with it. Reported as libakerror issue #38.
What 2.0.2's IGNORE change costs, measured
IGNORE now takes a copy of the context so the pool slot can be released, which fixes a
real leak. The copy lives in the public header as a file-scope static:
static AKERR_THREAD_LOCAL akerr_ErrorContext akerr_last_ignored;
static means one per translation unit, and AKERR_THREAD_LOCAL means each one is
thread-local. sizeof(akerr_ErrorContext) is 37,296 bytes, so:
| Build | TLS segment of build/basic |
|---|---|
| libakerror 2.0.1 | none at all |
| libakerror 2.0.2 | 1,417,248 bytes — 1.35 MiB, per thread |
38 copies × 37,296 accounts for the segment exactly; readelf -sW build/basic shows all 38.
It scales with translation-unit count, not with anything about the program, and every thread
a host game spawns pays it again — which is worth knowing in a tree that spent a release
taking akbasic_Runtime from 10.75 MiB to 2.40 MiB.
It also emits 84 -Wunused-variable warnings, one per TU that includes akerror.h
without using IGNORE. Nothing here builds with -Werror, so it is noise rather than a
failure — but a consumer that does cannot include the header.
Both are libakerror issue #37 and neither is worked around here, per the rule above: a
one-line extern in the header and a definition in src/error.c fixes it upstream, and
patching our copy would fork the submodule. Delete this subsection when it lands.
For both 0.x libraries the soname carries MAJOR.MINOR deliberately: 0.1 and 0.2 are
different ABIs, and both become major-only at 1.0. Do not read 0.1 → 0.2 as a compatible
bump — both libraries have actually made that jump, so anything built against the 0.1 headers
must be rebuilt rather than relinked.
libakstdlib and libakgl move together and have to. libakgl 0.2.0 consumes the
libakstdlib 0.2.0 API, so a tree pinning libakstdlib at 0.1.0 while adding libakgl by
add_subdirectory() compiles 0.2.0-era code against 0.1.0 headers and fails on aksl_fwrite,
aksl_fread and aksl_realpath. Bump them as a pair.
akstdlib.h pulls in <akstdlib_version.h>, which CMake generates into the build tree and
installs beside akstdlib.h — so link akstdlib::akstdlib and let the target carry its
include directories. Hand-adding deps/libakstdlib/include to an include path gets you a
missing-header error. It records what the caller was compiled against, and aksl_version()
reports what actually loaded. AKSL_VERSION_CHECK() compares them and raises AKERR_VALUE
naming both. Call it once during initialization: the soname normally catches a mismatch at
load time, but a 0.2.0 dropped in under the 0.1 filename loads happily and only the check
notices. It is a macro on purpose — it must expand at your call site to capture your numbers.
libakgl followed the same pattern. project(akgl VERSION ...) is the single source and
drives the generated include/akgl/version.h, which is .gitignored because include/
precedes the build tree on the include path and a stray copy there would shadow the generated
one and pin every consumer. It publishes AKGL_VERSION_AT_LEAST(major, minor, patch) — the
compile-time test libakstdlib could not write against libakerror — and akgl_version().
Version-pinning in find_package used to be asymmetric. It no longer is.
find_package(akstdlib 0.1) and find_package(akgl 0.1) both work; each ships a
ConfigVersion.cmake at SameMinorVersion, mirroring its soname. libakerror shipped
akerrorConfig.cmake and akerrorTargets.cmake but no akerrorConfigVersion.cmake, so any
versioned request failed against a correct install and the advice here was to ask for
akerror unversioned. That was libakerror issue #16 — closed — and libakstdlib issue #5,
which tracks the same fix from the other side and is still open only because nobody has shut
it. It has landed: libakerror now writes akerrorConfigVersion.cmake at
SameMajorVersion, matching the soname's major-only rule, rather than the
SameMinorVersion the other two use to match theirs.
Two things follow, and the second is the one that bites:
- A versioned request now works — but the floor to ask for is
2.0, not the1.0this file used to say.find_package(akerror 1.0)fails harder than before: it is a request for major 1 against a major-2 install, whichSameMajorVersioncorrectly rejects. - There is nothing in this repository to change.
akbasicreaches all three dependencies byadd_subdirectory, notfind_package, and ships no CMake package config of its own — so it has nofind_dependencycalls to add a floor to. The instruction that used to live here was written for a consumer this project never became. It matters to anyone installing these libraries and linkingakbasicagainst the installed copies, which is why it is recorded rather than deleted.
The compile-time floor is unchanged and still the real guard: an #error feature-testing
AKERR_FIRST_CONSUMER_STATUS, which akstdlib.h, akgl/error.h and our own
include/akbasic/error.h all carry — include any of them and you inherit it.
Embedding all three dependencies collides four ways
All three libraries register their CTest tests unconditionally and add coverage and mutation
targets. A top-level build that add_subdirectorys them walks into four separate collisions.
The handling is at the top of CMakeLists.txt, and its comments carry the same detail.
1. Duplicate CTest entries. Every dependency's tests register into our suite. Under
EXCLUDE_FROM_ALL their binaries are never built, so each lands as "Not Run" and fails. CMake
offers no way to un-register a test, and set_tests_properties cannot reach across directory
scopes. We shadow add_test() and set_tests_properties() for the duration of the
add_subdirectory() calls. libakstdlib carries the same shadow but only arms it when it
is top-level, so it does nothing for us — ours has to wrap all three.
set_property(TEST ...) has to be shadowed alongside them, and only in its TEST form.
libakgl 0.9.0 moved eight of these calls off set_tests_properties, correctly: that command
parses its arguments as name/value pairs, so a semicolon-separated value is split and every
element after the first is consumed as a bogus property name — which silently reduced its
LD_LIBRARY_PATH prepend list to one directory. set_property does not split. But with
add_test() suppressed the tests those calls name do not exist, and set_property errors
on an unknown test name where set_tests_properties was silent, so pulling 0.9.0 in turned
eight quiet no-ops into eight hard configure failures. Every other form — GLOBAL,
DIRECTORY, TARGET, SOURCE, INSTALL, CACHE — must pass straight through; the
dependencies set target and directory properties their own builds depend on.
Only one project in a tree may shadow add_test(), and this is that project. CMake
exposes an overridden command as _name and chains exactly one level: a second override
rebinds _add_test to the first override and the builtin becomes unreachable to everyone, so
our own registrations recurse until CMake stops at "Maximum recursion depth of 1000
exceeded". libakgl 0.3.0 briefly shadowed it unconditionally and that is exactly what
happened.
2. Duplicate test target names. add_subdirectory creates a dependency's targets even
under EXCLUDE_FROM_ALL. When libakstdlib added tests/test_version.c it collided with
libakgl's and stopped the configure dead:
add_executable cannot create target "test_version" because another target with the same
name already exists.
libakgl fixed its side by building programs as akgl_test_<name> while keeping the bare
CTest names. libakstdlib still uses bare test_<name> targets. Name every test target in
this repo akbasic_test_<name> — it costs nothing and it is the collision that actually
stopped a build.
3. Duplicate custom targets — fixed upstream, and the workaround is gone. libakerror
used to namespace its mutation target when embedded but not its coverage target, so
any coverage-enabled top-level build failed with "another target with the same name already
exists". This project shadowed add_custom_target and renamed that one to
akerror_coverage on the way past, and recorded the real fix as libakerror issue #15: the
same CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR test it already applied to
mutation.
That landed in 2.0.2. libakerror now picks akerror_coverage itself when embedded, so the
shadow was dead code that could only ever fire on a name the dependency had stopped using —
and it is deleted. All three dependencies namespace both targets correctly now, so there is
no custom-target collision left; only collisions 1, 2 and 4 below are live. The heading says
four because four is what a reader coming from the issue tracker will be looking for.
4. Stale build trees poison the coverage report. See below; it is the reason for
cmake -S . -B build.
Build trees stay out of the source directory
gcovr searches for .gcda/.gcno under its --root, and the dependencies set that to
their source directory — so a leftover instrumented build tree in the source dir is folded
into the report, and --object-directory does not narrow the search. A leftover
build-coverage/ makes a freshly configured run fail in coverage_reset, before any test
executes, with Got function ... on multiple lines. Because build*/ is .gitignored, the
state is easy to reach and hard to see. Always cmake -S . -B build.
Optional tooling, and what each one buys
Everything builds and tests with cmake and a C compiler. Three tools are optional, and each 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 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.
The 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.
API documentation is a gate, not a convenience
doxygen Doxyfile
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. Every public declaration under
include/akbasic/ carries a @brief, a @param per parameter, a @return and its
@throws. Keep it that way when you add one.
Tests
Three lists, and two of them invert "passed"
CMakeLists.txt declares AKBASIC_TESTS, AKBASIC_WILL_FAIL_TESTS and
AKBASIC_KNOWN_FAILING_TESTS. The first must exit 0. The second aborts by design. The
third asserts the correct contract for a defect that has an open issue and is
expected to fail.
A green ctest therefore does not mean defect-free. When a known-failing test starts
passing, CTest reports "unexpectedly passed" — that is the cue to move it into
AKBASIC_TESTS along with the fix, not to delete it.
AKBASIC_WILL_FAIL_TESTS is currently empty and is kept declared anyway, so the shape is
there when it is next needed.
The dependencies use the same split under their own prefixes: AKSL_TESTS /
AKSL_WILL_FAIL_TESTS / AKSL_KNOWN_FAILING_TESTS, and AKERR_TESTS /
AKERR_WILL_FAIL_TESTS. libakstdlib 0.2.0 fixed all six defects it had confirmed
and left AKSL_KNOWN_FAILING_TESTS empty.
Test target names
Every test program builds as akbasic_test_<name> while registering under the bare CTest
name. That is not cosmetic: add_executable creates a dependency's targets even under
EXCLUDE_FROM_ALL, and when libakstdlib added a test_version it collided with
libakgl's and stopped the configure dead.
The golden corpora
tests/language/ is the editable language corpus. It includes cases carried over from the
deprecated Go implementation as well as cases written for this interpreter. Every .bas file
has a sibling .txt expectation, and a new language feature needs that pair as well as unit
tests. Change both deliberately in the same commit; provenance does not make a case immutable.
Mutation-check a fix before you believe it
Coverage says a line ran; it does not say anything would have noticed if it were wrong.
That matters more here than usual, because the akerror macros expand at their call sites
and gcov attributes them to the caller.
The discipline for any fix with a test: revert the fix, confirm the test fails, restore
it. Use a file copy, not git checkout — git checkout -- src in a loop like this has
already wiped a session's worth of unrelated edits.
cmake --build build --target mutation # whole tree; hours
python3 scripts/mutation_test.py --target src/value.c --threshold 70
Mutation testing 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.
The dependencies' own harnesses
Option prefixes differ per repository, which is the thing that catches people out:
cmake -S . -B build-asan -DAKSL_SANITIZE=ON # libakstdlib
cmake -S . -B build-coverage -DAKSL_COVERAGE=ON # libakstdlib
cmake -S . -B build-coverage -DAKGL_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug # libakgl
cmake --build build --target coverage
cmake --build build --target mutation
deps/libakerror/test.sh shows the full CI gate for that library: ctest, mutation ≥ 65, and
coverage ≥ 90 line / ≥ 50 branch. The branch gate in this repository was re-ratcheted 45 →
40 after the libakerror 1.0.0 bump, because the new PREPARE_ERROR/FAIL_* macros expand
to more branches per call site — a denominator change, not a regression.
rebuild.sh in libakgl and libakstdlib installs into the developer-specific prefix
/home/andrew/local and deletes build directories. Use the portable commands above unless
that exact behaviour is what you want.
Benchmarks, and what the collision scan actually costs
tests/collision_perf.c is the only benchmark here. It is labelled perf, so ctest -LE perf
leaves it out of the ordinary run, and it borrows deps/libakgl/tests/benchutil.h by include
path rather than copying it — the harness is the house convention across these repositories and
a second copy would be a fork.
Both checked-in build trees are Debug, and -O0 numbers are not worth reading.
benchutil.h refuses to enforce a budget without __OPTIMIZE__ for exactly that reason.
Configure a third tree and run it at scale:
cmake -S . -B build-perf -DAKBASIC_WITH_AKGL=ON -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build-perf --target akbasic_test_collision_perf
cd build-perf && AKGL_BENCH_SCALE=10 ctest -L perf
The question it was written to settle: akbasic_collision_service() runs at the top of every
interpreter step, and the akgl frontend takes AKBASIC_FRONTEND_STEPS_PER_FRAME (256) steps
per rendered frame — so a busy program scans up to 256 times a frame, nearly always over sprites
that have not moved. Is that worth changing to a per-frame cadence?
Measured at RelWithDebInfo, scale 10, best of 5:
| Row | ns/op |
|---|---|
| full scan, 0 sprites | 59.2 |
| full scan, 2 sprites, spread out | 153.3 |
| full scan, 4 sprites, spread out | 229.0 |
| full scan, 8 sprites, spread out | 366.4 |
| full scan, breakout's own layout | 590.6 |
| full scan, 8 sprites, all overlapping | 1145.4 |
| cached scan, nothing moved | 40.0 |
| one rendered frame, 8 sprites + full text grid | 1,191,947 |
Two paths, and which one a call takes is the whole story. The scan short-circuits when no
sprite has moved and no static geometry has changed since the last one — its inputs are exactly
those, so if none changed the answer cannot have. A frame runs one full scan and 255 cached
ones. The full scan rows nudge a sprite before each call to defeat that, so they measure a
scan that actually ran; they therefore include a MOVSPR-equivalent, which is honest for
comparison because a real frame pays that too.
So for examples/breakout/sprites/breakout.bas, the most demanding program here:
590.6 + (255 × 40.0) = 10.8 µs against a 1.19 ms frame — 0.91%
That is less than it cost before any of this work, when it was 96.3 ns unconditionally on all 256 steps, or 24.7 µs, or 2.0% — and it now includes static geometry and produces contacts. The short circuit is what pays for both: eight sprites against sixty-four rectangles is five hundred and twelve tests, fine once a frame and ruinous 256 times.
So the per-step cadence stays, and the question of moving it to per-frame is answered "no"
twice over. It is what makes a collision report describe where the sprites have just been moved
to rather than where they were (src/runtime.c, above the service call); it changes when a
handler fires for every program that already works; and the cost it was supposed to save is now
under one percent of a frame. Recorded here rather than argued again.
Read the two synthetic extremes as a bracket, not as an answer. "Spread out" is every pair rejected on four comparisons; "all overlapping" is every pair going through to the narrowphase, which is a state no real program sits in. Real programs land between them, and the breakout row is there so the question "between them where" has an answer.
Breakout reaches the high end of that bracket for a reason worth knowing: two of its eight
sprites are the screen, a captured HUD strip and a captured play field, so the field's box
covers everything and the bounding-box reject can never throw those pairs out. That is
the sprite-slot cost recorded in TODO.md §9 item 9, and fixing it — issue #23 — would take
this row down as a side effect.
Read a benchmark as a gap between two rows of the same run, not as an absolute. libakgl's
PERFORMANCE.md records a whole laptop reading 15% high on a later run, including rows nothing
had touched.
Code
The libakerror error convention
Mixing conventions breaks error propagation, so new C code follows this one — it is what
libakstdlib and libakgl both do.
- Any function that can fail returns
akerr_ErrorContext AKERR_NOIGNORE *and opens withPREPARE_ERROR(e);. - Return with
SUCCEED_RETURN(e)/FAIL_RETURN(...), never a bare status code. - Propagate with
PASS(e, some_call());when there is nothing local to do. - Handle locally with
ATTEMPT { CATCH(e, call()); } CLEANUP { } PROCESS(e) { } HANDLE(e, AKERR_X) { } FINISH(e, true);—FINISH(e, true)re-raises unhandled errors to the caller. CATCHand theFAIL_*_BREAKmacros expand to a Cbreak, so they must never appear inside a loop or a nestedswitchwithin anATTEMPT: thebreakwould escape only the loop, and the rest of theATTEMPTwould run with an error pending. Inside a loop usePASSorFAIL_*_RETURN, or move the loop into its ownakerr_ErrorContext *helper andCATCHthat single call.- Never use a
*_RETURNmacro inside anATTEMPTblock. It returns pastCLEANUP, so every release,fcloseand free is skipped. deps/libakerror/AGENTS.mdis the authoritative statement of the protocol, anddeps/libakerror/UPGRADING.mdis required reading before writing an error code.
Error codes
akbasic owns 512–767 under the owner string "akbasic", claimed by
akbasic_error_register() — which akbasic_runtime_init() calls, so a host gets it for free.
Codes are an enum in include/akbasic/error.h so they stay compile-time integer constants;
HANDLE's case labels require that. Add a code and you must name it in
akbasic_error_register(), or it prints as "Unknown Error" in every stack trace that
carries it.
The coordinated range map
libakerror cannot enumerate its consumers, so this table is the coordination. Keep it
current, and reserve the whole 256 even where fewer codes are used, so a later addition does
not need a second reservation:
| Owner string | Range | Status |
|---|---|---|
"libakerror" |
0 – 255 | reserved by akerr_init(); do not touch |
| (none) | — | libakstdlib deliberately reserves nothing and defines no codes of its own — it raises AKERR_* and propagates errno, both inside the reserved band. Its tests/test_status_registry.c pins that as a contract, so nobody has to coordinate with it |
"libakgl" |
256 – 262 | reserved by akgl_error_init(); AKGL_ERR_BASE … AKGL_ERR_LIMIT - 1, seven codes. Was five until 0.8.0 added AKGL_ERR_COLLISION and 0.9.0 added AKGL_ERR_UI |
| (free) | 263 – 511 | headroom for libakgl to grow into; do not claim it |
"akbasic" |
512 – 767 | ours; AKBASIC_ERR_BASE is 512 |
Values 0–255 are the host errno space plus the AKERR_* codes. Consumers allocate from
AKERR_FIRST_CONSUMER_STATUS (256) upward, as absolute integer constants — never as
offsets from AKERR_LAST_ERRNO_VALUE, so a libc that grows an errno cannot move them. Every
library that can coexist in one process reserves its range in its own initializer:
akerr_ErrorContext AKERR_NOIGNORE *akerr_reserve_status_range(int first_status, int count, const char *owner);
akerr_ErrorContext AKERR_NOIGNORE *akerr_register_status_name(const char *owner, int status, const char *name);
Both are AKERR_NOIGNORE, so a collision is an ordinary error to CATCH, HANDLE or PASS
out of an init function. Ownership is enforced: naming a status in someone else's range
fails with AKERR_STATUS_NAME_FOREIGN, and naming one nobody reserved fails with
AKERR_STATUS_NAME_UNRESERVED. You do not need to call akerr_init() first — every registry
entry point calls it for you, and it does not clear reservations made before it ran. Repeating
an identical reservation is a no-op; a subset or superset of your own range is not, so reserve
the whole thing in one call.
akgl_error_init() has an ordering requirement worth knowing before wiring up a main(): it
must run before anything else in libakgl, because a code raised before it registers carries
no name into its stack trace. akgl_game_init() calls it as its first statement, but a
program driving subsystems directly — which is what the embedded interpreter does — has to
call it itself. It is idempotent.
What 1.0.0 removed, and what it still does not solve
These no longer exist. Any code, compile definition or documentation referring to them is stale:
| Removed | Replacement |
|---|---|
AKERR_MAX_ERR_VALUE |
nothing — the name registry is sparse and takes any int |
__AKERR_ERROR_NAMES |
akerr_name_for_status(); the table is private to the library now |
AKERR_STATUS_RANGE_OK / AKERR_STATUS_NAME_OK |
success is a NULL akerr_ErrorContext *, like everything else |
libakerror's own tracker says the limit plainly (its issue #4): ownership enforcement covers naming,
which is the part the library mediates. It cannot detect two components compiling the same
integer into a HANDLE case label without ever registering a name — that never reaches the
registry. §3 adds that there is no way to ask who owns a status or to enumerate reservations,
so the table above is the only tooling there is. Two rules follow:
- Always reserve, even for codes you never name. Reservation is the only thing that makes a collision visible at all.
- Never define an error code as an offset from another library's symbol.
Capacity is not a concern: the name registry holds 3072 entries and akerr_init() consumes
about 150; reservations cap at 64 ranges. Both are PRIVATE to the libakerror target, so
raising them is a libakerror configure-time decision, not something akbasic sets.
Registration became thread safe in libakerror 2.0.0, and the rule barely moves.
akerr_reserve_status_range() and akerr_register_status_name() are serialized now — two
threads reserving overlapping ranges cannot both succeed, one gets AKERR_STATUS_RANGE_OVERLAP
naming the winner. What is still yours to coordinate is re-registering a name for a status
another thread may be looking up: akerr_name_for_status() returns a pointer into the registry
rather than a copy, which is what makes it usable from a stack trace, and a second registration
overwrites that buffer in place. The reader is outside the lock by the time it reads the
string, so no lock can fix it.
So: still do it during single-threaded init, before the host game spawns anything — not because the call would race, but because there is one operation in the registry that cannot be made safe and this is the discipline that avoids needing it.
Structures reuse the array machinery, deliberately
TYPE declares a record, so an instance has a known slot count and takes one contiguous
run from the same value pool DIM A#(10) draws from. There is no structure pool, and
adding one would be the wrong instinct: the only new table holds descriptors — names and
slot offsets — and the data goes where array data already goes.
Two rules for changing any of it:
- A structure copy must not go through
akbasic_value_clone(). Clone copies one slot, and one slot holds a reference to an instance rather than the instance, so a structure taking that path aliases instead of copying — which is the semantics the language deliberately does not have.akbasic_environment_assign()intercepts first and callsakbasic_struct_copy(). If you add a place a structure can be assigned, it goes through that, not through clone. - A field chain gets its own leaf type and its own link field.
include/akbasic/grammar.hrecords three separate defects that came from giving one link field two meanings;AKBASIC_LEAF_FIELDkeeps its base on.left, which nothing else on that leaf type uses.
docs/14-architecture.md has the layout diagram and the three-pass prescan.
Nothing calls malloc
libakgl's hard rule, and ours: obtain objects from akgl_heap_next_* and release them back,
and if no heap layer exists for a needed type, add one rather than calling malloc. On our
side of the line every object comes from a fixed pool inside akbasic_Runtime, sized by the
constants in include/akbasic/types.h. Exhausting a pool is a diagnosable error, not a crash
and not a slow leak.
The verb table is sorted, and a test says so
src/verbs.c is searched with bsearch. A mis-sorted table does not fail to compile — it
silently fails to find a verb, and the symptom is Unknown command PRINT a long way from
the cause. tests/verbs_table.c asserts the ordering; adding a verb in the wrong place
fails there.
A builtin's name, arity and handler are one row in that table. Nothing is bootstrapped by
running a BASIC program of DEF statements at startup, the way the Go version did, so the
interpreter no longer has to be running before the interpreter is ready.
Nothing in the library terminates the process
Goal 3. FINISH_NORETURN appears only in a main() — today the driver's and the
examples'. A script's error is reported through the sink and swallowed; an interpreter
error propagates out as akerr_ErrorContext * for the host.
Both halves of that need saying, because the second one is easy to get right and the first
one is easy to get wrong: process_line_run() swallowed its context correctly from the
start while the direct-mode branch of process_line_repl() used a bare PASS, so a
VERIFY against a file that did not match — an ordinary user answer — tore down the driver
with a stack trace.
Generated files
Never hand-edit build/ trees, the generated akerror.h, akgl.pc, or
include/akgl/SDL_GameControllerDB.h. Change the template or the generator.
Style
C99, four-space indent, braces on their own line for function bodies, spaces inside control-flow parentheses. Match the surrounding file — several mix tabs and spaces and there is no repo-wide formatter. Do not reformat code you are not otherwise changing; style conversions get their own commit.
Public symbols take the akbasic_ prefix, akbasic_TypeName for types, AKBASIC_ for
macros. static helpers drop it. Headers and sources are paired by feature —
src/scanner.c and include/akbasic/scanner.h.
Commits
Short, imperative, sentence-case subjects describing observable behaviour: "Fix refcount leak
and stack-trace buffer overflow". Keep them focused, and include the tests with the behaviour
change. libakgl's AGENTS.md requires an agent to add itself — program, model and version —
as a commit co-author, and this repository follows the same rule.
Each dependency carries its own AGENTS.md with authoritative per-repo rules. Read the
relevant one before editing a submodule.
Editing the documentation
Every fenced block in README.md and docs/*.md is executed by ctest. The test is
docs_examples; it runs in both build configurations and it fails the build. It caught
four wrong examples the day it was added — two transcripts showing a leading space
PRINT does not emit, a struct in README.md that had grown two members hours
earlier, and a FILTER refusal quoted with the wrong wording.
An untagged block is a failure, not a default. That is deliberate: the way a harness like this dies is by quietly matching nothing and passing, so leaving a block untagged is a missing decision rather than a free pass.
The tags
| Info string | What happens |
|---|---|
basic |
Written to a file and run. Must exit 0 and print no ? line : CLASS error |
basic repl |
Fed to a fresh interpreter on stdin. The leading READY banner is dropped before comparison |
basic norun |
Shown, not run. For fragments and for anything that loops forever |
basic requires=akgl |
Run only in the -DAKBASIC_WITH_AKGL=ON build |
basic requires=noakgl |
Run only in the default build. For verbs whose refusal is the example |
basic setup=NAME |
Runs tests/docs_setups/NAME.sh in the sandbox first |
output |
The exact stdout of the block above it, compared byte for byte |
c |
Compiled with -fsyntax-only -std=gnu99 -Wall -Wextra -Werror against the real include path |
c wrap=NAME |
The same, with tests/docs_preludes/NAME.pre before it and NAME.post after |
c excerpt=PATH |
Must appear in PATH, ignoring comments and whitespace. Not compiled |
c norun |
Shown, not compiled |
sh |
Run in a sandbox with the built interpreter at ./build/basic. Must exit 0. A leading $ is stripped |
sh setup=NAME |
The same, after tests/docs_setups/NAME.sh |
sh norun |
Shown, not run |
cmake |
Never executed. Hand-maintained, by decision |
text |
Never executed. A block diagram, or a stack trace captured from a real run |
basic screenshot=NAME |
Also the source of docs/images/NAME.png. The harness checks the file exists; tools/docs_screenshots.sh is what makes it |
basic size=WxH |
That figure's surface, when 320x200 is not the size the point needs |
basic text=1 |
That figure is drawn with the text layer, for a program whose output is characters |
basic ui=1 |
That figure lends the program a UI device. It gets a font, because widgets draw text, and not the text layer, which owns every pixel of the rows it covers and would black the picture out under them |
Attributes combine: basic requires=akgl setup=ship is a real tag in docs/08-sprites.md.
Figures are output, not assets
A chapter about CIRCLE wants a picture of what CIRCLE draws, and the way a picture
goes wrong is that it stops being of the code beside it — silently, because a screenshot
taken by hand still looks like a screenshot long after the verb changed. So a figure is
generated from the listing it illustrates:
$ cmake --build build-akgl --target docs_screenshots
tools/screenshot.c is a second, much smaller SDL host — the dummy video driver, a
software renderer, run the program to completion, read the target back, write a PNG. It
draws no text layer by default, deliberately: a READY in the corner is noise in a figure
about BOX, and skipping it means no font has to be found.
text=1 turns the text layer on, for the case that default gets wrong: a figure of a
program whose output is characters. docs/17-tutorial-breakout.md builds a game whose
wall, HUD and messages are all in the grid, and without the layer its figure is two sprites
on a black field. The tag makes docs_screenshots.sh hand the tool
assets/fonts/C64_Pro_Mono-STYLE.ttf at AKBASIC_FRONTEND_FONT_SIZE, which is what the
standalone frontend opens, so the figure's cell size is the reader's cell size. The sink is
then the akgl one alone rather than a tee, so the program's output goes into the picture
instead of onto stdout — which the caller reads to decide a figure failed. A raised error
is still caught, by docs_examples running the same block.
Three rules around it:
- The PNGs are checked in. A reader on the forge has no build tree. They are generated files that are tracked on purpose, so do not hand-edit one — change the listing and regenerate, which is the whole point of the arrangement.
- The build never regenerates them. The target is only ever run deliberately, because a
makethat quietly rewrote eight binaries would put that diff in front of whoever happened to build. docs_examplesfails a tagged block with no image, in both configurations, so a figure cannot be added to a chapter and then forgotten.
requires=akgl belongs on a screenshot block too. The two tags answer different questions:
one is whether the program is run by the suite, the other is whether it produces a
figure, and a graphics listing wants both.
text exists because docs/14-architecture.md needed block diagrams and an untagged block
is a hard error. It carries no executable claim, which is exactly why it has to be said:
the alternative was an indented code block that the extractor never sees, and a picture
nobody decided about is indistinguishable from a test nobody ran. Keep the diagrams inside
about 88 columns so they do not wrap on a forge.
Which one to reach for
- A program with visible output —
basicplus anoutputblock. Preferred: it is the only shape that pins what the reader will actually see. - A program with no output —
basicalone. The harness still asserts it parses, runs, and raises nothing. - An interactive transcript —
basic repl, with the input in one block and the output in the next. A block that interleaves the two cannot be checked mechanically, and splitting it is not a loss: the piped interpreter printsREADYonce at startup rather than after each line, so an interleaved transcript is not literally true anyway. - A verb that draws, plays or moves —
basic requires=akgl. These produce no stdout, so they get nooutputblock; the assertion is that they do not refuse. - A refusal message — write the program that provokes it and put the message in an
outputblock. Refusals carry a trailing blank line; the block has to have it too. norun— for a fragment (60 DATA 255, 129, ...), an infinite loop, or a command that would reconfigure the tree the suite is running inside. Say why in the prose.- A diagram or a captured trace —
text. Nothing is checked, so anything in one that could be checked belongs in a block that is.
sh blocks: what is not run, and why
Fourteen of the sixteen shell blocks are norun, and the reason is the same for all of
them: they operate on the build tree the test is running inside, or they need the network.
Only docs/02-getting-started.md runs any, and it runs them in the sandbox.
| Block | Why |
|---|---|
git submodule update --init --recursive |
Network, and the sandbox is not a repository |
cmake -S . -B build |
Would reconfigure the live build tree mid-test |
ctest --test-dir build |
Re-enters this suite |
doxygen Doxyfile |
Slow, and writes into the build tree |
the mutation target |
Hours |
That leaves a real gap: a renamed CMake option in README.md would go unnoticed. It could
be closed later with an excerpt-style check against CMakeLists.txt. It has not been.
Preludes
tests/docs_preludes/NAME.pre and NAME.post bracket a c wrap=NAME block. They exist so
an example can be written the way a reader wants to read it — PASS(e, akbasic_runtime_global(...))
and nothing else — while still compiling. What belongs in a prelude:
- Symbols the example invents to stay readable:
your_clock_ms(),my_renderer. - Scaffolding the macro protocol requires: a function body, a
PREPARE_ERROR, anATTEMPTfor a block ofCATCHcalls. - Includes the surrounding prose already listed and the block does not repeat.
What does not belong in a prelude is anything that would let a wrong example compile.
A prelude declaring akbasic_runtime_init itself, for instance, would defeat the check.
Compiler diagnostics point at the markdown: the harness emits a #line directive, so a
broken example reports as README.md:322: error: too few arguments.
Excerpts
c excerpt=include/akbasic/sink.h says the block is a copy of something in that header and
must still match it, comments and whitespace ignored. Use it where compiling the block
would be wrong — echoing a typedef compiles only by redefining the type. This is the
check that caught the stale akbasic_TextSink, and a compile check could not have.
When docs_examples fails
The message names the file and the line of the block. For an output mismatch it prints both
sides through cat -A, because the errors it catches are trailing spaces and missing
newlines, which a plain diff renders invisibly.
Fix the documentation, not the expectation — unless the interpreter is what changed, in
which case fix the interpreter first and the documentation second. Never edit an output
block to match output you have not looked at.
Run one document at a time while you work:
./tests/docs_examples.sh --root . --basic ./build/basic \
--cflags-file build/docs_cflags.txt docs/04-control-flow.md
--root and --basic are made absolute by the script before it starts, because both are
used from inside a sandbox directory it cds into. Relative ones used to fail every
example with exited 127 and every setup= with "setup failed" — invisible under CTest,
which passes absolute paths, and immediate for anyone running one document by hand.
The pass line reports what it executed, by kind. Read it. A harness that passes because it stopped matching anything looks exactly like a harness that passes because the documentation is correct, and the count is the only thing that tells them apart — it has already caught one such case, where a CMake generator expression evaluated to an empty argument that the script read as a filename.