# 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. 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/`. ```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.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.7.0 | `libakgl.so.0.7` | **`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 ``, 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` 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_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. **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_` while keeping the bare CTest names. `libakstdlib` still uses bare `test_` targets. **Name every test target in this repo `akbasic_test_`** — 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 `.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 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_` 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 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. --- ## 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 – 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 | 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 `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. ### 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. --- ## 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 | 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.