# 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`](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. Every group of it is done; what remains is
   in the issue tracker 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.** 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/`.

```sh norun
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 `BasicToken`s. 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

```sh norun
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.3**, 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.3 | `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()` |

`project(akerror VERSION ...)` now stamps 2.0.3, matching that library's own "Release 2.0.3"
release notes. It previously disagreed — the soname and `akerrorConfigVersion.cmake` still
said 2.0.2 while the notes described 2.0.3 — but that was `libakerror` issue #38, which is
closed; the table and the notes agree again.

`IGNORE` still takes a *copy* of the ignored context so the pool slot can be released, which
fixes a real leak, but the copy no longer costs TLS per translation unit. It was a file-scope
`static` in the public header — one copy per TU, 37,296 bytes each, 1.35 MiB of thread-local
storage in `build/basic` alone, plus 84 `-Wunused-variable` warnings for every TU that never
called `IGNORE`. `libakerror` issue #37 moved it to an `extern` declaration in the header with
a single definition in `src/error.c`, which restores one copy per thread and silences the
warning. Issue #37 is closed and this subsection is gone accordingly.

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 `.gitignore`d 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 the `1.0`
  this 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, which `SameMajorVersion` correctly rejects.
- **There is nothing in this repository to change.** `akbasic` reaches all three dependencies
  by `add_subdirectory`, not `find_package`, and ships no CMake package config of its own —
  so it has no `find_dependency` calls 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 linking `akbasic` against 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_subdirectory`s 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 `.gitignore`d, 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

```sh norun
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.

```sh norun
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:

```sh norun
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:

```sh norun
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 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 **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:

```c norun
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 calls
  `akbasic_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.h`
  records three separate defects that came from giving one link field two meanings;
  `AKBASIC_LEAF_FIELD` keeps 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.

### Abandoned generators

The one invariant generators (`GEN`/`EMIT`, `FOR EACH`/`DO EACH`) add to the environment
pool: **a suspended generator hangs off its loop scope's `forGeneratorEnv` as a *child*,
off the parent chain, so no walk up `->parent` ever finds it.** Any path that discards a
loop scope — `EXIT`, a mismatched `NEXT`, a `LOOP` condition saying stop, an error unwind
through `akbasic_runtime_pump_generator()` or `akbasic_runtime_call_function()` — must
release that generator too, or its pool slot is stranded until the next `RUN`; with
twelve slots, a loop that abandons a dozen of them kills the program with "Environment
pool exhausted" far from the cause.

Two functions own the invariant, and every discard path goes through one of them:
`akbasic_runtime_release_generator()` (walks a detached generator chain up through its
call frame, recursing into any `forGeneratorEnv` it passes) and
`akbasic_runtime_unwind_to_environment()` (pops the *active* chain down to a target,
releasing each popped scope's suspended generator on the way). If you add a new path
that pops or discards environments, use one of these — a bare
`akbasic_runtime_prev_environment()` loop reintroduces the leak, and
`tests/generators.c` holds the pool-exhaustion tests that will say so.

---

## 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:

```sh norun
$ 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
  `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 output** — `basic` plus an `output` block. Preferred: it is the
  only shape that pins what the reader will actually see.
- **A program with no output** — `basic` alone. 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 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 moves** — `basic 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 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`, 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:

```sh norun
./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 `cd`s 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.
