Port the BASIC interpreter from Go to C

Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.

The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.

Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.

Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.

src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.

Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.

Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.

The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.

ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 23:53:56 -04:00
commit f8c15c2f2c
60 changed files with 10192 additions and 0 deletions

417
CLAUDE.md Normal file
View File

@@ -0,0 +1,417 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this project is
`akbasic` is a **C rewrite of the Go BASIC interpreter in `deps/basicinterpret`**, written in the same
style as the `ak*` C libraries it builds on.
The repository is currently **empty apart from its submodules** — no commits, no source tree, no build
files. The staged content is `.gitmodules` plus four submodules under `deps/`. Everything below the
"Project goals" section describes the material to build from, not code that exists here yet.
| Submodule | Language | Role |
|---|---|---|
| `deps/basicinterpret` | Go | The implementation being rewritten; behavioral spec for the port (its `go.mod` is already `module akbasic`) |
| `deps/libakerror` | C | TRY/CATCH-style error contexts — the error-handling 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 |
### Project goals
In priority order:
1. **Port Go → C.** Reproduce the reference interpreter in C, in the idiom of `libakerror` /
`libakstdlib` / `libakgl`: `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.** Implement the full Dartmouth BASIC and Commodore 128 BASIC v7 verb and
function set. `deps/basicinterpret/README.md` ends with an explicit list of everything unimplemented
(`SOUND`, `PLAY`, `ENVELOPE`, `VOL`, `SPRITE`, `MOVSPR`, `GRAPHIC`, `DRAW`, `BOX`, `PAINT`,
`WINDOW`, `GETKEY`, `DO`/`LOOP`/`WHILE`/`UNTIL`, `ON`, `TRAP`, the disk verbs, …) — that list is
the work queue. A few entries are deliberately out of scope on a modern PC and marked so there
(`BANK`, `FAST`, `MONITOR`); keep that reasoning rather than reviving them.
**When `libakgl` cannot supply a capability a verb needs, do not work around it here.** Add a TODO
item to `deps/libakgl/TODO.md` describing the missing API — what the BASIC verb requires, what the
`akgl_*` entry point should look like, and what tests would cover it — and follow the numbered,
prose-paragraph style of the existing "Carried over" entries. Audio (`PLAY`/`SOUND`/`ENVELOPE`/
`FILTER`/`VOL`/`TEMPO`) and immediate-mode drawing (`DRAW`/`BOX`/`PAINT`/`CIRCLE`) are the obvious
gaps: `src/draw.c` and `src/text.c` are both at 0% coverage and there is no mixer-backed audio API
yet. Growing `libakgl` to serve the interpreter is a wanted outcome, not a detour.
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:
- Build the interpreter as a library target with a thin `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 the
driver's `main()`.
- The interpreter does not own the window, renderer, or game loop. It draws through whatever
`akgl` renderer the host has already initialized, and a host must be able to step or bound
execution rather than surrendering control to a `run()` that never returns.
Before doing anything else in a fresh clone:
```sh
git submodule update --init --recursive
```
Note that `libakgl` vendors its *own* copies of `libakerror` and `libakstdlib` (plus SDL3, SDL3_image,
SDL3_mixer, SDL3_ttf, jansson, 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` / `deps/libakstdlib` *before* `add_subdirectory(deps/libakgl)`
or the targets will be declared twice.
Doing it in that order is now load-bearing for a second reason: `deps/libakerror` is at **1.0.0**,
which is a source and ABI break with an soname (`libakerror.so.1`). `libakstdlib` and `libakgl` must be
compiled against that header, not a pre-1.0.0 one, and an installed `libakerror.so.0` on the system
must not be picked up. Build everything from the submodules in one tree. `libakgl`'s
`target_compile_definitions(akerror PUBLIC AKERR_MAX_ERR_VALUE=256)` at
`deps/libakgl/CMakeLists.txt:44` refers to a macro 1.0.0 deleted — it is inert, and it should go when
that repo is next touched. See the error-code section below for a live collision this upgrade exposed.
### Dependency versions and what they promise
| Submodule | Version | soname | ABI rule | Version API |
|---|---|---|---|---|
| `deps/libakerror` | 1.0.0 | `libakerror.so.1` | major only | **none** — no version macro, no `ConfigVersion` file |
| `deps/libakstdlib` | 0.1.0 | `libakstdlib.so.0.1` | **`MAJOR.MINOR` while major is 0** | `AKSL_VERSION_*`, `aksl_version()`, `AKSL_VERSION_CHECK()` |
| `deps/libakgl` | 0.1.0 | `libakgl.so.0.1` | **`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.
`libakstdlib` 0.1.0 added a version API worth using. `akstdlib.h` now pulls in
`<akstdlib_version.h>`, which is **generated into the build tree** by CMake and installed 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 defines
`AKSL_VERSION_MAJOR`/`MINOR`/`PATCH`/`STRING`/`NUMBER` and `AKSL_VERSION_SONAME`, recording what the
caller was *compiled* against, and the library exposes `aksl_version()`, `aksl_version_string()` and
`aksl_version_soname()` for 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` 0.1.0 followed the same pattern one commit later, for the same reason: it had `AKGL_VERSION`
hand-maintained in `game.h` and no `VERSION` on `project()` at all, so `akgl.pc` shipped an empty
`Version:` and the `.so` had no soname. Now `project(akgl VERSION 0.1.0)` 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()` for what actually loaded.
`libakstdlib` also states, and tests, that it defines no status codes and reserves no range. That is
why it does not appear as an owner in the range map below.
**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 both `akstdlib.h` and `akgl/error.h`
now carry — include either and you inherit the guard. The missing version file is filed in both
`deps/libakstdlib/TODO.md` §2.3 and `deps/libakgl`'s history; fix it upstream, then add the `1.0` floor
to the `find_dependency` calls.
### Embedded builds collide on test names, target names and coverage data
All three libraries register their CTest tests unconditionally and add coverage/mutation targets. A
top-level `akbasic` build that `add_subdirectory`s all three walks into four separate collisions.
`libakgl` hit two of them in the last week and its fixes are the ones to copy.
**1. Duplicate CTest entries.** Every dependency's tests register into *our* suite. Pulled in
`EXCLUDE_FROM_ALL` their binaries are never built, so each shows up as "Not Run" and fails. CMake
offers no way to un-register a test and `set_tests_properties` cannot reach across directory scopes.
`libakstdlib` solved it by shadowing `add_test` and `set_tests_properties` for the duration of the
`add_subdirectory()` call — `deps/libakstdlib/CMakeLists.txt:104-150`. Note that its shadow only arms
when *it* is top-level, so it does nothing for us: **we need our own, around all three
`add_subdirectory()` calls**, not just `libakerror`'s.
**2. Duplicate test *target* names.** `add_subdirectory` creates a dependency's targets even under
`EXCLUDE_FROM_ALL`. When `libakstdlib` 0.1.0 added `tests/test_version.c`, `libakgl`'s own
`test_version` stopped the configure dead:
```
add_executable cannot create target "test_version" because another target with the same name
already exists.
```
`libakgl` fixed it by building its programs as `akgl_test_<name>` while keeping the bare CTest names
(`ctest -R version` still works). `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 (`deps/libakerror/CMakeLists.txt:194` vs `:172`), so any
coverage-enabled top-level build fails with *"another target with the same name already exists"*.
Shadow `add_custom_target` and rename that one, as `deps/libakstdlib/CMakeLists.txt:134-141` does.
`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.** `libakgl` defect #13: `gcovr` searches for
`.gcda`/`.gcno` under `--root`, which its `CMakeLists.txt:208`/`:223` set to the *source* directory, so
every instrumented build tree left in the source dir is folded into the report. `--object-directory`
does not narrow that 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. **Keep build trees out of the
source directory** — the `cmake -S . -B build` convention above already does, and this is the reason
to hold to it.
## Reference implementation (`deps/basicinterpret`)
This is a Commodore BASIC 7.0 / Dartmouth BASIC dialect written in Go with SDL2 + SDL2_ttf. It is the
behavioral spec: when a question about semantics comes up ("what does `READ` do when it runs off the
end of `DATA`?"), the answer lives in this code and in `tests/`.
```sh
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
```
`test.sh` is a golden-file harness: for every `tests/**/*.bas` it runs the interpreter and md5-compares
stdout against the sibling `.txt`. The exit code is the number of failures. To run a single case,
just run the interpreter on that `.bas` and diff against its `.txt`.
That corpus is the natural acceptance suite for the rewrite: the C interpreter should reproduce the
same stdout for the same `.bas` files. Carry it over (or drive it in place) alongside the per-module
CTest executables the `ak*` style calls for — the two answer different questions, and new language
features from goal 2 need a `.bas`/`.txt` pair as well as unit tests.
`deps/basicinterpret/README.md` is the language reference — implemented verbs, functions, variable
type suffixes, and an explicit list of what is *not* implemented. Read it before adding or porting
language features.
### Architecture of the reference interpreter
Scanner → parser → tree-walking runtime, one source line at a time:
- `basicscanner.go``BasicScanner` 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 `BasicASTLeafType` and leaf structure). Commands get their own
parse paths in `basicparser_commands.go`.
- `basicruntime.go``BasicRuntime` owns the program source (`[MAX_SOURCE_LINES]BasicSourceLine`),
the variable pool, the scanner/parser, the environment stack, and the SDL window. `evaluate()` walks
the AST; `commandByReflection()` dispatches a verb to its `BasicRuntime` method by name, so adding a
verb is largely a matter of adding a correctly-named method.
- `basicruntime_commands.go` / `basicruntime_functions.go` — the verb and function implementations.
- `basicenvironment.go``BasicEnvironment` is 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` and are pushed/popped with `newEnvironment()` / `prevEnvironment()`.
- `basicvalue.go` / `basicvariable.go``BasicValue` (the strongly-typed value) and `BasicVariable`
(a named slot plus array metadata). 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** as well as
to the surface; that mirror is the only reason the golden-file suite works, so the C port needs an
equivalent (a text sink the driver can point at stdout while the embedded case renders through
`akgl`) or the whole `tests/` corpus becomes unusable.
Two design decisions matter most if you are porting this to C:
1. **Everything is fixed-size and statically allocated.** `main.go` defines 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
the README says a line is limited to roughly 16 operations. This lines up with the `ak*` libraries'
no-dynamic-allocation rule — the port should keep the pool-allocation model, not replace it with `malloc`.
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. Any reimplementation has to reproduce this or restructure control flow deliberately.
Runtime modes are `MODE_REPL`, `MODE_RUN`, `MODE_RUNSTREAM` (piped input), `MODE_QUIT`; `BASIC_TRUE`
is `-1` and `BASIC_FALSE` is `0`, per Commodore convention.
## Working in the `ak*` C libraries
Each of the three C libraries carries its own `AGENTS.md` (`CLAUDE.md` in them just points at it) with
authoritative per-repo rules. Read the relevant one before editing a submodule. The shared shape:
```sh
cmake -S . -B build # out-of-tree, always
cmake --build build --parallel
ctest --test-dir build --output-on-failure
ctest --test-dir build -R <name> --output-on-failure # single test
```
Extra harnesses, by library (option prefixes differ per repo):
```sh
cmake -S . -B build-asan -DAKSL_SANITIZE=ON # libakstdlib: ASan + UBSan
cmake -S . -B build-coverage -DAKSL_COVERAGE=ON # libakstdlib
cmake -S . -B build-coverage -DAKGL_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug # libakgl (needs gcovr)
cmake --build build --target coverage
cmake --build build --target mutation # slow; scripts/mutation_test.py for a subset
```
`libakerror/test.sh` shows the full CI gate for that library (ctest + mutation ≥65 + coverage ≥90 line
/ ≥50 branch). `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
behavior is wanted.
Tests are one-file C programs registered in a list in `CMakeLists.txt`. `libakstdlib` splits them three
ways and **two of the lists invert the meaning of "Passed"**: `AKSL_TESTS` must exit 0;
`AKSL_WILL_FAIL_TESTS` abort by design; `AKSL_KNOWN_FAILING_TESTS` assert the *correct* contract for a
defect documented in `TODO.md` and are expected to fail. A green `ctest` run 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 `AKSL_TESTS` along with the fix. `libakerror` has the same split
(`AKERR_TESTS` / `AKERR_WILL_FAIL_TESTS`).
That split is not academic for `libakstdlib`: `deps/libakstdlib/TODO.md` §2.1 lists six **confirmed**
defects, four with a known-failing test apiece. Several are in functions this port would otherwise
reach for by default — the `aksl_ato*` family cannot report a conversion failure at all, and the
linked-list functions truncate. **Read §2.1 before calling anything in `akstdlib.h` for the first
time**; `TODO.md` §1.9 in this repo records which calls are cleared for use here and which are not.
The branch-coverage gate there was re-ratcheted 45 → 40 after the 1.0.0 bump, because the new
`PREPARE_ERROR`/`FAIL_*` macros expand to more branches per call site — that is a denominator change,
not a regression, and the same effect will show up in this repo's numbers.
## The libakerror error convention
New C code in this repo should follow it — it is what `libakstdlib` and `libakgl` both do, and mixing
conventions breaks error propagation.
- 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; use `FINISH_NORETURN(e)` in `main()`.
- **`CATCH` and the `FAIL_*_BREAK` macros expand to a C `break`**, so they must never appear inside a
loop or 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 loops use `PASS` / `FAIL_*_RETURN`, or move the
loop into its own `akerr_ErrorContext *` helper and `CATCH` that single call.
- Custom error codes must be **reserved** with `akerr_reserve_status_range()` and **named** with
`akerr_register_status_name()` during initialization — see the allocation section below before
defining any.
- Unhandled errors that reach the top level abort the process with a stack trace. That is the intended
behavior for the standalone driver — but see goal 3: the interpreter *library* must not rely on it.
### Error-code allocation across libraries
**`libakerror` 1.0.0 changed this completely.** The hazard that used to live in this section — a
consumer-sized `__AKERR_ERROR_NAMES` array, a mismatched `AKERR_MAX_ERR_VALUE` corrupting BSS, and no
authority to allocate from — is gone. Read `deps/libakerror/UPGRADING.md` before writing any error
code; the short version follows.
**What 1.0.0 removed.** 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 |
It is also a **source and ABI break** carrying an soname (`libakerror.so.1`). Anything built against a
pre-1.0.0 header must be rebuilt — including `libakstdlib` and `libakgl`. A stale
`-DAKERR_MAX_ERR_VALUE=...` is now harmless but useless; delete it when you see it.
**How allocation works now.** Values `0``255` belong to `libakerror` (the host errno space plus the
`AKERR_*` codes), reserved by `akerr_init()` under the owner string `"libakerror"`. 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 at init:
```c
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 return `akerr_ErrorContext *` and are `AKERR_NOIGNORE`, so a collision is an ordinary error you
`CATCH`, `HANDLE`, or `PASS` out of your 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 (unlike pre-1.0.0) it no longer clears reservations made before it ran.
Repeating an identical reservation for the same owner is a no-op; a subset or superset of your own
range is not, so reserve the whole thing in one call.
**The coordinated range map for this dependency stack.** The library 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_BASE``AKGL_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 |
**Every owner reserves at init, and each does it in its own initializer.** `akerr_init()` claims
0255 for itself. `akgl_error_init()` (`deps/libakgl/src/error.c`, new in libakgl 0.1.0) claims
`AKGL_ERR_BASE` = `AKERR_FIRST_CONSUMER_STATUS` = 256 through `AKGL_ERR_LIMIT - 1` = 260 and names all
five codes. `libakstdlib` claims nothing. `akbasic_init()` claims 512767.
`akgl_error_init()` has an ordering requirement worth knowing before you wire up `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, so
calling it more than once is fine; what is *not* fine is a subset or superset reservation, which
`libakerror` refuses.
**What `akbasic` does.** Reserve and name in `akbasic_init()`, and let failures propagate:
```c
#define AKBASIC_OWNER "akbasic"
enum {
AKBASIC_ERR_BASE = 512, /* AKERR_FIRST_CONSUMER_STATUS + 256; see the map above */
AKBASIC_ERR_SYNTAX = AKBASIC_ERR_BASE,
AKBASIC_ERR_TYPE,
/* ... */
AKBASIC_ERR_LIMIT = AKBASIC_ERR_BASE + 256
};
```
Use an `enum`, not a chain of `#define`s: the values stay compile-time integer constants, so `HANDLE`
still works (its `case` labels require that), and adding a code does not mean editing an arithmetic
offset. Reserve `AKBASIC_ERR_LIMIT - AKBASIC_ERR_BASE` in one call.
**What is still not solved, and what it means for us.** `libakerror`'s own `TODO.md` §2 says it 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 coordination advice above has no tooling behind it yet; the table in this file is
the tooling. Two practical 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.** The `libakgl` defect above
is exactly what that produces.
Capacity is not a concern here: the name registry holds 3072 entries and `akerr_init()` consumes about
150, and reservations cap at 64 ranges. Both are `PRIVATE` to the `libakerror` target and invisible to
consumers — raising them is a `libakerror` configure-time decision, not something `akbasic` sets.
Registration is **not thread safe**; do it during single-threaded init, before the host game spawns
anything.
`libakgl` adds a hard rule of its own: **avoid dynamic memory allocation.** Obtain objects from
`akgl_heap_next_*` and release them back; if no heap layer exists for a needed type, add one rather
than calling `malloc`.
## Style and commits
C is C99-compatible, 4-space indent, braces on the next line for function bodies, spaces inside
control-flow parens. Match the surrounding file — several files mix tabs and spaces, and there is no
repo-wide formatter, so avoid unrelated formatting churn. Public symbols take a library prefix
(`akerr_`, `aksl_`, `akgl_`) with `akerr_TypeName`-style types and `AKGL_UPPER_SNAKE_CASE` macros.
Since this interpreter is meant to link into `libakgl` (goal 3), it needs its own non-colliding
prefix in the same shape — `akbasic_` for functions, `akbasic_TypeName` for types, `AKBASIC_` for
macros and constants — and matching header/source pairs named by feature, as in `src/scanner.c` /
`include/akbasic/scanner.h`.
Commit subjects are short, imperative, sentence case ("Fix refcount leak and stack-trace buffer
overflow"). `libakgl`'s `AGENTS.md` requires agents to add themselves (program, model, version) as a
commit co-author. Never edit generated output — `build/` trees, generated `akerror.h`, `akgl.pc`,
`include/akgl/SDL_GameControllerDB.h`; change the template or generator script instead.