Split the documentation by who reads it
Some checks failed
akbasic CI Build / cmake_build (push) Successful in 3m2s
akbasic CI Build / sanitizers (push) Successful in 3m51s
akbasic CI Build / coverage (push) Failing after 3m24s
akbasic CI Build / akgl_build (push) Failing after 20s
akbasic CI Build / mutation_test (push) Has been cancelled
Some checks failed
akbasic CI Build / cmake_build (push) Successful in 3m2s
akbasic CI Build / sanitizers (push) Successful in 3m51s
akbasic CI Build / coverage (push) Failing after 3m24s
akbasic CI Build / akgl_build (push) Failing after 20s
akbasic CI Build / mutation_test (push) Has been cancelled
README.md was 577 lines and answered four different questions at once: what
the project is, how to build it, every verb and function in the language, and
how to maintain the test harness. The verb and function lists had already been
written a second time in docs/11 and docs/12, which is how a list of that size
goes stale -- there is no way to notice the two have drifted apart.
README.md is now 150 lines and holds only what somebody evaluating the project
needs: what it is, the quickstart, why it was rewritten in C, the five rules
embedding imposes on the design, the two ways to use it, and where everything
else lives. Technical detail goes to docs/, maintenance to MAINTENANCE.md.
The akbasic_TextSink struct moved to docs/10-embedding.md rather than being
deleted. It was the corpus's only `c excerpt=` block -- the check that caught
the stale struct two commits ago -- so dropping it with the README would have
quietly retired a test. docs/10 also stopped claiming README.md carries the
full API surface and the pool limits, which the trim made false.
CLAUDE.md went from 458 lines to 62, because almost none of it was
agent-specific. The project goals, the Go reference and its architecture, the
dependency version and ABI rules, the four ways an embedded build collides,
the libakerror convention, the error-code range map and the style rules are
all things a maintainer needs, and they are now in MAINTENANCE.md with one
copy to keep true. CLAUDE.md points there and keeps only the rules no test
enforces: tests in the same commit asserting the correct contract, file a
missing dependency capability upstream, do not edit generated output or
tests/reference/, co-author your commits.
Four claims did not survive the move, having gone stale where nothing could
notice:
- "The repository is currently empty apart from its submodules -- no
commits, no source tree, no build files." There are 43 commits.
- libakgl's target_compile_definitions(akerror PUBLIC AKERR_MAX_ERR_VALUE)
at deps/libakgl/CMakeLists.txt:44, described as inert but present. It is
gone; only a historical mention in a comment remains.
- "akbasic_init() claims 512-767." There is no akbasic_init. It is
akbasic_error_register(), called from akbasic_runtime_init().
- Time-relative phrasing ("libakgl hit two of them in the last week").
Five places pointed at CLAUDE.md for the range map or the file-it-upstream
rule and now point at MAINTENANCE.md: include/akbasic/error.h,
src/runtime_disk.c and three entries in TODO.md. Both source changes are
comments. deps/libakgl/TODO.md cites it too and is left alone; it is a
submodule, and the rule it quotes is still reachable from CLAUDE.md.
ctest is green at 95 of 95, docs_examples included: 36 programs, 9
transcripts, 44 output comparisons, 3 C snippets, 1 excerpt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
491
CLAUDE.md
491
CLAUDE.md
@@ -1,74 +1,34 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this
|
||||
repository.
|
||||
|
||||
## What this project is
|
||||
## Read MAINTENANCE.md first
|
||||
|
||||
`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.
|
||||
**[`MAINTENANCE.md`](MAINTENANCE.md) is the authoritative document for changing this project**,
|
||||
and it is written for whoever has to do the changing — human or agent. It carries what used to
|
||||
live in this file: the project goals, the Go reference implementation and its architecture, the
|
||||
dependency versions and their ABI rules, the four ways an embedded build collides, the
|
||||
`libakerror` error convention, the coordinated error-code range map, the test lists, the
|
||||
documentation-example harness, and the style and commit rules.
|
||||
|
||||
**That Go project is deprecated and will not be updated, and this interpreter is no longer required
|
||||
to match it.** See `TODO.md` §0.1, which is the first thing to read: it retires the byte-for-byte
|
||||
fidelity constraint that several sections of that file were written on. The reference's corpus is
|
||||
checked in at `tests/reference/` and stays green as a *regression* suite; the Go source stays
|
||||
readable as documentation of what the original did. Neither is binding any more.
|
||||
There is one copy of all of that, on purpose. Anything you would have added here, add there
|
||||
instead — the maintainer needs it as much as you do, and a second copy is a second thing to
|
||||
keep true.
|
||||
|
||||
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.
|
||||
`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, and meant to end up embedded in `libakgl` as a
|
||||
scripting engine for game authors.
|
||||
|
||||
| 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.
|
||||
|
||||
**Group H (sprites) is done.** `src/runtime_sprite.c` and `src/sprite_akgl.c`. libakgl's
|
||||
sprite JSON turned out not to be in the way: `akgl_sprite_load_json()` is a thin wrapper over
|
||||
four initializers plus writes to public struct fields, and every field it fills from a
|
||||
document is something a Commodore sprite does not have. The one thing that *did* survive into
|
||||
the verb syntax is the image path — `SPRSAV "ship.png", 1` goes through `akgl_path_relative()`
|
||||
and `akgl_spritesheet_initialize()`, the same calls the JSON loader makes. `SPRDEF` is out of
|
||||
scope with `BANK`/`FAST`/`MONITOR`: it is an interactive editor, not a programmable verb.
|
||||
|
||||
**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.
|
||||
| Read | For |
|
||||
|---|---|
|
||||
| [`MAINTENANCE.md`](MAINTENANCE.md) | Everything above. Start here |
|
||||
| [`TODO.md`](TODO.md) | Outstanding defects, with file, line and consequence. §0.1 first — it retires the byte-for-byte fidelity constraint several later sections were written on |
|
||||
| [`README.md`](README.md) | What the project is and why, for somebody who has not seen it |
|
||||
| [`docs/`](docs/README.md) | The language itself: thirteen chapters, verb and function reference |
|
||||
| `deps/libakerror/AGENTS.md` | The `ATTEMPT`/`CLEANUP`/`PROCESS`/`HANDLE`/`FINISH` protocol, authoritatively |
|
||||
| `deps/libakerror/UPGRADING.md` | 1.0.0's status registry. Required before writing an error code |
|
||||
| `deps/<library>/AGENTS.md` | Per-repo rules. Read the relevant one **before editing a submodule** |
|
||||
|
||||
Before doing anything else in a fresh clone:
|
||||
|
||||
@@ -76,382 +36,27 @@ Before doing anything else in a fresh clone:
|
||||
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.2.0 | `libakstdlib.so.0.2` | **`MAJOR.MINOR` while major is 0** | `AKSL_VERSION_*`, `aksl_version()`, `AKSL_VERSION_CHECK()` |
|
||||
| `deps/libakgl` | 0.4.0 | `libakgl.so.0.4` | **`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 — and both
|
||||
libraries have now actually made that jump, so anything built against the 0.1 headers must be
|
||||
rebuilt rather than relinked.
|
||||
|
||||
**The two moved together and had to.** `libakgl` 0.2.0 consumes the `libakstdlib` 0.2.0 API, so a
|
||||
tree that pins `libakstdlib` at 0.1.0 and adds `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.
|
||||
|
||||
`libakstdlib` 0.1.0 added a version API worth using, and it still applies at 0.2.0. `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.2.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 acceptance suite for the rewrite: the C interpreter reproduces the same stdout
|
||||
for the same `.bas` files. **It has been copied into this repository at `tests/reference/`** and no
|
||||
longer runs out of the submodule — see `tests/reference/README.md` for the provenance and for the
|
||||
rule that those expectations are never edited to suit this interpreter. It sits 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 in `tests/language/` as well as unit tests.
|
||||
|
||||
**`deps/basicinterpret` is no longer a build or test dependency.** Both configurations were
|
||||
configured, built and run from scratch with it moved out of the tree. It is kept only as the
|
||||
behavioural spec to read when a question about semantics comes up — which is a real use, and the
|
||||
reason it has not been removed. The Commodore font it also supplied now lives in `assets/fonts/`.
|
||||
|
||||
`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
|
||||
```
|
||||
|
||||
**Optional test tooling for this repository.** `xdotool` and `script`(1) drive the `akgl_typing`
|
||||
test, which types at a real focused SDL window -- the only test that covers the path upstream of
|
||||
SDL, and the one that would have caught the missing `SDL_StartTextInput()`. It needs a real X
|
||||
server, steals keyboard focus for about fifteen seconds, and skips itself (CTest `Skipped`, not
|
||||
a failure) when it cannot run. `AKBASIC_SKIP_INTERACTIVE=1` skips it deliberately.
|
||||
|
||||
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 *used* to be load-bearing for `libakstdlib`, and no longer is: 0.2.0 fixed all six of the
|
||||
confirmed defects its `TODO.md` §2.1 listed, folded the four known-failing tests back into the
|
||||
suites for the things they test, and left `AKSL_KNOWN_FAILING_TESTS` empty. The `aksl_ato*` family
|
||||
now raises `AKERR_VALUE` on no digits or trailing junk and `ERANGE` on overflow, the linked-list
|
||||
functions no longer truncate, `AKERR_ITERATOR_BREAK` stops a tree traversal, and `aksl_realpath` no
|
||||
longer reads uninitialised memory.
|
||||
|
||||
`TODO.md` §1.9 in this repo is still the place to check before reaching into `akstdlib.h`, but it
|
||||
is now a record of what changed rather than a list of bans. One caveat survives the upgrade:
|
||||
`aksl_strhash_djb2` still sign-extends `char`, so a high-bit byte hashes differently from the
|
||||
`unsigned char` answer — see §1.3, which explains why the symbol tables cannot reach it.
|
||||
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
|
||||
0–255 for itself. `akgl_error_init()` (`deps/libakgl/src/error.c`) 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 512–767.
|
||||
|
||||
`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.
|
||||
## Rules that are on you rather than on a test
|
||||
|
||||
Most of the conventions in `MAINTENANCE.md` are enforced by something — a sorted-table test, a
|
||||
`WILL_FAIL` list, a Doxygen gate, a byte-compared corpus. These are not, so they are worth
|
||||
repeating where you will see them:
|
||||
|
||||
- **Add tests in the same commit as the behaviour change**, and assert the *correct* contract
|
||||
even where the code is currently wrong. A known-failing test goes in
|
||||
`AKBASIC_KNOWN_FAILING_TESTS` with a `TODO.md` entry; it does not get pinned to the buggy
|
||||
behaviour, because that turns the eventual fix into a test failure.
|
||||
- **Never work around a missing dependency capability here.** File it in that repository's
|
||||
`TODO.md` — what the BASIC verb requires, what the entry point should look like, what tests
|
||||
would cover it. `MAINTENANCE.md` explains why, and names the four gaps this closed upstream.
|
||||
- **Never edit generated output** — `build/` trees, the generated `akerror.h`, `akgl.pc`,
|
||||
`include/akgl/SDL_GameControllerDB.h`. Change the template or the generator script.
|
||||
- **Do not reformat code you are not otherwise changing.** Several files mix tabs and spaces
|
||||
and there is no repo-wide formatter; style conversions get their own commit.
|
||||
- **Do not edit `tests/reference/`.** Those expectations came from the Go implementation and
|
||||
are never edited to suit this interpreter. A deliberate divergence goes in `TODO.md` and
|
||||
`docs/13-differences.md`.
|
||||
- **Update `TODO.md` when you learn something about a defect**, including that it is worse or
|
||||
better than recorded. Publishing a problem you cannot fix yet is a contribution.
|
||||
- **Add yourself — program, model and version — as a commit co-author.** `libakgl`'s
|
||||
`AGENTS.md` requires it and this repository follows the same rule.
|
||||
|
||||
648
MAINTENANCE.md
648
MAINTENANCE.md
@@ -1,9 +1,541 @@
|
||||
# Maintaining akbasic
|
||||
|
||||
`README.md` is for people using the interpreter. `docs/` is for people writing BASIC in
|
||||
it. `CLAUDE.md` is for agents. This file is for whoever has to change the thing: the
|
||||
conventions that are enforced by a test rather than by a comment, and the ones that are
|
||||
not enforced at all and therefore have to be written down.
|
||||
`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.
|
||||
|
||||
---
|
||||
|
||||
## 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 **1.0.0**, a source
|
||||
and ABI break carrying 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` must not
|
||||
be picked up.
|
||||
|
||||
### 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.2.0 | `libakstdlib.so.0.2` | **`MAJOR.MINOR` while major is 0** | `AKSL_VERSION_*`, `aksl_version()`, `AKSL_VERSION_CHECK()` |
|
||||
| `deps/libakgl` | 0.4.0 | `libakgl.so.0.4` | **`MAJOR.MINOR` while major is 0** | `AKGL_VERSION*`, `akgl_version()`, `AKGL_VERSION_AT_LEAST()` |
|
||||
|
||||
For both 0.x libraries the soname carries `MAJOR.MINOR` deliberately: 0.1 and 0.2 are
|
||||
*different* ABIs, and both become major-only at 1.0. Do not read `0.1 → 0.2` as a compatible
|
||||
bump — both libraries have actually made that jump, so anything built against the 0.1 headers
|
||||
must be rebuilt rather than relinked.
|
||||
|
||||
**`libakstdlib` and `libakgl` move together and have to.** `libakgl` 0.2.0 consumes the
|
||||
`libakstdlib` 0.2.0 API, so a tree pinning `libakstdlib` at 0.1.0 while adding `libakgl` by
|
||||
`add_subdirectory()` compiles 0.2.0-era code against 0.1.0 headers and fails on `aksl_fwrite`,
|
||||
`aksl_fread` and `aksl_realpath`. Bump them as a pair.
|
||||
|
||||
`akstdlib.h` pulls in `<akstdlib_version.h>`, which CMake **generates into the build tree** and
|
||||
installs beside `akstdlib.h` — so link `akstdlib::akstdlib` and let the target carry its
|
||||
include directories. Hand-adding `deps/libakstdlib/include` to an include path gets you a
|
||||
missing-header error. It records what the caller was *compiled* against, and `aksl_version()`
|
||||
reports what actually *loaded*. `AKSL_VERSION_CHECK()` compares them and raises `AKERR_VALUE`
|
||||
naming both. **Call it once during initialization**: the soname normally catches a mismatch at
|
||||
load time, but a 0.2.0 dropped in under the 0.1 filename loads happily and only the check
|
||||
notices. It is a macro on purpose — it must expand at your call site to capture your numbers.
|
||||
|
||||
`libakgl` followed the same pattern. `project(akgl VERSION ...)` is the single source and
|
||||
drives the generated `include/akgl/version.h`, which is `.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_<name>` while keeping the bare
|
||||
CTest names. `libakstdlib` still uses bare `test_<name>` targets. **Name every test target in
|
||||
this repo `akbasic_test_<name>`** — it costs nothing and it is the collision that actually
|
||||
stopped a build.
|
||||
|
||||
**3. Duplicate custom targets.** `libakerror` namespaces its `mutation` target when embedded
|
||||
but **not** its `coverage` target, so any coverage-enabled top-level build fails with *"another
|
||||
target with the same name already exists"*. We shadow `add_custom_target` and rename that one
|
||||
to `akerror_coverage` on the way past. `libakstdlib` (both targets) and `libakgl` (its
|
||||
`mutation` target) namespace themselves correctly. **The real fix is upstream in
|
||||
`libakerror`** — the same `CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR` test it already
|
||||
applies to `mutation` — and it is filed in `deps/libakstdlib/TODO.md` §2.3. Delete the
|
||||
workaround when it lands.
|
||||
|
||||
**4. Stale build trees poison the coverage report.** See below; it is the reason for
|
||||
`cmake -S . -B build`.
|
||||
|
||||
### Build trees stay out of the source directory
|
||||
|
||||
`gcovr` searches for `.gcda`/`.gcno` under its `--root`, and the dependencies set that to
|
||||
their *source* directory — so a leftover instrumented build tree in the source dir is folded
|
||||
into the report, and `--object-directory` does not narrow the search. A leftover
|
||||
`build-coverage/` makes a freshly configured run fail in `coverage_reset`, before any test
|
||||
executes, with `Got function ... on multiple lines`. Because `build*/` is `.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_<name>` while registering under the bare CTest
|
||||
name. That is not cosmetic: `add_executable` creates a dependency's targets even under
|
||||
`EXCLUDE_FROM_ALL`, and when `libakstdlib` added a `test_version` it collided with
|
||||
`libakgl`'s and stopped the configure dead.
|
||||
|
||||
### The golden corpora
|
||||
|
||||
`tests/reference/` is the Go implementation's own acceptance suite, byte-compared.
|
||||
**Nothing in it is ever edited to suit this interpreter.** If a case fails, either this
|
||||
interpreter is wrong or the divergence is deliberate — and a deliberate one goes in
|
||||
`TODO.md` and `docs/13-differences.md`, not into the expectation file. `tests/reference/README.md`
|
||||
says the same thing at more length.
|
||||
|
||||
`tests/language/` is ours and may be changed freely. A new language feature needs a
|
||||
`.bas`/`.txt` pair there as well as unit tests.
|
||||
|
||||
### Mutation-check a fix before you believe it
|
||||
|
||||
Coverage says a line ran; it does not say anything would have noticed if it were wrong.
|
||||
That matters more here than usual, because the `akerror` macros expand at their call sites
|
||||
and `gcov` attributes them to the caller.
|
||||
|
||||
The discipline for any fix with a test: **revert the fix, confirm the test fails, restore
|
||||
it.** Use a file copy, not `git 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 is **not thread safe** — do it during single-threaded init, before the host game
|
||||
spawns anything.
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -60,8 +592,9 @@ Attributes combine: `basic requires=akgl setup=ship` is a real tag in `docs/08-s
|
||||
|
||||
### `sh` blocks: what is not run, and why
|
||||
|
||||
Five of the seven 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.
|
||||
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 |
|
||||
|---|---|
|
||||
@@ -120,106 +653,3 @@ it stopped matching anything looks exactly like a harness that passes because th
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
### Test target names
|
||||
|
||||
Every test program builds as `akbasic_test_<name>` while registering under the bare CTest
|
||||
name. That is not cosmetic: `add_executable` creates a dependency's targets even under
|
||||
`EXCLUDE_FROM_ALL`, and when `libakstdlib` added a `test_version` it collided with
|
||||
`libakgl`'s and stopped the configure dead.
|
||||
|
||||
### The golden corpora
|
||||
|
||||
`tests/reference/` is the Go implementation's own acceptance suite, byte-compared.
|
||||
**Nothing in it is ever edited to suit this interpreter.** If a case fails, either this
|
||||
interpreter is wrong or the divergence is deliberate — and a deliberate one goes in
|
||||
`TODO.md` and `docs/13-differences.md`, not into the expectation file. `tests/reference/README.md`
|
||||
says the same thing at more length.
|
||||
|
||||
`tests/language/` is ours and may be changed freely.
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
### 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 a coverage run fails before any test executes. `build*/` is
|
||||
`.gitignore`d, which makes the state easy to reach and hard to see. Always
|
||||
`cmake -S . -B build`.
|
||||
|
||||
---
|
||||
|
||||
## Code
|
||||
|
||||
### 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.
|
||||
|
||||
### Error codes
|
||||
|
||||
`akbasic` owns **512–767** under the owner string `"akbasic"`, reserved in
|
||||
`akbasic_runtime_init()`. Codes are an `enum` so they stay compile-time integer constants —
|
||||
`HANDLE`'s `case` labels require that. Never define one as an offset from another library's
|
||||
symbol; the coordinated map across the whole dependency stack is in `CLAUDE.md`.
|
||||
|
||||
### 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.
|
||||
|
||||
662
README.md
662
README.md
@@ -1,576 +1,150 @@
|
||||
This BASIC is styled after [Commodore BASIC 7.0](http://www.jbrain.com/pub/cbm/manuals/128/C128PRG.pdf) and the [Dartmouth BASIC from 1964](https://www.dartmouth.edu/basicfifty/basic.html). It is a C rewrite of [basicinterpreter](https://source.starfort.tech/andrew/basicinterpreter), which was itself built from the instructions for the Java implementation of Lox in [craftinginterpreters.com](https://craftinginterpreters.com) before striking off on its own. The Go version is vendored at `deps/basicinterpret` and is the behavioural specification: when a question about semantics comes up, the answer lives in that code. Its acceptance corpus is **checked in here** at [`tests/reference/`](tests/reference/README.md) and runs on every build, so nothing about building or testing this project needs that submodule any more.
|
||||
# akbasic
|
||||
|
||||
A BASIC interpreter written in C, styled after [Commodore BASIC 7.0](http://www.jbrain.com/pub/cbm/manuals/128/C128PRG.pdf)
|
||||
and the [Dartmouth BASIC of 1964](https://www.dartmouth.edu/basicfifty/basic.html). It runs a
|
||||
`.bas` file, it gives you a prompt, and — the point of the exercise — it links into a C program
|
||||
as a scripting engine.
|
||||
|
||||
It is a rewrite of [basicinterpreter](https://source.starfort.tech/andrew/basicinterpreter), a Go
|
||||
implementation that started from the Java Lox instructions in
|
||||
[craftinginterpreters.com](https://craftinginterpreters.com) and then struck off on its own. That
|
||||
project is deprecated. It is vendored here as the behavioural spec to read when a question about
|
||||
semantics comes up, and its acceptance corpus is checked in at
|
||||
[`tests/reference/`](tests/reference/README.md) and runs on every build — so nothing about
|
||||
building or testing this project needs it.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```sh norun
|
||||
git submodule update --init --recursive
|
||||
cmake -S . -B build
|
||||
cmake --build build --parallel
|
||||
|
||||
# To use the interactive REPL
|
||||
./build/basic
|
||||
|
||||
# To run a basic file from the command line
|
||||
./build/basic tests/reference/language/functions.bas
|
||||
|
||||
# The test suite: unit tests plus the Go version's own corpus, byte-compared
|
||||
ctest --test-dir build --output-on-failure
|
||||
|
||||
# API documentation, into build/docs/html
|
||||
doxygen Doxyfile
|
||||
|
||||
# With the libakgl-backed sink, devices and SDL frontend. Off by default, because
|
||||
# this pulls in SDL3 and the whole interpreter builds and tests without it.
|
||||
cmake -S . -B build-akgl -DAKBASIC_WITH_AKGL=ON
|
||||
cmake --build build-akgl --parallel
|
||||
ctest --test-dir build-akgl --output-on-failure
|
||||
|
||||
# That build of `basic` is a different program: it opens an 800x600 window, draws
|
||||
# BASIC output into it in the Commodore font, and still puts every byte on stdout.
|
||||
./build-akgl/basic tests/reference/language/functions.bas
|
||||
```
|
||||
|
||||
### Optional tools, and what you lose without them
|
||||
|
||||
Everything above works with cmake and a C compiler. Three tools are optional, and each one 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
|
||||
automatically 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.
|
||||
|
||||
There are 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.
|
||||
|
||||
Mutation testing is worth a word, because it is the only gate that checks the error-handling
|
||||
control flow at all. `libakerror`'s `ATTEMPT`/`CATCH`/`PASS` macros expand at their call sites,
|
||||
so gcov attributes them to the caller and line coverage cannot see them. `scripts/mutation_test.py`
|
||||
breaks the library many small ways and checks that the suite notices:
|
||||
|
||||
```sh norun
|
||||
cmake --build build --target mutation # the whole src/ tree; slow, hours
|
||||
python3 scripts/mutation_test.py --target src/value.c --list
|
||||
python3 scripts/mutation_test.py --target src/value.c --threshold 70
|
||||
./build/basic # the REPL
|
||||
./build/basic tests/reference/language/functions.bas # run a program
|
||||
```
|
||||
|
||||
It 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.
|
||||
```basic
|
||||
10 FOR I# = 1 TO 3
|
||||
20 PRINT "HELLO " + I#
|
||||
30 NEXT I#
|
||||
```
|
||||
|
||||
Every example in `README.md` and in `docs/` is executed by the suite, and its output
|
||||
compared byte for byte, as the CTest case `docs_examples`. Documentation goes stale because
|
||||
the *code* moved, not because somebody edited a chapter, so it runs on every build rather
|
||||
than on a docs path filter. It found four wrong examples the day it was added — two
|
||||
transcripts carrying a leading space `PRINT` does not emit, a `struct` in this file that had
|
||||
grown two members hours earlier, and a refusal message quoted with the wrong wording. The
|
||||
fence-tag convention it reads is documented in [`MAINTENANCE.md`](MAINTENANCE.md).
|
||||
```output
|
||||
HELLO 1
|
||||
HELLO 2
|
||||
HELLO 3
|
||||
```
|
||||
|
||||
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, so `doxygen Doxyfile` is a gate rather than a
|
||||
convenience. Every one of the 114 public declarations under `include/akbasic/` carries a
|
||||
`@brief`, a `@param` per parameter, a `@return` and its `@throws`.
|
||||
Two things in that program are not Commodore BASIC and will catch you out immediately: variables
|
||||
carry a **type suffix** (`I#` is an integer), and **`+` concatenates a string with a number**.
|
||||
[Chapter 3](docs/03-the-language.md) explains both; [Chapter 13](docs/13-differences.md) is the
|
||||
whole list of what differs from a C128.
|
||||
|
||||
# Why rewrite it in C?
|
||||
Graphics, sound and sprites need the SDL build, which is off by default because the interpreter
|
||||
and its entire test suite build on a machine with no SDL installed at all:
|
||||
|
||||
```sh norun
|
||||
cmake -S . -B build-akgl -DAKBASIC_WITH_AKGL=ON
|
||||
cmake --build build-akgl --parallel
|
||||
```
|
||||
|
||||
That `basic` is a different program: it opens a window, draws BASIC output into it in the
|
||||
Commodore font, and still puts every byte on stdout.
|
||||
|
||||
## Why rewrite it in C?
|
||||
|
||||
Three reasons, in the order they matter.
|
||||
|
||||
The interpreter is meant to end up *inside* [libakgl](https://source.starfort.tech/andrew/libakgl) as a scripting engine for game authors, and libakgl is C. Embedding a Go runtime in a C game is not a thing anybody should do to themselves.
|
||||
|
||||
The Go version was already written against static pools and explicit state structs — `[MAX_SOURCE_LINES]BasicSourceLine`, a fixed variable pool, a 32-leaf ceiling per line — so it ports across almost directly. It reads like C that happens to be spelled in Go. Rewriting it in the idiom of `libakerror` and `libakstdlib` was less work than it sounds.
|
||||
|
||||
And the port is a good excuse to find out what the original actually does, as opposed to what it looks like it does. It found five defects nobody knew about. See "What Isn't Implemented / Isn't Working", below.
|
||||
|
||||
# What Works?
|
||||
|
||||
Everything the Go version does, and by now a little more. All 41 `.bas` files of the reference's own corpus produce the expected output, including error messages and the trailing blank line after one. Each is a separate CTest case, so a failure names the file.
|
||||
|
||||
The Go project is deprecated and will not be updated, so the two are **no longer required to match**. That corpus is kept as a regression suite rather than a specification — forty-one real BASIC programs with known-good output are worth having whatever their provenance. Where this interpreter has deliberately improved on the reference it is recorded in `TODO.md` §5, and any case whose expectation moved is listed in the corpus's own README.
|
||||
|
||||
That corpus lives at [`tests/reference/`](tests/reference/README.md), copied byte for byte out of the Go project. Its README records where it came from and the rule for changing it: **diverge deliberately, never by accident.** A failure there means this interpreter's behaviour changed — nine times in ten that is a bug you just introduced, and the tenth time it is an improvement that should move the expectation, say why, and add a line to `TODO.md` §5.
|
||||
|
||||
## The guide
|
||||
|
||||
A full multi-chapter guide lives in [`docs/`](docs/README.md), organised the way the
|
||||
C128 Programmer's Reference Guide is: the language, then each hardware area, then a
|
||||
reference section for every verb and function. If you already write BASIC 7.0, start
|
||||
with [Chapter 13](docs/13-differences.md) — it is the list of everything that behaves
|
||||
differently here, and why.
|
||||
|
||||
## Case Sensitivity
|
||||
|
||||
The old computers BASIC was originally written on only had CAPITAL LETTER KEYS on their keyboards. Modern keyboards have the indescribable luxury of upper and lower case. In this basic, verbs and function names are case insensitive. Variable names are case sensitive.
|
||||
|
||||
## Variables
|
||||
|
||||
* `A#` Integer variables
|
||||
* `A%` Float variables
|
||||
* `A$` String variables. Strings support addition operations with other types.
|
||||
* `LET` is supported but optional
|
||||
* Variables are strongly typed
|
||||
|
||||
Note that `%` means *float* here, which inverts the Commodore convention where `%` is integer. That is what the Go version does and it is not being changed out from under anybody.
|
||||
|
||||
## Arrays
|
||||
|
||||
* `DIM IDENTIFIER(DIMENSION[, ...])` allows for provisioning of multiple dimensional arrays
|
||||
* `DIM A$(3)` results in a single dimensional array of strings with 3 elements
|
||||
* `PRINT A$(2)` accesses the last element in an array and returns it to the verb
|
||||
* `LEN(A#)` on an array returns its total element count
|
||||
* Arrays are strongly typed
|
||||
|
||||
## Expressions
|
||||
|
||||
* `+`
|
||||
* `-`
|
||||
* `^`
|
||||
* `*` (also works on strings)
|
||||
* `/`
|
||||
* `< <= <> == >= >` less than, less than equal, not equal, equal, greater equal, greater than
|
||||
|
||||
Expressions can be grouped with `()` arbitrarily deeply. Currently the interpreter has a limit of 32 tokens and leaves per line. In effect this means about 16 operations in a single line.
|
||||
|
||||
## Commands (Verbs)
|
||||
|
||||
The following commands/verbs are implemented:
|
||||
|
||||
* `AUTO n` : Turn automatic line numbering on/off at increments of `n`
|
||||
* `COLLISION t [, line-or-label]` : Call a subroutine when sprites collide. Type 1 only;
|
||||
omitting the target disarms it. The handler is entered between source lines and must end in
|
||||
`RETURN`, exactly as a `GOSUB` body does. Types 2 (sprite-to-background) and 3 (light pen)
|
||||
are refused by name
|
||||
* `REM` : everything after this is a comment
|
||||
* `DATA LITERAL[, ...]`: Define a series of literal values that can be read by a preceding `READ` verb
|
||||
* `DEF FN(X, ...) = expression` : Define a function with arguments that performs a given expression. See also "Subroutines", below.
|
||||
* `DELETE [n-n]`: Delete some portion of the lines in the current program
|
||||
* `DELETE`: Delete ALL lines in the program
|
||||
* `DELETE n-n`: Delete lines between `n` and `n` (inclusive)
|
||||
* `DELETE -n`: Delete lines from 0 to `n`
|
||||
* `DELETE n`: Delete lines from `n` to the end of the program
|
||||
* `DLOAD FILENAME`: Load the BASIC program in the file FILENAME (string literal or string variable) into memory
|
||||
* `DSAVE FILENAME`: Save the current BASIC program in memory to the file specified by FILENAME (string literal or string variable)
|
||||
* `EXIT`: Exit a loop before it would normally finish
|
||||
* `FOR` : Iterate over a range of values and perform (statement) or block each time.
|
||||
|
||||
```basic
|
||||
10 FOR I# = 1 TO 5
|
||||
20 PRINT I#
|
||||
30 NEXT I#
|
||||
40 FOR J# = 1 TO 5 STEP 2
|
||||
50 PRINT J#
|
||||
60 NEXT J#
|
||||
```
|
||||
|
||||
```output
|
||||
1
|
||||
2
|
||||
3
|
||||
4
|
||||
5
|
||||
1
|
||||
3
|
||||
5
|
||||
```
|
||||
|
||||
* `CLR`: Drop every variable and function definition, keeping the program
|
||||
* `CONT`: Resume a program stopped by `STOP`, from where it stopped. Refused if nothing stopped
|
||||
* `GOTO n`: Go to line n in the program
|
||||
* `GOSUB n`: Go to line n in the program and return here when `RETURN` is found
|
||||
* `HELP`: Re-list the line the last error happened on
|
||||
* `IF (comparison) THEN (statement) [ELSE (statement)]` : Conditional branching. Everything after
|
||||
`THEN` on the line belongs to the condition, and everything after an `ELSE` belongs to the
|
||||
`ELSE` — the same scoping a C128 uses
|
||||
* `INPUT "PROMPT STRING" VARIABLE`: Read input from the user and store it in the named variable
|
||||
* `LABEL IDENTIFIER`: Place a label at the current line number. Labels are constant integer identifiers that can be used in expressions like variables (including GOTO) but which cannot be assigned to. Labels do not have a type suffix (`$`, `#` or `%`).
|
||||
* `LIST [n-n]`: List all or a portion of the lines in the current program, with the same range forms as `DELETE`
|
||||
* `NEW`: Erase the program *and* every variable, and take the line counter home
|
||||
* `POKE ADDRESS, VALUE`: Poke the single byte VALUE into the ADDRESS
|
||||
* `PRINT (expression)`
|
||||
* `QUIT` : Exit the interpreter
|
||||
* `READ IDENTIFIER[, ...]` : Fill the named variables with data from a subsequent DATA statement
|
||||
* `RETURN` : return from `GOSUB` to the point where it was called
|
||||
* `RUN [n]`: Run the program currently in memory, optionally starting at line `n`
|
||||
* `STOP`: Stop program execution at the current point. `CONT` resumes from there
|
||||
* `MOVSPR n, ...` : Move a sprite. Four forms, and the separators are what tell them apart:
|
||||
* `MOVSPR n, x, y` — put it at (x, y) in BASIC's 320x200 space
|
||||
* `MOVSPR n, +x, -y` — move it *by* that much; a signed coordinate is an offset
|
||||
* `MOVSPR n, d ; a` — move it `d` pixels along bearing `a`, in degrees clockwise from
|
||||
straight up. Distance first, then angle
|
||||
* `MOVSPR n, a # s` — set it moving along bearing `a` at speed `s` (0-15, 0 stops). Nothing
|
||||
moves on the statement itself: the sprite moves as the program runs, paced off the host's
|
||||
clock
|
||||
* `SPRCOLOR [c1] [, c2]` : Set the two colour registers every multicolour sprite shares. Either
|
||||
may be omitted, which leaves that register alone
|
||||
* `SPRITE n [,on] [,colour] [,priority] [,xexpand] [,yexpand] [,multicolour]` : Configure a
|
||||
sprite. Only the number is required, and an omitted argument leaves that attribute alone
|
||||
* `SPRSAV source, n` : Give sprite `n` an image. Three kinds of source:
|
||||
* `SPRSAV "ship.png", n` — an image file. Tried against the working directory first and then
|
||||
against the directory the running program was loaded from, so a program stored beside its
|
||||
art works from anywhere. The sprite takes the image's own size
|
||||
* `SPRSAV A$, n` — a region `SSHAPE` saved
|
||||
* `SPRSAV A#, n` — 63 elements of a DIMmed integer array, three bytes per row and twenty-one
|
||||
rows, most significant bit leftmost. This is the `DATA`-driven form; it takes an integer
|
||||
array rather than a string because a string here cannot hold a zero byte
|
||||
* `SWAP A, B`: Exchange two variables of the same type, arrays and their dimensions included
|
||||
* `TRON` / `TROFF`: Turn line tracing on and off. A traced program prints `[10][20]` inline
|
||||
ahead of each line, as a C128 does
|
||||
|
||||
Statements are separated by colons, so a line can hold several: `10 A# = 1 : PRINT A#`. An empty
|
||||
statement is legal, so a trailing colon and a run of them are both fine.
|
||||
|
||||
## Functions
|
||||
|
||||
The following functions are implemented
|
||||
|
||||
* `ABS(x#|x%)`: Return the absolute value of the float or integer argument
|
||||
* `ATN(x#|x%)`: Return the arctangent of the float or integer argument. Input and output are in radians.
|
||||
* `BUMP(1)`: Return a bitmask of which sprites have collided since the last call — bit 0 is sprite 1. **Reading clears it**, which is what makes "has anything hit me since I last looked" answerable at all. Type 1 (sprite-to-sprite) only
|
||||
* `CHR(x#)`: Return the character value of the UTF-8 unicode codepoint in x#. Returns as a string.
|
||||
* `COS(x#|x%)`: Return the cosine of the float or integer argument. Input and output are in radians.
|
||||
* `HEX(x#)`: Return the string representation of the integer number in x#
|
||||
* `INSTR(X$, Y$)`: Return the index of `Y$` within `X$` (-1 if not present)
|
||||
* `LEN(var$)`: Return the length of the object `var$` (either a string or an array)
|
||||
* `LEFT(X$, Y#)`: Return the leftmost Y# characters of the string in X$. Y# is clamped to LEN(X$).
|
||||
* `LOG(X#|X%)`: Return the natural logarithm of X#|X%
|
||||
* `MID(var$, start, length)` : Return a substring from `var$`
|
||||
* `MOD(x#, y#)`: Return the modulus of ( x / y). Only works on integers.
|
||||
* `PEEK(X)`: Return the value of the BYTE at the memory location of integer X and return it as an integer
|
||||
* `POINTER(X)`: Return the address in memory for the value of the variable identified in X. This is the direct integer, float or string value stored, it is not a reference to the internal variable structure.
|
||||
* `POINTERVAR(X)` : Return the address in memory of the variable X. This is the address of the internal `akbasic_Variable` structure, which includes additional metadata about the variable, in addition to the value. For a pointer directly to the value, use `POINTER`.
|
||||
* `RAD(X#|X%)`: Convert degrees to radians
|
||||
* `RSPCOLOR(F#)`: Return one of `SPRCOLOR`'s two shared registers — 1 or 2
|
||||
* `RSPPOS(N#, F#)`: Return sprite `N#`'s x (0), y (1) or speed (2)
|
||||
* `RSPRITE(N#, F#)`: Return one of sprite `N#`'s `SPRITE` settings, in `SPRITE`'s own argument order: enabled (0), colour (1), priority (2), x-expand (3), y-expand (4), multicolour (5)
|
||||
* `RIGHT(X$, Y#)`: Return the rightmost Y# characters of the string in X$. Y# is clamped to LEN(X$).
|
||||
* `SGN(X#)`: Returns the sign of X# (-1 for negative, 1 for positive, 0 if 0).
|
||||
* `SHL(X#, Y#)`: Returns the value of X# shifted left Y# bits
|
||||
* `SHR(X#, Y#)`: Returns the value of X# shifted right Y# bits
|
||||
* `SIN(X#|X%)`: Returns the sine of the float or integer argument. Input and output are radians.
|
||||
* `SPC(X#)`: Returns a string of X# spaces. This is included for compatibility, you can also use `(" " * X)` to multiply strings.
|
||||
* `STR(X#)`: Returns the string representation of X.
|
||||
* `TAN(X#|X%)`: Returns the tangent of the float or integer variable X. Input and output are in radians.
|
||||
* `VAL(X$)`: Returns the float value of the number in X$
|
||||
* `XOR(X#, Y#)`: Performs a bitwise exclusive OR on the two integer arguments
|
||||
|
||||
Unlike the Go version, none of these are bootstrapped by running a BASIC program of `DEF` statements through the interpreter at startup. A builtin's name, arity and handler are one row in the dispatch table in `src/verbs.c`, which means the interpreter no longer has to be running before the interpreter is ready.
|
||||
|
||||
## Subroutines
|
||||
|
||||
In addition to `DEF`, `GOTO` and `GOSUB`, this BASIC also implements subroutines that accept arguments, return a value, and can be called as functions. Example
|
||||
|
||||
```basic
|
||||
10 DEF ADDTWO(A#, B#)
|
||||
20 C# = A# + B#
|
||||
30 RETURN C#
|
||||
40 D# = ADDTWO(3, 5)
|
||||
50 PRINT D#
|
||||
```
|
||||
|
||||
```output
|
||||
8
|
||||
```
|
||||
|
||||
Subroutines must be defined before they are called. Subroutines share the global variable scope with the rest of the program.
|
||||
|
||||
# Embedding the interpreter
|
||||
|
||||
The whole point of the rewrite. `libakbasic` is a static library with a thin `src/main.c` driver on top; the REPL, argv handling and `QUIT` belong to the driver, not to the library. A host program links the library and keeps control.
|
||||
|
||||
Four rules the library holds to, because a game engine cannot tolerate a scripting language that breaks any of them:
|
||||
|
||||
* **Nothing terminates the process.** No `exit()`, no `abort()`, no `panic`. Errors come back as `akerr_ErrorContext *`. `FINISH_NORETURN` never appears in the library at all — only in a `main()`, which today means the driver's and the embedding example's.
|
||||
* **Nothing calls `malloc`.** Every object comes from a fixed pool inside `akbasic_Runtime`. Exhausting one is a diagnosable error, not a crash and not a slow leak.
|
||||
* **No file-scope mutable state.** Interpreter state lives in an `akbasic_Runtime` you own. Two of them in one process do not interfere.
|
||||
* **The host owns the loop.** `akbasic_runtime_run(rt, n)` executes at most `n` source lines and returns. A script with an infinite loop costs you `n` lines per frame and nothing else.
|
||||
|
||||
A complete, compiled, runnable example is in [`examples/embed.c`](examples/embed.c) — it is built by every build and registered as a test, so it cannot rot. The shape is:
|
||||
|
||||
```c wrap=hostvars
|
||||
#include <akerror.h>
|
||||
#include <akbasic/runtime.h>
|
||||
#include <akbasic/sink.h>
|
||||
|
||||
/*
|
||||
* The runtime carries every pool the interpreter owns -- a few megabytes -- so
|
||||
* it goes in static storage or inside your own game state, never on the stack.
|
||||
*/
|
||||
static akbasic_Runtime SCRIPT;
|
||||
|
||||
static const char *PROGRAM =
|
||||
"10 PRINT \"COUNTING:\"\n"
|
||||
"20 FOR I# = 1 TO 5\n"
|
||||
"30 PRINT I# * I#\n"
|
||||
"40 NEXT I#\n";
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *script_start(akbasic_TextSink *sink)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
PASS(errctx, akbasic_runtime_init(&SCRIPT, sink));
|
||||
PASS(errctx, akbasic_runtime_load(&SCRIPT, PROGRAM));
|
||||
PASS(errctx, akbasic_runtime_start(&SCRIPT, AKBASIC_MODE_RUN));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* Call this once per frame. Eight source lines, then back to your renderer. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *script_tick(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
if ( SCRIPT.mode == AKBASIC_MODE_QUIT ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
PASS(errctx, akbasic_runtime_run(&SCRIPT, 8));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
## Exchanging variables with the host
|
||||
|
||||
Yes — in both directions, for integers, floats and strings, using the same variable pool the
|
||||
script itself uses. There is no marshalling layer and no copy: the host and the script are
|
||||
looking at the same `akbasic_Value`.
|
||||
|
||||
`akbasic_runtime_global()` finds a variable by name and creates it if it does not exist. The
|
||||
type comes from the name's suffix, exactly as it does for BASIC code, so `HP#` is an integer
|
||||
and `NAME$` is a string. Every scalar is really a one-element array, which is why the subscript
|
||||
list is `{0}` with a count of 1.
|
||||
|
||||
```c wrap=hostvars
|
||||
/* host -> script, before the script starts */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *host_set_int(akbasic_Runtime *obj, const char *name, int64_t value)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akbasic_Variable *variable = NULL;
|
||||
int64_t subscript[1] = { 0 };
|
||||
|
||||
PASS(errctx, akbasic_runtime_global(obj, name, &variable));
|
||||
PASS(errctx, akbasic_variable_set_integer(variable, value, subscript, 1));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* script -> host, after it stops */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *host_get_int(akbasic_Runtime *obj, const char *name, int64_t *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akbasic_Variable *variable = NULL;
|
||||
akbasic_Value *value = NULL;
|
||||
int64_t subscript[1] = { 0 };
|
||||
|
||||
PASS(errctx, akbasic_runtime_global(obj, name, &variable));
|
||||
PASS(errctx, akbasic_variable_get_subscript(variable, subscript, 1, &value));
|
||||
FAIL_NONZERO_RETURN(errctx, (value->valuetype != AKBASIC_TYPE_INTEGER), AKBASIC_ERR_TYPE,
|
||||
"%s is not an integer", name);
|
||||
*dest = value->intval;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
Used like this, with `akbasic_variable_set_string` and `set_float` as the other two:
|
||||
|
||||
```c wrap=hostcalls
|
||||
CATCH(errctx, akbasic_runtime_load(&SCRIPT, PROGRAM));
|
||||
|
||||
CATCH(errctx, host_set_int(&SCRIPT, "HP#", 100)); /* seed */
|
||||
CATCH(errctx, host_set_int(&SCRIPT, "LEVEL#", 7));
|
||||
CATCH(errctx, host_set_string(&SCRIPT, "NAME$", "LINK"));
|
||||
|
||||
CATCH(errctx, akbasic_runtime_start(&SCRIPT, AKBASIC_MODE_RUN));
|
||||
CATCH(errctx, akbasic_runtime_run(&SCRIPT, 0));
|
||||
|
||||
CATCH(errctx, host_get_int(&SCRIPT, "SCORE#", &score)); /* collect */
|
||||
```
|
||||
|
||||
The full thing, including the string and float forms, is in
|
||||
[`examples/hostvars.c`](examples/hostvars.c). It is built and run by every build.
|
||||
|
||||
### Which scope it lands in, and why you should not care
|
||||
|
||||
`akbasic_runtime_global()` always resolves against the script's outermost scope, so seeding
|
||||
before `akbasic_runtime_start()`, reading after the script stops, and creating a variable while
|
||||
a script is suspended part-way through a bounded `akbasic_runtime_run()` all do the same,
|
||||
obvious thing.
|
||||
|
||||
That is worth one paragraph of history, because the obvious-looking alternative is a trap.
|
||||
`akbasic_environment_get()` resolves against `obj->environment` — whatever scope is *active* —
|
||||
and a suspended script is usually inside a `FOR` or `GOSUB` body. A variable created through it
|
||||
lands in that body and dies when the body pops: the script reads it correctly inside the loop
|
||||
and gets `0` the moment the loop ends, with nothing raised anywhere. Reaching for the root
|
||||
explicitly does not help either, because only the active environment auto-creates, so
|
||||
`akbasic_environment_get(root, "NEW#", &var)` with a child active hands back `NULL` and no
|
||||
error and an unchecked host dereferences it.
|
||||
|
||||
Both of those are still true of `akbasic_environment_get()`, which is the right behaviour for
|
||||
what the *interpreter* uses it for. They are simply not your problem: use
|
||||
`akbasic_runtime_global()`.
|
||||
|
||||
[`examples/hostvars.c`](examples/hostvars.c) shows the difference rather than describing it,
|
||||
and `tests/hostvars.c` asserts it — a global created from inside a `FOR` body, and from inside
|
||||
a `GOSUB`, and still readable by the script afterwards.
|
||||
|
||||
## Where the output goes
|
||||
|
||||
`PRINT` writes through an `akbasic_TextSink`, which is a record of function pointers plus whatever state you hang off `self`:
|
||||
|
||||
```c excerpt=include/akbasic/sink.h
|
||||
typedef struct akbasic_TextSink
|
||||
{
|
||||
void *self;
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*write)(struct akbasic_TextSink *self, const char *text);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*writeln)(struct akbasic_TextSink *self, const char *text);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*readline)(struct akbasic_TextSink *self, char *dest, size_t len, bool *eof);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*clear)(struct akbasic_TextSink *self);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*moveto)(struct akbasic_TextSink *self, int col, int row);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*window)(struct akbasic_TextSink *self, int left, int top, int right, int bottom);
|
||||
} akbasic_TextSink;
|
||||
```
|
||||
|
||||
`akbasic_sink_init_stdio()` ships with the library and is what the driver and the golden-file suite use. A game supplies its own and draws into a text layer. The interpreter never owns a window, a renderer or an event loop, and `readline` is expected to set `*eof` rather than block — that is how `INPUT` behaves sanely inside a frame.
|
||||
|
||||
`akbasic_sink_init_tee()` also ships with the library and composes two sinks into one: writes go to both, and `readline` comes from whichever of the two you name as the reader. That is how the SDL build puts `PRINT` in a window *and* on stdout without the interpreter carrying a second write. It needs no SDL, so a game can use it to log a script's output to a file while it draws.
|
||||
|
||||
## Error codes
|
||||
|
||||
The library reserves status values **512–767** with `libakerror`'s registry, under the owner string `"akbasic"`. `akbasic_runtime_init()` claims the range for you and is idempotent, so calling it twice is harmless and calling it after your own initialization is fine. If something else in the process already owns part of that band, `init` fails loudly rather than silently aliasing your error codes onto somebody else's.
|
||||
|
||||
For reference, the coordinated map across this dependency stack is `libakerror` 0–255, `libakgl` 256–260, and `akbasic` 512–767.
|
||||
|
||||
## Linking
|
||||
The interpreter is meant to end up *inside*
|
||||
[libakgl](https://source.starfort.tech/andrew/libakgl) as a scripting engine for game authors,
|
||||
and libakgl is C. Embedding a Go runtime in a C game is not a thing anybody should do to
|
||||
themselves.
|
||||
|
||||
The Go version was already written against static pools and explicit state structs — a fixed
|
||||
source table, a fixed variable pool, a 32-leaf ceiling per line — so it ports across almost
|
||||
directly. It reads like C that happens to be spelled in Go.
|
||||
|
||||
And the port is a good excuse to find out what the original actually does, as opposed to what it
|
||||
looks like it does. It found five defects nobody knew about.
|
||||
|
||||
## Design philosophy
|
||||
|
||||
A game engine cannot tolerate a scripting language that surprises it. Five rules follow from
|
||||
that, and between them they explain most of what looks unusual in this interpreter:
|
||||
|
||||
* **Nothing in the library terminates the process.** No `exit()`, no `abort()`, no `panic`.
|
||||
Errors come back as `akerr_ErrorContext *` for the host to handle. `FINISH_NORETURN` appears
|
||||
only in a `main()`.
|
||||
* **Nothing calls `malloc`.** Every object comes from a fixed pool inside `akbasic_Runtime`.
|
||||
Exhausting one is a diagnosable error, not a crash and not a slow leak.
|
||||
* **No file-scope mutable state.** Interpreter state lives in an `akbasic_Runtime` you own. Two
|
||||
of them in one process do not interfere.
|
||||
* **The host owns the loop.** `akbasic_runtime_run(rt, n)` executes at most `n` steps and
|
||||
returns. A script containing `10 GOTO 10` costs you `n` steps per frame and nothing else.
|
||||
* **Hardware is a record of function pointers.** Graphics, audio, input and sprites attach as
|
||||
backends, and any of them may be `NULL` — that is how a host withholds a capability, and how a
|
||||
host that renders some other way never links libakgl at all.
|
||||
|
||||
Nothing is silently ignored, either. A verb that needs a device it was not given, or a capability
|
||||
nothing underneath can supply, refuses by name and says why.
|
||||
|
||||
## Two ways to use it
|
||||
|
||||
**As a program.** `basic` is a REPL and a script runner, and the standalone driver owns the
|
||||
things a library has no business owning: argv, `QUIT`, and the window in the SDL build.
|
||||
[The guide](docs/README.md) is written for this reader.
|
||||
|
||||
**As a library.** A host links `akbasic::akbasic`, hands the interpreter a script, and steps it a
|
||||
frame at a time:
|
||||
|
||||
```cmake
|
||||
add_subdirectory(deps/akbasic EXCLUDE_FROM_ALL)
|
||||
target_link_libraries(YOUR_GAME PRIVATE akbasic::akbasic)
|
||||
```
|
||||
|
||||
`akbasic` links `akerror::akerror` and `akstdlib::akstdlib` publicly, so you inherit both. If your project already declares those targets, declare them *before* adding this one — the same rule that applies to `libakgl`.
|
||||
Host and script exchange variables through the same pool the script itself uses — no marshalling
|
||||
layer and no copy. `PRINT` goes through an `akbasic_TextSink` the host supplies, so a game draws
|
||||
BASIC output into its own text layer. [`examples/embed.c`](examples/embed.c) and
|
||||
[`examples/hostvars.c`](examples/hostvars.c) are complete and runnable, and both are built and
|
||||
run by every build, so they cannot rot. [Chapter 10](docs/10-embedding.md) walks through the API.
|
||||
|
||||
`libakgl` itself is **not** a dependency of the core library. `-DAKBASIC_WITH_AKGL=ON` builds two additional targets, and the split between them is the one that matters if you are embedding this:
|
||||
## What state it is in
|
||||
|
||||
| Target | What it is | Link it? |
|
||||
|---|---|---|
|
||||
| `akbasic` | The interpreter. No SDL, no window, nothing that terminates the process. | Always. |
|
||||
| `akbasic_akgl` | The akgl-backed text sink and the graphics, sound and input backends. Every one takes a renderer, a font or a device **you** already created. | If you want the interpreter drawing through your `libakgl` renderer. |
|
||||
| `akbasic_frontend` | The standalone program's host: it creates the window, opens the font, pumps SDL events and owns the frame loop. | **No.** You are the host. This exists so `basic` can be one. |
|
||||
Everything the Go version does, and by now a good deal more. All 41 `.bas` files of the
|
||||
reference's corpus produce the expected output, including error messages, each as a separate
|
||||
CTest case so a failure names the file.
|
||||
|
||||
Both are off by default, which is why the interpreter and its whole test suite build on a machine with no SDL.
|
||||
What is still missing, what is refused deliberately, and the eleven defects inherited from the Go
|
||||
version are catalogued in [`TODO.md`](TODO.md) and summarised for a BASIC programmer in
|
||||
[Chapter 13](docs/13-differences.md).
|
||||
|
||||
The graphics, sound, console and sprite verbs reach hardware through records of function pointers on the runtime — `akbasic_GraphicsBackend`, `akbasic_AudioBackend`, `akbasic_InputBackend` and `akbasic_SpriteBackend`, attached with `akbasic_runtime_set_devices()`. All four may be `NULL`, which is what a default build of the standalone driver gives them: a verb that needs a device it was not given raises rather than crashing, and a `PRINT`-only program never notices. An SDL build attaches all four. A host that renders some other way supplies its own records and never links `libakgl` at all.
|
||||
## Where everything else lives
|
||||
|
||||
**Sprites are real `libakgl` actors.** Each of BASIC's eight becomes a `SpriteSheet`, a `Sprite`, a `Character` and an `Actor` registered in `AKGL_REGISTRY_ACTOR`, so a host game sees a script's sprites alongside its own and can do anything to them it can do to an actor. `akbasic_sprite_akgl_render()` draws them; a host that never calls it gets a script whose sprite state is correct and whose sprites are never drawn. None of it goes through libakgl's sprite JSON — a Commodore sprite has no frame list, no animation speed and no state map — except for `SPRSAV "ship.png", n`, which resolves and loads through exactly the calls the JSON loader makes.
|
||||
| | |
|
||||
|---|---|
|
||||
| [`docs/`](docs/README.md) | The guide: thirteen chapters, the language then each hardware area then a reference section for every verb and function |
|
||||
| [`MAINTENANCE.md`](MAINTENANCE.md) | For contributors and maintainers: the documentation-example harness, the three test lists, mutation testing, error-code allocation, style |
|
||||
| [`TODO.md`](TODO.md) | Outstanding defects, with file, line and consequence |
|
||||
| [`tests/reference/README.md`](tests/reference/README.md) | Where the golden corpus came from, and the rule for changing it |
|
||||
|
||||
If you drive the interpreter without `akbasic_frontend_akgl_init()`, sprites need three things from you that `akgl_game_init()` would otherwise have done: `akgl_heap_init()`, `akgl_registry_init()`, and a `camera` pointing somewhere. `akgl_actor_render()` dereferences that global without checking it. `src/frontend_akgl.c` is the worked example.
|
||||
API documentation builds with `doxygen Doxyfile`, into `build/docs/html`.
|
||||
|
||||
Two things about those verbs that differ from a real C128, because both would otherwise surprise you. `PLAY` does not block — it queues its notes and `akbasic_runtime_step()` releases them against whatever time you last passed to `akbasic_runtime_settime()`, so the statement after a `PLAY` runs immediately. And `GETKEY` holds the program without blocking you: the step still returns, it simply does not advance until a key arrives.
|
||||
## Dependencies
|
||||
|
||||
## Limits
|
||||
Everything is a submodule; `git submodule update --init --recursive` gets all of it. There is
|
||||
nothing to install first.
|
||||
|
||||
Everything is bounded and pre-declared. The numbers are in `include/akbasic/types.h` and are the Go version's, plus three the Go version did not need because it called `make()`:
|
||||
|
||||
| Constant | Value | What it bounds |
|
||||
|---|---|---|
|
||||
| `AKBASIC_MAX_LEAVES` / `_TOKENS` | 32 | AST nodes and tokens per source line (~16 operations) |
|
||||
| `AKBASIC_MAX_VALUES` | 64 | Intermediate values per line |
|
||||
| `AKBASIC_MAX_VARIABLES` | 128 | Variables per scope |
|
||||
| `AKBASIC_MAX_SOURCE_LINES` | 9999 | Program length |
|
||||
| `AKBASIC_MAX_LINE_LENGTH` | 256 | Characters per line |
|
||||
| `AKBASIC_MAX_STRING_LENGTH` | 256 | Characters in a string value |
|
||||
| `AKBASIC_MAX_ENVIRONMENTS` | 32 | Nesting depth of `FOR` and `GOSUB` |
|
||||
| `AKBASIC_MAX_ARRAY_ELEMENTS` | 1024 | Elements in one array |
|
||||
| `AKBASIC_MAX_ARRAY_VALUES` | 4096 | Array elements across all variables |
|
||||
|
||||
Raising any of them costs BSS and nothing else. An `akbasic_Runtime` is presently **10.1MB**, and it is worth knowing where that goes before you reach for a knob: 4.1MB is the environment pool, 2.5MB the 9999-line source table, 2.3MB the function-definition pool and 1.2MB the array value pool. `AKBASIC_MAX_ENVIRONMENTS` is the expensive one — each environment carries its own token, leaf and value arrays — so halving it to 16 saves twice what halving the source table does.
|
||||
|
||||
# What Isn't Implemented / Isn't Working
|
||||
|
||||
## Defects inherited from the Go version
|
||||
|
||||
These are reproduced deliberately, not fixed. The Go version's test corpus is the acceptance suite, so a silent correction here is a behaviour change that would show up as a failing golden file. Each is catalogued in [`TODO.md`](TODO.md) section 6 with the file and line, and `tests/known_reference_defects.c` asserts the **correct** contract for six of them under `AKBASIC_KNOWN_FAILING_TESTS` — when one is fixed, CTest reports "unexpectedly passed", which is the cue to move it.
|
||||
|
||||
Five of them the port found; nobody knew about these before:
|
||||
|
||||
* **`1 - 2 - 3` computes `1 - 2`.** Subtraction stops after one operator where addition loops, so the rest of the line is abandoned in the token stream. This is the bad one — it is a *wrong answer*, not a refused one. `2 ^ 3 ^ 2` has the same shape.
|
||||
* **A negative literal cannot be passed to a builtin.** `ABS(-9)` is rejected with "function ABS takes 1 arguments, received 2", because the arity counter walks the same `.right` pointer a unary-minus leaf keeps its operand on. Assign to a variable first, as the Go version's own `sgn.bas` test quietly does.
|
||||
* **A comparison operator in a line's final column is dropped.** `A# =` produces one token, not two, because the scanner cannot peek past the end of the line and gives up without recording the operator.
|
||||
* **Hex literals do not work.** `0xff` lexes as `0x` followed by an identifier `ff`. The base-16 branch in the literal parser is unreachable, so the hex support the Go README implies has never existed.
|
||||
* **`PRINT$` is accepted as a variable name.** The "Reserved word in variable name" check compares the lexeme *including* its type suffix against the keyword table, so it never matches and never fires.
|
||||
|
||||
Plus six more the original already carried: a leading `0` selects base 8, so `PRINT 010` prints 8 and `PRINT 08` will not parse; `setBoolean` tags the value it builds `TYPE_STRING`; `stopWaiting` ignores its argument, so an inner block can clear an outer block's pending wait; `toString` on a variable has its emptiness test inverted; `EXIT` pops a loop without clearing that wait; and `mathPlus` mutates its left operand in place where every other operator clones. That last one is load-bearing rather than merely wrong — `NEXT` relies on it to advance the loop counter, so it cannot be fixed on its own.
|
||||
|
||||
## Not implemented
|
||||
|
||||
This list was checked against the dispatch table in `src/verbs.c` when it was last edited. It
|
||||
had drifted badly before that -- it named a third of the language as missing after it had
|
||||
landed -- so check it the same way rather than trusting it.
|
||||
|
||||
* `APPEND`, `BACKUP`, `BLOAD`, `BOOT`, `BSAVE`
|
||||
* `BEGIN`, `BEND`, `DO`, `LOOP`, `WHILE`, `UNTIL`, `END`, `ON`. You can do the same thing with `IF` and `GOTO`.
|
||||
* `CATALOG`, `CHAR`, `CLOSE`, `CMD`, `COLLECT`, `CONCAT`, `COPY`
|
||||
* `DCLEAR`, `DCLOSE`, `DIRECTORY`, `DOPEN`, `DVERIFY`
|
||||
* `ER`, `ERR`, `TRAP`, `RESUME`
|
||||
* `FETCH`, `STASH`, `SYS`
|
||||
* `HEADER`, `KEY`, `LOAD`, `SAVE`, `SCRATCH`, `RENAME`, `VERIFY`
|
||||
* `PUDEF`, `RENUMBER`, `RESTORE`
|
||||
* `SLEEP`, `TI`, `WAIT`, `WIDTH`, `WINDOW`, `USING`
|
||||
* `SPRDEF`
|
||||
* The I/O-channel variants (`GETIO`, `INPUTIO`, `OPENIO`, `PRINTIO`, `RECORDIO`)
|
||||
|
||||
`FILTER` parses and is then refused, which is a third state and deliberate. It sets the SID's
|
||||
filter cutoff, band switches and resonance; `akgl_audio_*` synthesises raw waveforms and mixes
|
||||
them, there is no filter stage to configure, and SDL3 supplies no primitive to build one from.
|
||||
Until an `akgl_audio_filter()` exists it refuses at execution rather than being silently
|
||||
ignored -- a program that asks for a low-pass and gets an unfiltered square wave has been lied
|
||||
to.
|
||||
|
||||
It is the only verb still blocked on a missing `libakgl` capability. Four others were -- text
|
||||
measurement, immediate-mode drawing, audio, and a non-blocking keystroke read -- and rather
|
||||
than work around them here they were filed in
|
||||
[`deps/libakgl/TODO.md`](deps/libakgl/TODO.md) under "API gaps blocking akbasic". All four have
|
||||
since landed upstream (`akgl_text_measure`, the `akgl_draw_*` family, `akgl_audio_*`, and
|
||||
`akgl_controller_poll_key`), so what is left is akbasic-side work.
|
||||
|
||||
Building the sprite verbs turned up three more, none of which blocks a verb. An actor is drawn
|
||||
square regardless of its sprite's height (libakgl's own defect 26) and an actor cannot be scaled
|
||||
per axis (filed from here); both are worked around by installing a `renderfunc` of ours on each
|
||||
actor. The third — an error context leaked per relative asset path resolved — was found and
|
||||
fixed upstream in 0.4.0 while this was being written.
|
||||
|
||||
## Deliberately out of scope
|
||||
|
||||
* `BANK` - the modern PC memory layout is incompatible with the idea of bank switching
|
||||
* `FAST` - Irrelevant on modern PC CPUs
|
||||
* `MONITOR` - there is no machine-language monitor to drop into
|
||||
* `SPRDEF` - an interactive full-screen sprite editor driven by single keystrokes, not a
|
||||
programmable verb. A program cannot call it usefully and this interpreter does not own the
|
||||
screen it would take over. `SPRSAV`'s three source forms are what replace it
|
||||
|
||||
# Dependencies
|
||||
|
||||
* [libakerror](https://source.starfort.tech/andrew/libakerror) 1.0.0 — TRY/CATCH-style error contexts. Every function that can fail returns one.
|
||||
* [libakstdlib](https://source.starfort.tech/andrew/libakstdlib) 0.2.0 — libc wrappers that report through `libakerror`.
|
||||
* [libakgl](https://source.starfort.tech/andrew/libakgl) 0.4.0 — **optional**, only for `-DAKBASIC_WITH_AKGL=ON`. Pulls in SDL3. 0.3.0 closed every API gap this project had filed against it; 0.4.0 is a leak-and-overread release. It changed no public struct, but the soname carries `MAJOR.MINOR` while the major is 0, so rebuild rather than relink.
|
||||
* [basicinterpret](https://source.starfort.tech/andrew/basicinterpret) — the Go original. Vendored as the behavioural spec for questions about semantics, and for nothing else: its acceptance corpus and its Commodore font are checked in here now, so **the build and the test suite do not need it**. Not linked, not built, and safe to omit.
|
||||
|
||||
Everything is a submodule; `git submodule update --init --recursive` gets all of it. There is nothing to install first.
|
||||
|
||||
String-to-number conversion goes straight to `libakstdlib`, which raises on no digits, on trailing junk and on overflow — so `VAL("garbage")` is an error rather than a silent `0`. This repository carried its own `src/convert.c` wrapper until `libakstdlib` 0.2.0 grew that contract; `tests/numeric_contract.c` now pins it from this side, because a regression in it would make four things quietly return zero.
|
||||
* [libakerror](https://source.starfort.tech/andrew/libakerror) 1.0.0 — TRY/CATCH-style error
|
||||
contexts. Every function that can fail returns one.
|
||||
* [libakstdlib](https://source.starfort.tech/andrew/libakstdlib) 0.2.0 — libc wrappers that
|
||||
report through `libakerror`. String-to-number conversion goes straight to it, which is why
|
||||
`VAL("garbage")` is an error rather than a silent `0`.
|
||||
* [libakgl](https://source.starfort.tech/andrew/libakgl) 0.4.0 — **optional**, only for
|
||||
`-DAKBASIC_WITH_AKGL=ON`. Pulls in SDL3. Its soname carries `MAJOR.MINOR` while the major is 0,
|
||||
so rebuild rather than relink.
|
||||
* [basicinterpret](https://source.starfort.tech/andrew/basicinterpret) — the Go original.
|
||||
Not linked, not built, and safe to omit.
|
||||
|
||||
11
TODO.md
11
TODO.md
@@ -39,8 +39,8 @@ What it does **not** change:
|
||||
|
||||
**Read these before touching anything**, in this order:
|
||||
|
||||
1. `CLAUDE.md` in this repository — project goals, the `libakerror` convention, the error-code
|
||||
range map, and the dependency versions.
|
||||
1. `MAINTENANCE.md` in this repository — project goals, the `libakerror` convention, the
|
||||
error-code range map, and the dependency versions.
|
||||
2. `deps/libakerror/AGENTS.md` — the `ATTEMPT`/`CLEANUP`/`PROCESS`/`HANDLE`/`FINISH` protocol.
|
||||
3. `deps/libakerror/UPGRADING.md` — 1.0.0's status registry. Required before writing an error
|
||||
code; the mechanism it replaced is gone.
|
||||
@@ -213,7 +213,7 @@ output.**
|
||||
### 1.7 Error codes are absolute, reserved from the 1.0.0 registry
|
||||
|
||||
`deps/libakerror` is at **1.0.0**. Read `deps/libakerror/UPGRADING.md` before writing an error
|
||||
code; `CLAUDE.md`'s error-code section is the condensed version. Three things this changes
|
||||
code; `MAINTENANCE.md`'s error-code section is the condensed version. Three things this changes
|
||||
from what you may have seen in an older draft or in `libakgl`:
|
||||
|
||||
- `AKERR_MAX_ERR_VALUE` **no longer exists**. The name registry is sparse and takes any `int`.
|
||||
@@ -221,9 +221,10 @@ from what you may have seen in an older draft or in `libakgl`:
|
||||
one, delete it.
|
||||
- `__AKERR_ERROR_NAMES` **no longer exists**. The table is private.
|
||||
- Codes are **absolute integer constants at 256 or above**, never `AKERR_LAST_ERRNO_VALUE + N`.
|
||||
The offset scheme is what produced the live `libakgl` collision documented in `CLAUDE.md`.
|
||||
The offset scheme is what produced the live `libakgl` collision documented in
|
||||
`MAINTENANCE.md`.
|
||||
|
||||
`akbasic` owns **512–767** per the range map in `CLAUDE.md`. Declare it as an `enum`, so the
|
||||
`akbasic` owns **512–767** per the range map in `MAINTENANCE.md`. Declare it as an `enum`, so the
|
||||
values stay compile-time integer constants (`HANDLE` expands to `case` labels, which require
|
||||
that) and adding a code does not mean renumbering an offset:
|
||||
|
||||
|
||||
@@ -78,6 +78,33 @@ bounded run is usually inside a `FOR` or `GOSUB` body, and a variable created th
|
||||
dies when the body pops — silently, with the script reading it correctly right up until
|
||||
it stops.
|
||||
|
||||
## Where the output goes
|
||||
|
||||
`PRINT` writes through an `akbasic_TextSink`, which is a record of function pointers plus
|
||||
whatever state you hang off `self`:
|
||||
|
||||
```c excerpt=include/akbasic/sink.h
|
||||
typedef struct akbasic_TextSink
|
||||
{
|
||||
void *self;
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*write)(struct akbasic_TextSink *self, const char *text);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*writeln)(struct akbasic_TextSink *self, const char *text);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*readline)(struct akbasic_TextSink *self, char *dest, size_t len, bool *eof);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*clear)(struct akbasic_TextSink *self);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*moveto)(struct akbasic_TextSink *self, int col, int row);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*window)(struct akbasic_TextSink *self, int left, int top, int right, int bottom);
|
||||
} akbasic_TextSink;
|
||||
```
|
||||
|
||||
`akbasic_sink_init_stdio()` ships with the library and is what the driver uses. A game
|
||||
supplies its own and draws into a text layer. `readline` is expected to set `*eof` rather
|
||||
than block — that is how `INPUT` behaves sanely inside a frame.
|
||||
|
||||
`akbasic_sink_init_tee()` also ships, and composes two sinks into one: writes go to both,
|
||||
and `readline` comes from whichever of the two you name as the reader. That is how the SDL
|
||||
build puts `PRINT` in a window *and* on stdout. It needs no SDL, so you can use it to log a
|
||||
script's output to a file while you draw.
|
||||
|
||||
## Lending devices
|
||||
|
||||
```c wrap=hostbody
|
||||
@@ -109,5 +136,8 @@ program keeps running.
|
||||
|
||||
## Reading it all
|
||||
|
||||
`README.md` in the repository root carries the full API surface, the pool limits, and
|
||||
the exact list of what each target pulls in.
|
||||
Two complete hosts are checked in and built by every build, so neither can rot:
|
||||
`examples/embed.c` runs a script a bounded number of steps at a time, and
|
||||
`examples/hostvars.c` passes integers, floats and strings in both directions. The full API
|
||||
surface is the headers under `include/akbasic/`, which `doxygen Doxyfile` renders; the pool
|
||||
limits are the table at the end of [Chapter 13](13-differences.md).
|
||||
|
||||
@@ -26,9 +26,9 @@
|
||||
/*
|
||||
* libakerror reserves 0-255 for the host's errno values and its own AKERR_*
|
||||
* codes. libakgl claims 256-260. akbasic claims 512-767, leaving 261-511 as
|
||||
* headroom for libakgl to grow into -- see CLAUDE.md for the coordinated range
|
||||
* map, which is the only coordination there is: libakerror can enumerate its
|
||||
* consumers no better than we can.
|
||||
* headroom for libakgl to grow into -- see MAINTENANCE.md for the coordinated
|
||||
* range map, which is the only coordination there is: libakerror can enumerate
|
||||
* its consumers no better than we can.
|
||||
*
|
||||
* These are absolute constants, never offsets from AKERR_LAST_ERRNO_VALUE, so a
|
||||
* libc that grows an errno cannot move them. An enum rather than a chain of
|
||||
|
||||
@@ -389,7 +389,7 @@ akerr_ErrorContext *akbasic_cmd_directory(akbasic_Runtime *obj, akbasic_ASTLeaf
|
||||
* Refused rather than half-built. Listing a directory needs opendir/readdir,
|
||||
* which `libakstdlib` does not wrap -- and this project's rule is that a
|
||||
* missing capability gets filed upstream rather than worked around here
|
||||
* (CLAUDE.md). Filed in deps/libakstdlib/TODO.md.
|
||||
* (MAINTENANCE.md). Filed in deps/libakstdlib/TODO.md.
|
||||
*
|
||||
* The alternative was shelling out to `ls`, which a library has no business
|
||||
* doing, or calling readdir directly and stepping outside the error
|
||||
|
||||
Reference in New Issue
Block a user