Files
akbasic/MAINTENANCE.md
Andrew Kesterson 3415c9aec6 Take libakgl 0.5.0 and rename every symbol it namespaced
0.5.0 gives every exported symbol the akgl_ prefix, which is the first libakgl
release to break this project's source rather than only its ABI. The soname goes
to libakgl.so.0.5 and include/akbasic/akgl.h asserts the floor.

What moved here: akgl_render_bind2d is akgl_render_2d_bind,
akgl_sprite_sheet_coords_for_frame is akgl_spritesheet_coords_for_frame, the
renderer, camera and window globals carry the prefix, and _akgl_renderer and
_akgl_camera are akgl_default_renderer and akgl_default_camera.

The renames were applied by site rather than by pattern, because renderer is also
a parameter name in src/sprite_akgl.c and a struct member throughout
src/frontend_akgl.c -- a substitution would have rewritten both without a word.
That is the same trap upstream describes hitting, and it is worth knowing that
the defect behind the rename was not cosmetic: an exported global called renderer
collided with a test's own variable, the executable's definition preempted the
library's, and every texture load in that suite failed while the suite passed.

0.5.0 also fixes libakgl defect 26, which was one of the two reasons
src/sprite_akgl.c installs its own renderfunc: akgl_actor_render took its
destination height from the sprite's width, drawing a 24x21 Commodore sprite as a
24x24 square. The renderfunc stays, because the other reason has not moved -- an
actor carries one scalar scale and SPRITE has separate x- and y-expand bits --
but the comments and TODO.md deviation 40 no longer claim a defect that is fixed.

Every documentation figure re-renders byte-identical under the new library, which
is what says the sprite and drawing paths did not move underneath them.

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

40 KiB
Raw Blame History

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

  1. 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 into malloc-based data structures. SDL2/SDL2_ttf calls in the Go version become libakgl calls (akgl_text_*, the renderer, the registry), not raw SDL3 calls.

  2. Finish the language. The full Dartmouth BASIC and Commodore 128 BASIC 7.0 verb and function set. deps/basicinterpret/README.md ends with the original's list of what was unimplemented, and that list was the work queue. What remains is in TODO.md and is summarised for a BASIC programmer in docs/13-differences.md. A few entries are deliberately out of scope on a modern PC — BANK, FAST, MONITOR, and SPRDEF, which is an interactive editor rather than a programmable verb. Keep that reasoning rather than reviving them.

  3. Be embeddable. The end state is the interpreter linking into libakgl as a scripting engine for game authors. That constrains the design from the start:

    • The interpreter is a library target with a thin src/main.c driver on top. The REPL, QUIT and 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_NORETURN belongs only in a main().
    • The interpreter owns no window, renderer or game loop. It draws through whatever akgl renderer the host already initialized, and a host must be able to bound execution rather than surrender control to a run() that never returns.

Missing capabilities get filed upstream, not worked around

When libakgl — or libakstdlib — cannot supply something a verb needs, do not work around it here. Add a numbered item to that repository's TODO.md 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. 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. Four gaps were filed this way — text measurement, immediate-mode drawing, audio, and a non-blocking keystroke read — and all four landed upstream as akgl_text_measure, the akgl_draw_* family, akgl_audio_* and akgl_controller_poll_key. FILTER is the one verb still blocked on a gap, and DIRECTORY is refused pending an opendir/readdir wrapper in libakstdlib. 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/reference/ 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 into BasicTokens. Verbs and function names are case-insensitive; variable names are case-sensitive.
  • basicparser.go / basicparser_commands.go — recursive-descent parser producing BasicASTLeaf nodes; basicgrammar.go defines 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 of IF/FOR/GOSUB/READ. Environments chain via parent.
  • 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() and Println() mirror every line to stdout, and that mirror is the only reason a golden-file suite is possible at all. Our akbasic_TextSink, and akbasic_sink_init_tee() in particular, is the equivalent.
  1. Everything is fixed-size and statically allocated. main.go set the budget: MAX_LEAVES/MAX_TOKENS 32 per environment, MAX_VALUES 64, MAX_VARIABLES 128, MAX_SOURCE_LINES 9999, MAX_LINE_LENGTH 256, MAX_ARRAY_DEPTH 64. The 32-leaf ceiling is why a line is limited to roughly 16 operations. Ours are in include/akbasic/types.h and are the same numbers plus three the Go version did not need because it called make().
  2. waitingForCommand drives 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 whole FOR ... NEXT written 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.1, 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: __akerr_last_ignored 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.1 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.5.0 libakgl.so.0.5 MAJOR.MINOR while major is 0 AKGL_VERSION*, akgl_version(), AKGL_VERSION_AT_LEAST()

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 is asymmetric, and that is deliberate. find_package(akstdlib 0.1) and find_package(akgl 0.1) both work; each ships a ConfigVersion.cmake at SameMinorVersion, mirroring its soname. find_package(akerror 1.0) fails against a correct install, because libakerror ships akerrorConfig.cmake and akerrorTargets.cmake but no akerrorConfigVersion.cmake. Ask for akerror unversioned. Its floor is enforced instead by 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 the guard. The missing version file is filed in deps/libakstdlib/TODO.md §2.3; when it lands, add the 1.0 floor to the find_dependency calls.

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.

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. libakerror namespaces its mutation target when embedded but not its coverage target, so any coverage-enabled top-level build fails with "another target with the same name already exists". We shadow add_custom_target and rename that one to akerror_coverage on the way past. libakstdlib (both targets) and libakgl (its mutation target) namespace themselves correctly. The real fix is upstream in libakerror — the same CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR test it already applies to mutation — and it is filed in deps/libakstdlib/TODO.md §2.3. Delete the workaround when it lands.

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 is documented in TODO.md 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 its TODO.md §2.1 listed 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/reference/ is the Go implementation's own acceptance suite, byte-compared. Nothing in it is ever edited to suit this interpreter. If a case fails, either this interpreter is wrong or the divergence is deliberate — and a deliberate one goes in TODO.md and docs/13-differences.md, not into the expectation file. tests/reference/README.md says the same thing at more length.

tests/language/ is ours and may be changed freely. A new language feature needs a .bas/.txt pair there as well as unit tests.

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 checkoutgit 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.


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 with PREPARE_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.
  • CATCH and the FAIL_*_BREAK macros expand to a C break, so they must never appear inside a loop or a nested switch within an ATTEMPT: the break would escape only the loop, and the rest of the ATTEMPT would run with an error pending. Inside a loop use PASS or FAIL_*_RETURN, or move the loop into its own akerr_ErrorContext * helper and CATCH that single call.
  • Never use a *_RETURN macro inside an ATTEMPT block. It returns past CLEANUP, so every release, fclose and free is skipped.
  • deps/libakerror/AGENTS.md is the authoritative statement of the protocol, and deps/libakerror/UPGRADING.md is required reading before writing an error code.

Error codes

akbasic owns 512767 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 260 reserved by akgl_error_init(); AKGL_ERR_BASEAKGL_ERR_LIMIT - 1, five codes
(free) 261 511 headroom for libakgl to grow into; do not claim it
"akbasic" 512 767 ours; AKBASIC_ERR_BASE is 512

Values 0255 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 TODO.md §2 says the limit plainly: 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.

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

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, deliberately: a READY in the corner is noise in a figure about BOX, and skipping it means no font has to be found.

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 make that quietly rewrote eight binaries would put that diff in front of whoever happened to build.
  • docs_examples fails 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 outputbasic plus an output block. Preferred: it is the only shape that pins what the reader will actually see.
  • A program with no outputbasic alone. The harness still asserts it parses, runs, and raises nothing.
  • An interactive transcriptbasic 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 prints READY once at startup rather than after each line, so an interleaved transcript is not literally true anyway.
  • A verb that draws, plays or movesbasic requires=akgl. These produce no stdout, so they get no output block; the assertion is that they do not refuse.
  • A refusal message — write the program that provokes it and put the message in an output block. 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 tracetext. 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, an ATTEMPT for a block of CATCH calls.
  • 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.