94.6% line, 98.6% function, measured from a coverage tree outside the source directory. The akgl targets are not in that figure and the entry now says so, because a reader of the table would otherwise read it as untested. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1201 lines
76 KiB
Markdown
1201 lines
76 KiB
Markdown
# TODO
|
||
|
||
Implementation plan for the Go → C port of `deps/basicinterpret`.
|
||
|
||
This file is written to be executed by AI agents, not read by humans for inspiration. Every
|
||
item names the files it touches, the exact deliverable, and the command that proves it done.
|
||
Work the phases in order. Inside a phase, items with no `Depends:` line may be done in
|
||
parallel.
|
||
|
||
---
|
||
|
||
## 0. Agent protocol
|
||
|
||
**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.
|
||
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.
|
||
4. `deps/libakstdlib/TODO.md` §2.1 — six **confirmed** defects in the library this port calls
|
||
into. §1.9 below says which calls are cleared for use; that section is not optional
|
||
reading, it bans a family of functions the port would otherwise reach for by reflex.
|
||
5. `deps/libakgl/AGENTS.md` — the no-`malloc` rule and the commit co-author requirement.
|
||
6. `deps/basicinterpret/README.md` — the language reference and the unimplemented list.
|
||
|
||
**Rules for working this file:**
|
||
|
||
- **Do not** mark an item done until its acceptance command passes on a clean out-of-tree
|
||
build. "It compiles" is not acceptance.
|
||
- **Do** update this file in the same commit as the work: strike the item, or replace it with
|
||
the defect it uncovered. This file holds outstanding items only.
|
||
- **Do** add the agent program name, model name and version as a commit co-author. That rule
|
||
comes from `libakgl` and applies here.
|
||
- **Never** hand-edit generated output. `build/` trees and the generated `akerror.h` are
|
||
off-limits; change the generator.
|
||
- **Never** reformat a file you are not otherwise changing.
|
||
- When a step is blocked because `libakgl` cannot supply a capability, **do not work around
|
||
it here.** File it in `deps/libakgl/TODO.md` in the numbered prose style of that file's
|
||
"Carried over" section and note the block in §7 below.
|
||
|
||
**Style**, restated so nobody has to go look: C99, 4-space indent, tabs at width 8
|
||
(`stroustrup`), function-body braces in column 0 on their own line, control braces on the same
|
||
line, **always brace**, spaces inside control-flow parens — `if ( x == y ) {`. Pointer star
|
||
binds to the identifier: `char *name`. Prefix is `akbasic_` for functions,
|
||
`akbasic_TypeName` for types, `AKBASIC_UPPER_SNAKE` for macros. `static` helpers drop the
|
||
prefix. Parameter names must match between header and source.
|
||
|
||
---
|
||
|
||
## 1. Design decisions already made
|
||
|
||
These are settled. Do not relitigate them mid-port; if evidence says one is wrong, say so in
|
||
a commit that changes it deliberately, and update this section.
|
||
|
||
### 1.1 Reflection becomes one aligned dispatch table
|
||
|
||
The Go runtime resolves verbs with `reflect.MethodByName("Command" + NAME)`
|
||
(`basicruntime.go:401`), functions with `"Function" + NAME`, and special parse paths with
|
||
`"ParseCommand" + NAME` (`basicparser.go:103`). C has no reflection and we are not adding
|
||
any. Replace all three with **one** static table in `src/verbs.c`, sorted by name, searched
|
||
with `bsearch(3)`:
|
||
|
||
```c
|
||
/* name token type parse handler exec handler */
|
||
{ "AUTO", AKBASIC_TOK_CMDIMM, NULL, cmd_auto },
|
||
{ "DATA", AKBASIC_TOK_COMMAND, parse_data, cmd_data },
|
||
{ "DEF", AKBASIC_TOK_COMMAND, parse_def, cmd_def },
|
||
```
|
||
|
||
A `NULL` parse handler means "parse the rval as a plain expression", which is exactly what
|
||
`commandByReflection` returning `(nil, nil)` means today. Adding a verb is adding one row plus
|
||
two functions. **Keep the table column-aligned and one row per verb** — it is a table, so it
|
||
gets laid out as one.
|
||
|
||
This also kills the Go scanner's three separate maps (`reservedwords`, `commands`,
|
||
`functions`, `basicscanner.go:64-67`): the token type lives in the same row.
|
||
|
||
### 1.2 Strings are fixed-size and live inline
|
||
|
||
`libakstdlib` has no string type. `libakgl` has `akgl_String` but it is `PATH_MAX` bytes,
|
||
refcounted, and pool-allocated — wrong shape for a value that gets copied on every
|
||
assignment, and it would drag a `libakgl` dependency into the core interpreter.
|
||
|
||
Define in `include/akbasic/types.h`:
|
||
|
||
```c
|
||
#define AKBASIC_MAX_STRING_LENGTH 256 /* matches AKBASIC_MAX_LINE_LENGTH */
|
||
```
|
||
|
||
and give `akbasic_Value` a `char stringval[AKBASIC_MAX_STRING_LENGTH]` **inline**. `clone()`
|
||
becomes a struct assignment. No allocator, no refcount, no lifetime question.
|
||
|
||
**Tradeoff, stated:** every `akbasic_Value` is ~300 bytes, so one environment's
|
||
`values[AKBASIC_MAX_VALUES]` pool is ~19KB, and 32 environments is ~610KB of BSS. That is
|
||
fine on a PC and is the price of never calling `malloc`. If it ever isn't fine, the knob is
|
||
`AKBASIC_MAX_STRING_LENGTH`, not the allocator.
|
||
|
||
Truncation is an **error**, not a silent clamp: `FAIL_RETURN(e, AKBASIC_ERR_VALUE, ...)`.
|
||
|
||
### 1.3 Maps become fixed-capacity open-addressed tables
|
||
|
||
Five Go maps need replacing:
|
||
|
||
| Go site | Purpose | C replacement |
|
||
|---|---|---|
|
||
| `BasicScanner.reservedwords/commands/functions` | keyword → token type | the §1.1 static table + `bsearch` |
|
||
| `BasicEnvironment.variables` | name → `*BasicVariable` | `akbasic_SymbolTable`, capacity `AKBASIC_MAX_VARIABLES` |
|
||
| `BasicEnvironment.functions` | name → `*BasicFunctionDef` | `akbasic_SymbolTable`, capacity `AKBASIC_MAX_FUNCTIONS` |
|
||
| `BasicEnvironment.labels` | name → line number | `akbasic_SymbolTable`, capacity `AKBASIC_MAX_LABELS` |
|
||
|
||
One implementation, `src/symtab.c`, keyed by `aksl_strhash_djb2()` (already in
|
||
`libakstdlib`) with linear probing and a fixed slot array. **Use the existing hash; do not
|
||
write another one.** Table full is an error, not a resize.
|
||
|
||
Caveat, from `deps/libakstdlib/TODO.md` §1.6/§2.2.6: the wrapper sign-extends `char`, so a
|
||
high-bit byte hashes wrong — `"\xff\xfe"` returns 5859874 where the `unsigned char` answer is
|
||
5868578. BASIC identifiers are 7-bit ASCII (the scanner only accepts `IsLetter`/`IsDigit` plus
|
||
a type suffix), so this cannot bite the symbol tables. It **would** bite if anyone later keys
|
||
a table on a string literal or a filename. Do not work around it here; it is already filed
|
||
upstream.
|
||
|
||
### 1.4 Environments come from a pool and are released
|
||
|
||
Go calls `new(BasicEnvironment)` at `basicruntime.go:121` and `basicparser_commands.go:124`
|
||
and never frees one. A long-running `GOSUB` or `FOR` in Go leaks; the GC eventually catches
|
||
some of it, and nothing in the tests notices.
|
||
|
||
C gets `HEAP_ENVIRONMENT[AKBASIC_MAX_ENVIRONMENTS]` with
|
||
`akbasic_env_acquire()` / `akbasic_env_release()`, in the shape of `akgl_heap_next_*`.
|
||
`akbasic_runtime_prev_environment()` **must** release the environment it pops. Pool
|
||
exhaustion is `AKBASIC_ERR_ENVIRONMENT`, reported with the current line number.
|
||
|
||
Watch the one place this is not a clean stack: `userFunction` (`basicruntime.go:348`) stores
|
||
a `BasicEnvironment` **by value** inside `BasicFunctionDef` and re-`init()`s it on every call.
|
||
In C the funcdef holds an `akbasic_Environment *` acquired at `DEF` time and reset per call —
|
||
it is owned by the funcdef, not the pool's free list, until the funcdef dies.
|
||
|
||
### 1.5 Output goes through a text sink backend
|
||
|
||
`Write()` and `Println()` (`basicruntime_graphics.go:140,148`) mirror every line to stdout
|
||
*and* to an SDL surface. That mirror is the only reason the golden-file suite works. Do not
|
||
reproduce it as a hardcoded pair of calls.
|
||
|
||
Define a record of function pointers, populated by an initializer — the house pattern:
|
||
|
||
```c
|
||
typedef struct akbasic_TextSink
|
||
{
|
||
void *self;
|
||
akerr_ErrorContext AKERR_NOIGNORE *(*write)(struct akbasic_TextSink *self, char *text);
|
||
akerr_ErrorContext AKERR_NOIGNORE *(*writeln)(struct akbasic_TextSink *self, char *text);
|
||
akerr_ErrorContext AKERR_NOIGNORE *(*readline)(struct akbasic_TextSink *self, char *dest, size_t len);
|
||
akerr_ErrorContext AKERR_NOIGNORE *(*clear)(struct akbasic_TextSink *self);
|
||
} akbasic_TextSink;
|
||
```
|
||
|
||
`akbasic_sink_init_stdio()` is in the library and `akbasic_sink_init_akgl()` is in the
|
||
akgl-backed module. The driver is supposed to pick: a default build selects stdio, while an
|
||
`AKBASIC_WITH_AKGL` build selects the SDL text path and mirrors its output to stdout. It does
|
||
not do that yet; §3 records the missing standalone frontend. Cursor arithmetic, wrapping and
|
||
scrolling belong to the akgl sink, not to the interpreter.
|
||
|
||
### 1.6 The interpreter steps; it does not run
|
||
|
||
Go's `run()` (`basicruntime.go:682`) is a `for {}` that owns the process until `MODE_QUIT`.
|
||
Goal 3 forbids that: a host game must be able to bound execution.
|
||
|
||
The library exposes:
|
||
|
||
```c
|
||
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_step(akbasic_Runtime *obj);
|
||
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_run(akbasic_Runtime *obj, int maxsteps);
|
||
```
|
||
|
||
`_step()` does exactly what one iteration of Go's `for {}` body does and returns.
|
||
`_run(obj, maxsteps)` loops `_step()` until the mode is `AKBASIC_MODE_QUIT` or `maxsteps`
|
||
steps have elapsed; `maxsteps <= 0` means unbounded, which is what the standalone driver
|
||
passes. **This is a deliberate restructure, and it must not change a single byte of golden
|
||
output.**
|
||
|
||
`QUIT` sets `AKBASIC_MODE_QUIT` and returns. **Nothing in the library calls `exit()`,
|
||
`abort()`, or `FINISH_NORETURN`.** `FINISH_NORETURN` appears exactly once in the tree, in
|
||
`src/main.c`.
|
||
|
||
### 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
|
||
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`.
|
||
There is nothing for a consumer to size, and no compile definition to set. Anywhere you find
|
||
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`.
|
||
|
||
`akbasic` owns **512–767** per the range map in `CLAUDE.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:
|
||
|
||
```c
|
||
#define AKBASIC_OWNER "akbasic"
|
||
|
||
enum {
|
||
AKBASIC_ERR_BASE = 512, /** Start of akbasic's reserved status range */
|
||
AKBASIC_ERR_SYNTAX = AKBASIC_ERR_BASE,
|
||
/** Parse-time grammar violation */
|
||
AKBASIC_ERR_TYPE, /** Incompatible types in an operation */
|
||
AKBASIC_ERR_UNDEFINED, /** Reference to an undefined verb, function or label */
|
||
AKBASIC_ERR_BOUNDS, /** Array subscript or pool index out of range */
|
||
AKBASIC_ERR_ENVIRONMENT, /** Environment pool exhausted or orphaned environment */
|
||
AKBASIC_ERR_VALUE, /** A value was malformed, truncated or unconvertible */
|
||
AKBASIC_ERR_STATE, /** A verb ran outside the block structure it requires */
|
||
AKBASIC_ERR_LIMIT = AKBASIC_ERR_BASE + 256
|
||
};
|
||
```
|
||
|
||
`akbasic_init()` reserves the **whole** 256 in one call and then names each code. Both
|
||
registry calls return `akerr_ErrorContext *` and are `AKERR_NOIGNORE`, so a collision is an
|
||
ordinary error — `PASS` it and let it propagate out of init:
|
||
|
||
```c
|
||
akerr_ErrorContext AKERR_NOIGNORE *akbasic_init(void)
|
||
{
|
||
PREPARE_ERROR(errctx);
|
||
|
||
PASS(errctx, akerr_reserve_status_range(AKBASIC_ERR_BASE,
|
||
AKBASIC_ERR_LIMIT - AKBASIC_ERR_BASE,
|
||
AKBASIC_OWNER));
|
||
PASS(errctx, akerr_register_status_name(AKBASIC_OWNER, AKBASIC_ERR_SYNTAX,
|
||
"Syntax Error"));
|
||
/* ... one per code ... */
|
||
SUCCEED_RETURN(errctx);
|
||
}
|
||
```
|
||
|
||
Reserve the whole range in **one** call — a subset or superset of your own range raises
|
||
`AKERR_STATUS_RANGE_OVERLAP`, not a no-op. Use `akerr_register_status_name()`, never the
|
||
two-argument `akerr_name_for_status(status, name)` set path: the owned form is the one that
|
||
catches a component writing into a range that is not its own, and the two-argument form is
|
||
what let `libakgl` silently clobber `libakerror`'s names.
|
||
|
||
There is no startup ceiling to assert any more — the BSS-overflow hazard the old draft
|
||
defended against was deleted along with `AKERR_MAX_ERR_VALUE`. What replaces it is the
|
||
reservation itself: if `akbasic_init()` returns an error, something else owns part of 512–767
|
||
and the process must not continue as though it does not.
|
||
|
||
Do not call `akerr_init()` first. Every registry entry point calls it, and since 1.0.0 it no
|
||
longer clears reservations made before it ran.
|
||
|
||
### 1.8 Error message text is part of the acceptance contract
|
||
|
||
`tests/language/array_outofbounds.txt` is, verbatim:
|
||
|
||
```
|
||
? 20 : RUNTIME ERROR Variable index access out of bounds at dimension 0: 4 (max 2)\n\n
|
||
```
|
||
|
||
The trailing double newline is real: `basicError` builds a string ending in `\n` and hands it
|
||
to `Println`, which adds another. **Reproduce the message strings and the newline behaviour
|
||
byte for byte** or the golden suite fails for reasons that have nothing to do with the
|
||
interpreter. Where a Go message reads awkwardly, keep it awkward and note it here.
|
||
|
||
Numeric formatting must match too: integers via `%" PRId64 "`, floats via `%f` (Go's `%f` and
|
||
C's `%f` both give six decimals — `tests/language/arithmetic/float.txt` confirms).
|
||
|
||
### 1.9 Which `libakstdlib` calls are cleared for use — **the bans are lifted**
|
||
|
||
`deps/libakstdlib` is at **0.2.0**, and that release *fixed all six* of the confirmed defects
|
||
this section was built around, plus seventeen contract gaps. The table of bans that used to
|
||
live here is gone because it is no longer true, and leaving it would send an agent around a
|
||
workaround for a function that now works.
|
||
|
||
What changed, in the four that mattered to this port:
|
||
|
||
| Was banned | Now |
|
||
|---|---|
|
||
| `aksl_atoi` / `_atol` / `_atoll` / `_atof` | Every one takes a `dest` pointer and raises `AKERR_VALUE` on no digits or trailing junk, `ERANGE` on overflow — exactly the contract this section demanded |
|
||
| `aksl_list_append` / `_iterate` | Append no longer truncates to two nodes; iterate no longer skips the first half |
|
||
| `aksl_tree_iterate` | `AKERR_ITERATOR_BREAK` stops the traversal |
|
||
| `aksl_realpath` | Rewritten; no longer reads uninitialised memory on its error path |
|
||
|
||
Two signature changes reach this repository. `aksl_fread` and `aksl_fwrite` now take a
|
||
required `size_t *nmemb_out` and report a short transfer as `AKERR_IO` instead of a silent
|
||
success — so a `DSAVE` onto a full disk is now an error a program sees, where before it
|
||
reported nothing. `src/runtime_commands.c` passes the count and discards it, which is correct:
|
||
the library does the noticing now.
|
||
|
||
**`src/convert.c` is gone.** It existed only because `aksl_ato*` could not report a conversion
|
||
failure, and its own note said what to do when that changed: *"When it grows it, delete
|
||
`src/convert.c` and switch the call sites over."* That is done. Six call sites now go straight
|
||
to the library — `newLiteralInt` and `newLiteralFloat` in `src/grammar.c`, the line number in
|
||
`src/scanner.c`, `FunctionVAL` in `src/runtime_functions.c`, `INPUT` in
|
||
`src/runtime_commands.c`, and `GSHAPE`'s handle in `src/runtime_graphics.c`.
|
||
|
||
Three things that fell out of it, recorded because each was checked rather than assumed:
|
||
|
||
- **`aksl_strtoll(str, NULL, base, &dest)` is the exact replacement**, not `aksl_atoll`, wherever
|
||
a base is involved. The `ato*` forms are base 10; `src/grammar.c` picks base 8 or 16 from the
|
||
lexeme's prefix. A `NULL` endptr is what makes trailing junk an error rather than a stopping
|
||
point, and that is the whole contract this port needs.
|
||
- **The raised status changed** from `AKBASIC_ERR_VALUE` to `AKERR_VALUE`, and the message text
|
||
with it — `VAL("garbage")` now says `no digits in "garbage"`. §1.8 makes message text part of
|
||
the acceptance contract, so this was checked against the corpus first: **no golden file
|
||
contained a conversion message**, which is also why §1.9 used to ask for one. Two now exist,
|
||
`tests/language/numeric/val_refuses_garbage` and `octal_literal`, so the next change to that
|
||
text has to move a golden file.
|
||
- **`tests/convert.c` became `tests/numeric_contract.c`.** The assertions did not become
|
||
worthless when the wrapper went away — they became assertions about a contract this port
|
||
depends on and no longer owns, which is worth pinning exactly because a regression in it would
|
||
make `VAL("garbage")` return 0 silently. Same reasoning `libakstdlib`'s own
|
||
`tests/test_status_registry.c` gives for pinning that it reserves no status range.
|
||
|
||
One caveat survives the upgrade unchanged: `aksl_strhash_djb2` still sign-extends `char`, so a
|
||
high-bit byte hashes differently from the `unsigned char` answer. BASIC identifiers are 7-bit
|
||
ASCII so the symbol tables cannot reach it — see §1.3, which is still accurate.
|
||
|
||
---
|
||
|
||
## 2. What exists — **the core port is complete and green**
|
||
|
||
Phases 0 through 6 of the original plan are done. The interpreter builds clean under
|
||
`-Wall -Wextra`, reproduces the reference byte for byte, and passes under ASan and UBSan.
|
||
|
||
```sh
|
||
cmake -S . -B build && cmake --build build --parallel
|
||
ctest --test-dir build --output-on-failure # 59/59
|
||
cmake -S . -B build-asan -DAKBASIC_SANITIZE=ON # 59/59
|
||
cmake -S . -B build-cov -DAKBASIC_COVERAGE=ON # 92.3% line, 96.9% function
|
||
```
|
||
|
||
| Module | Source | Reference |
|
||
|---|---|---|
|
||
| Symbol table | `src/symtab.c` | the five Go maps |
|
||
| Value | `src/value.c` | `basicvalue.go` |
|
||
| Variable | `src/variable.c` | `basicvariable.go` |
|
||
| Tokens and AST leaves | `src/grammar.c` | `basicgrammar.go` + `basicparser.go` |
|
||
| Dispatch table | `src/verbs.c` | the three reflection lookups |
|
||
| Scanner | `src/scanner.c` | `basicscanner.go` |
|
||
| Parser | `src/parser.c`, `src/parser_commands.c` | `basicparser*.go` |
|
||
| Environment | `src/environment.c` | `basicenvironment.go` |
|
||
| Runtime | `src/runtime.c` | `basicruntime.go` minus SDL |
|
||
| Verbs and functions | `src/runtime_commands.c`, `src/runtime_functions.c` | `basicruntime_{commands,functions}.go` |
|
||
| Text sink | `src/sink_stdio.c` | `basicruntime_graphics.go`'s stdout mirror |
|
||
| Stdio driver | `src/main.c` | `main.go` minus its SDL frontend |
|
||
| Embedding examples | `examples/embed.c`, `examples/hostvars.c` | *(new)* |
|
||
|
||
`akbasic_runtime_load(rt, source)` was added while writing the README's embedding
|
||
section: a host usually holds its script as a string and wants the sink reserved for output,
|
||
and the only path that existed — `AKBASIC_MODE_RUNSTREAM` reading through the sink's
|
||
`readline` — forces a game to point its output device at its source text. `examples/embed.c`
|
||
is the code the README quotes, built by every build and registered as a CTest case so a
|
||
signature change breaks the build rather than rotting the document.
|
||
|
||
**The acceptance suite is the reference's own corpus, driven in place.** All 41 `.bas` files
|
||
under `deps/basicinterpret/tests/` are registered as individual CTest cases and byte-compared
|
||
against their `.txt` — including the trailing double newline on an error line (§1.8). Nothing
|
||
is copied, so the corpus cannot drift from its upstream.
|
||
|
||
Eighteen unit tests cover the modules the corpus cannot reach on its own.
|
||
`tests/known_reference_defects.c` is registered in `AKBASIC_KNOWN_FAILING_TESTS` and asserts
|
||
the **correct** contract for six of the defects in §6; when one is fixed CTest reports
|
||
"unexpectedly passed", which is the cue to split that assertion out and strike the item.
|
||
|
||
Two budget numbers moved during implementation and are worth knowing:
|
||
`AKBASIC_MAX_ARRAY_ELEMENTS` (1024) caps one array and `AKBASIC_MAX_ARRAY_VALUES` (4096) caps
|
||
all of them together, drawn from an `akbasic_ValuePool` the runtime owns. The reference had no
|
||
such ceiling because it called `make()`.
|
||
|
||
---
|
||
|
||
|
||
## 3. The akgl-backed sink, devices and standalone frontend — **done**
|
||
|
||
`-DAKBASIC_WITH_AKGL=ON` builds and its suite passes, and the build option now changes what
|
||
the executable *does*: an AKGL build of `basic` opens a window, draws BASIC output into it,
|
||
pumps SDL events, lets you type at it, and still puts every byte on stdout. Four adaptors in
|
||
the `akbasic_akgl` target, which is the only thing here that links SDL, are complete and
|
||
tested:
|
||
|
||
| File | Backs |
|
||
|---|---|
|
||
| `src/sink_akgl.c` | the §1.5 text sink, over `akgl_text_measure` and `akgl_text_rendertextat` |
|
||
| `src/graphics_akgl.c` | `akbasic_GraphicsBackend`, over `akgl_draw_*`, and the SSHAPE surface pool |
|
||
| `src/audio_akgl.c` | `akbasic_AudioBackend`, over `akgl_audio_*` |
|
||
| `src/input_akgl.c` | `akbasic_InputBackend`, over `akgl_controller_poll_key` |
|
||
|
||
The character grid comes from `akgl_text_measure(font, "A", &w, &h)` — the direct equivalent of
|
||
the `font.SizeUTF8("A")` at `basicruntime.go:96`, and the call this was blocked on until
|
||
42b60f7. Wrapping is done on the character grid rather than by handing SDL_ttf a `wraplength`,
|
||
because the cursor has to land somewhere definite: a program that `PRINT`s a long string and
|
||
then `PRINT`s again expects the second to start on the row after the first ended, and only the
|
||
code that placed the characters knows which row that is.
|
||
|
||
**The interpreter owns no window, renderer or event loop.** Every initializer takes something
|
||
the host already made. Each calls `akgl_error_init()` first: `akgl_game_init()` would have,
|
||
but we drive subsystems directly and never call it, and a code raised before that registration
|
||
carries no name into its stack trace. It is idempotent.
|
||
|
||
*Acceptance:* `tests/akgl_backends.c`, a 128x128 software renderer under the dummy video
|
||
driver read back with `SDL_RenderReadPixels` — the pattern `deps/libakgl/tests/draw.c`
|
||
established, which needs no display and no offscreen harness. Registered as the `akgl_backends`
|
||
CTest case, and only when `AKBASIC_WITH_AKGL` is on.
|
||
|
||
### The standalone frontend
|
||
|
||
The standalone executable is itself a host, and that half of the Go frontend is now ported —
|
||
`src/frontend_akgl.c`, in its own `akbasic_frontend` target. **The separation is in the build
|
||
graph and not only in a comment**: `akbasic` is the interpreter, `akbasic_akgl` is the four
|
||
adaptors that draw through somebody else's renderer, and `akbasic_frontend` is the one thing in
|
||
the repository that creates a window. A game embedding the interpreter links the first two and
|
||
not the third.
|
||
|
||
| Piece | Where |
|
||
|---|---|
|
||
| Window, renderer, font, the four adaptors | `akbasic_frontend_akgl_init()` |
|
||
| Sink and devices onto a runtime | `akbasic_frontend_akgl_attach()` |
|
||
| One frame: pump events, draw the text layer, present | `akbasic_frontend_akgl_pump()` |
|
||
| The bounded frame loop | `akbasic_frontend_akgl_drive()` |
|
||
| stdout mirror | `src/sink_tee.c`, in the *core* library |
|
||
|
||
Four things are worth knowing about how it came out.
|
||
|
||
1. **The stdout mirror is a sink, not a second write.** §1.5 made the sink boundary precisely
|
||
so the interpreter would never carry the reference's hardcoded pair of calls, and §3 item 5
|
||
said it again. `akbasic_sink_init_tee()` composes two sinks into one and takes `readline`
|
||
from whichever of the two was named as the reader — which is the whole difference between
|
||
the two modes: a program read from a file comes in through the stdio half, and typed lines
|
||
come in through the akgl half's line editor. It lives in the core library and is tested
|
||
there (`tests/sink_tee.c`), because composing two function-pointer records needs no SDL.
|
||
|
||
2. **The line editor borrows the host's loop a frame at a time.** `readline` has to wait for a
|
||
typed line, and §1.6 forbids the library blocking — and a sink that sat on the keyboard with
|
||
no way to pump events would deadlock the process on its first `INPUT`. So the host installs
|
||
an `akbasic_AkglPump` with `akbasic_sink_akgl_set_pump()`, and the editor calls it between
|
||
keystrokes. The loop is still the host's. `akbasic_frontend_akgl_pump()` has that exact
|
||
signature and is installed as-is. Without a pump the sink reports EOF, which is what it did
|
||
before and what a host that wants no editor still gets.
|
||
|
||
3. **The frame loop is bounded, and that is what makes the close button work.** 256 steps per
|
||
frame (`AKBASIC_FRONTEND_STEPS_PER_FRAME`), then pump and present. `10 GOTO 10` under an
|
||
unbounded `run()` would own the process forever; `tests/akgl_frontend.c` runs exactly that
|
||
and asserts the window close ends it.
|
||
|
||
4. **The frame is not cleared.** The graphics verbs draw straight to the renderer rather than
|
||
into a display list, so clearing would wipe every `DRAW` at the end of the frame it was
|
||
issued in. The text layer is drawn over whatever is there, which is also how a C128 stacks
|
||
its text plane on its bitmap plane.
|
||
|
||
*Acceptance:* two things, and the second one is the stronger.
|
||
|
||
- `tests/akgl_frontend.c`, registered as the `akgl_frontend` CTest case: a program run through
|
||
the frontend under the dummy driver, asserting the mirrored stdout **byte for byte** and
|
||
reading the renderer back to prove the same characters were drawn in the Commodore font;
|
||
synthetic key events pushed into SDL's own queue and read back through the frontend's input
|
||
backend, so every link the host owns is in the path; the line editor's fold to upper case,
|
||
its backspace and its escape; a whole REPL session typed at the window and ended with a typed
|
||
`QUIT`; and the window-close path.
|
||
- **The entire golden corpus is driven through the AKGL binary.** A `-DAKBASIC_WITH_AKGL=ON`
|
||
build registers the same 41 upstream cases against the same executable, which now opens a
|
||
window to run them, and all 41 still match byte for byte. That is a much broader statement
|
||
than any hand-written frontend test: the SDL path changes no observable output anywhere in
|
||
the corpus.
|
||
|
||
Three `no_device.bas` local cases are **deliberately not registered** in an AKGL build. Their
|
||
premise is a driver that was given no devices, which is true of the stdio driver and
|
||
deliberately false of this one. The refusal path they cover is asserted directly against the
|
||
backend records in `tests/devices.c`, which both configurations build.
|
||
|
||
**Manual check against a real display, 2026-07-31.** The dummy driver cannot prove presentation,
|
||
so `build-akgl/basic` was run against X display `:0` on a program that prints a line, draws a
|
||
`BOX` and then loops. `xwininfo` found a real 800x600 window titled `BASIC`; the window was
|
||
captured with `import` and measured: the first text row carries glyph pixels (mean 65/255), the
|
||
box's left edge at x=100 is a solid white line (mean 255), the box interior is black — it
|
||
outlines rather than fills — and an empty corner is black. stdout carried `HELLO WORLD` at the
|
||
same time. `QUIT` exits cleanly with status 0.
|
||
|
||
**What that check does not cover, and could not here:** typing at a real focused window. No key
|
||
synthesis tool (`xdotool`, `xte`, python-xlib) is installed on this machine, so the keyboard
|
||
path is proved only through SDL's own event queue in `tests/akgl_frontend.c` — which is the
|
||
same queue a real keyboard delivers into, but not the same as a window manager giving the
|
||
window focus. Somebody should type at it once.
|
||
|
||
### Five things that had to be worked around to get there
|
||
|
||
All five are `libakgl`'s and all five are filed in `deps/libakgl/TODO.md`. Each workaround is
|
||
commented at its site with the words "filed upstream" so it can be found and deleted later.
|
||
|
||
1. **An embedded `libakgl` demands its dependencies be *installed*.** It builds its vendored
|
||
SDL, SDL_image, SDL_mixer, SDL_ttf and jansson only when it is top-level; embedded, it goes
|
||
down a `find_package` path and fails on a machine that has none of them — while the
|
||
submodules sit right there in `deps/libakgl/deps`. Every lookup is guarded with
|
||
`if(NOT TARGET ...)`, so our `CMakeLists.txt` adds those five subdirectories *before*
|
||
`add_subdirectory(deps/libakgl)` and the vendored copies get used after all. Same trick and
|
||
same ordering requirement `akerror::akerror` and `akstdlib::akstdlib` already need.
|
||
|
||
2. **`akgl/controller.h` does not compile on its own.** It declares two handler function
|
||
pointers taking an `akgl_Actor *` and includes nothing that declares the type.
|
||
`src/input_akgl.c` includes `akgl/actor.h` ahead of it.
|
||
|
||
3. **There is no way to attach a 2D backend to a renderer you already have.**
|
||
`akgl_render_init2d()` installs the six vtable pointers, but it also creates its own window
|
||
from the game properties and writes to the `camera` global, so it belongs to the
|
||
`akgl_game_init()` path — which is exactly the path an embedding host is not on.
|
||
`tests/akgl_backends.c` assigns the six pointers by hand. What is wanted upstream is the
|
||
vtable half of `init2d` on its own.
|
||
|
||
4. **`akgl_text_rendertextat()` segfaults on a backend whose vtable is empty.** It reaches
|
||
through `renderer->draw_texture` without checking it, so the first `PRINT` through the sink
|
||
dereferences NULL. That is the same class of defect 42b60f7's own commit added a draw test
|
||
for — "a backend that exists but was never given an `SDL_Renderer`" — and the text path
|
||
still has it.
|
||
|
||
### Still missing from the sink
|
||
|
||
Nothing that blocks a verb. The line editor exists (see above), so `INPUT` and the REPL work in
|
||
the window. Two limits are worth stating because a program can reach both:
|
||
|
||
- **No shifted character can be typed.** The keystroke ring carries a keycode and no modifier
|
||
state, so `"`, `!`, `(`, `)`, `:` and `;` are unreachable and a string literal cannot be typed
|
||
at the window at all. Letters are folded to upper case, which is a C128 doing what a C128 does
|
||
rather than a workaround, but punctuation has no such excuse. Filed upstream as `libakgl`
|
||
item 10; until it lands, the way to run a program with a string literal in it is to pass the
|
||
file on the command line, which is unaffected.
|
||
- **No cursor movement within a line.** Backspace and escape only. The arrow keys stay in the
|
||
ring for a script's own `GET` loop, which is the more valuable use of them.
|
||
|
||
`WINDOW` and `KEY` in group E want a text-window rectangle and a function-key table on top of
|
||
this, which is ordinary work here rather than anything blocked.
|
||
|
||
---
|
||
|
||
## 4. Remaining work: language completion (goal 2)
|
||
|
||
The work queue is the "What Isn't Implemented" list in `deps/basicinterpret/README.md`.
|
||
Each verb is: table row (§1.1) → parse handler if it needs one → exec handler → unit test →
|
||
a new `.bas`/`.txt` pair. **Both** kinds of test; they answer different questions.
|
||
|
||
Order by what unblocks the most, and by what does not need `libakgl` to grow first:
|
||
|
||
| Group | Verbs | Blocked on |
|
||
|---|---|---|
|
||
| A. Structure | `DO`, `LOOP`, `WHILE`, `UNTIL`, `ON`, `BEGIN`, `BEND`, `END` | nothing — reuses `waitingForCommand` |
|
||
| ~~B. Housekeeping~~ | ~~`NEW`, `CLR`, `CONT`, `SWAP`, `TRON`, `TROFF`, `HELP`~~ | **done** — `src/runtime_housekeeping.c`, `tests/housekeeping_verbs.c` |
|
||
| B′. Housekeeping, deferred | `RESTORE`, `RENUMBER` | neither is small; see below |
|
||
| C. Errors | `TRAP`, `RESUME`, `ER`, `ERR` | needs a BASIC-visible error object |
|
||
| D. Strings/format | `USING`, `PUDEF`, `WIDTH`, `CHAR` | nothing |
|
||
| E. Console | `WINDOW`, `KEY`, `SLEEP`, `WAIT`, `TI` | the sink, or the clock `akbasic_runtime_settime()` now carries |
|
||
| F. Disk | `DOPEN`, `DCLOSE`, `APPEND`, `RECORD`, `HEADER`, `COLLECT`, `BACKUP`, `COPY`, `CONCAT`, `RENAME`, `SCRATCH`, `DIRECTORY`, `CATALOG`, `DCLEAR`, `DVERIFY`, `SAVE`, `LOAD`, `VERIFY`, `BLOAD`, `BSAVE`, `BOOT` | `aksl_f*` only; no new akgl |
|
||
| ~~G. Graphics~~ | ~~`GRAPHIC`, `DRAW`, `BOX`, `CIRCLE`, `PAINT`, `COLOR`, `SCALE`, `SSHAPE`, `GSHAPE`, `LOCATE`~~ | **done** — `src/runtime_graphics.c`, `tests/graphics_verbs.c` |
|
||
| H. Sprites | `SPRITE`, `MOVSPR`, `SPRCOLOR`, `SPRDEF`, `SPRSAV`, `COLLISION` | `libakgl` sprite/actor API — check before filing |
|
||
| ~~I. Audio~~ | ~~`PLAY`, `SOUND`, `ENVELOPE`, `VOL`, `TEMPO`~~ | **done** — `src/runtime_audio.c`, `src/play.c`, `tests/audio_verbs.c` |
|
||
| I′. Audio, still gapped | `FILTER`, `SOUND`'s sweep arguments | `libakgl` has neither; both refused with `AKBASIC_ERR_DEVICE`, both filed in §7 |
|
||
| ~~E′. Console input~~ | ~~`GET`, `GETKEY`, `SCNCLR`~~ | **done** — `src/runtime_input.c`, `tests/input_verbs.c` |
|
||
| J. Machine | `SYS`, `FETCH`, `STASH`, `POKE`/`PEEK` variants | nothing |
|
||
|
||
**`RESTORE` and `RENUMBER` are deferred, and neither is a small job.**
|
||
|
||
- **`RESTORE` needs a DATA pointer, and there isn't one.** `READ` does not read: it declares
|
||
that the *next* `DATA` line reached in execution order should fill its identifiers, and skips
|
||
forward until one turns up (`akbasic_cmd_read`, ported faithfully). There is no cursor into a
|
||
list of `DATA` items, so there is nothing for `RESTORE` to reset. Implementing it honestly
|
||
means implementing the pointer, which is worth doing on its own merits, because the current
|
||
arrangement has a defect the corpus does not catch: **a `DATA` line that appears *before* its
|
||
`READ` is never found**, so `10 DATA 1,2` / `20 READ A#` runs off the end of the program in
|
||
silence, and a second `READ` re-reads the same line rather than continuing. The fix is a
|
||
pre-scan of every `DATA` statement into a flat list plus a read cursor, at which point
|
||
`RESTORE [line]` is three lines. Its own commit, with the golden cases the change would need.
|
||
- **`RENUMBER` has to rewrite references or it is worse than useless.** Moving lines is easy —
|
||
source is filed under its line number in `source[]`. Rewriting every `GOTO`, `GOSUB`, `THEN`,
|
||
`ELSE`, `RUN` and `LIST` target to match is the job, and doing it on source text means
|
||
distinguishing a line number from a digit inside a string literal. Refusing it is better than
|
||
a `RENUMBER` that silently breaks every branch in the program.
|
||
|
||
`LOCATE` used to appear in both E and G. It belongs to G alone: in BASIC 7.0 `LOCATE` moves the
|
||
*graphics* pixel cursor that `DRAW` starts from, and the text cursor verb is `CHAR`, which is
|
||
already in group D.
|
||
|
||
**Out of scope, and staying that way** — the reference marks these as incompatible with a
|
||
modern PC and that reasoning stands: `BANK` (no bank switching), `FAST` (irrelevant CPU speed
|
||
control), `MONITOR` (no machine-language monitor).
|
||
|
||
Also on the queue, from the reference's own defect list:
|
||
|
||
- ~~**Multiple statements per line**~~ **Done.** `akbasic_parser_parse()` consumes a run of
|
||
`COLON` tokens before each statement and hands the caller a NULL leaf when nothing followed
|
||
them, which is how an empty statement — a trailing separator, or `::` — stays legal rather
|
||
than becoming an error. `tests/parser_commands.c` counts statements per line;
|
||
`tests/language/statements/multiple_per_line.bas` asserts what actually runs.
|
||
|
||
**One thing about it needed a decision rather than a transcription**, and it is recorded as
|
||
deviation 29 in §5: BASIC 7.0 scopes everything after `THEN` to the condition, so
|
||
`IF C THEN A : B` must run neither `A` nor `B` when `C` is false. The parser takes exactly one
|
||
statement per arm, so the rest of the line reaches the statement loop as ordinary statements
|
||
and the branch has to tell the loop whether to run them.
|
||
|
||
**A known limitation, not a defect:** a whole `FOR`/`NEXT` on one line
|
||
(`FOR I# = 1 TO 3 : PRINT I# : NEXT I#`) does not loop. The block structure is the
|
||
reference's `waitingForCommand` model (§1.6), which skips forward *by source line* to the verb
|
||
it is waiting for, so a `NEXT` on the same line as its `FOR` is never reached. Fixing it means
|
||
restructuring control flow to work on statements rather than lines, which is a deliberate
|
||
piece of work and wants its own commit. Group A's `DO`/`LOOP` will meet the same wall.
|
||
- **Array references in parameter lists** (`READ A$(0), B#`) currently fail to parse. This is
|
||
the *same collision* §6 item 13 was, one field along: an identifier keeps its subscript list
|
||
on `.right`, which is also where an argument list chains its arguments. That item was fixed by
|
||
moving a unary leaf's operand to `.left`; the same move very likely works here, since an
|
||
identifier leaf has no other use for the field either. Fix in `akbasic_parser_primary` and
|
||
`akbasic_leaf_first_subscript`.
|
||
|
||
---
|
||
|
||
## 5. Deliberate deviations from the reference
|
||
|
||
Keep this list current. Each of these changes structure without changing observable output,
|
||
and each one has to be defensible against the golden suite.
|
||
|
||
1. Reflection → static dispatch table (§1.1).
|
||
2. Go maps → fixed open-addressed tables (§1.3).
|
||
3. Unbounded `new(BasicEnvironment)` → pool with release (§1.4).
|
||
4. Hardcoded stdout+SDL mirror → text sink backend (§1.5).
|
||
5. `run()` owns the process → `step()` / bounded `run()` (§1.6).
|
||
6. `panic()` → `FAIL_RETURN`; no library call terminates the process (§1.6).
|
||
7. `debug.PrintStack()` on parse error → the `akerr` stack trace only (§3.1 of the original plan).
|
||
8. `BasicEnvironment.eval_clone_identifiers` deleted as dead state (§4.2 of the original plan).
|
||
9. `BasicValue.name` and `BasicEnvironment.update()` deleted as dead: nothing ever writes the
|
||
field a non-empty string, and `update()` — its only reader — has no callers.
|
||
10. The `DEF`-statement bootstrap is gone. The reference declares every builtin by *running a
|
||
BASIC program of `DEF` lines through the interpreter* at startup and then nulling out the
|
||
expressions (`basicruntime_functions.go:14`); here a builtin's name, arity and handler are
|
||
a row in the dispatch table, and `MOD`, `SPC` and `STR` are ordinary native handlers. This
|
||
removes the need to run the interpreter before the interpreter is ready.
|
||
11. Undefined behaviour the reference reaches by a defined route is refused rather than
|
||
inherited: a shift count outside 0..63, a negative string multiplier, and integer division
|
||
by zero all raise. Go defines all three (or panics); C does not. No golden case exercises
|
||
any of them, so observable behaviour is unchanged.
|
||
12. `CommandEXIT` clears the pending `NEXT` wait before popping. The reference does not, which
|
||
leaves the parent waiting for a `NEXT` that never arrives (§6 item 8) — in C that is a hang
|
||
rather than a misbehaviour, and no golden case depends on it.
|
||
|
||
### Deviations in the verbs the reference never implemented
|
||
|
||
Items 1–12 above are deviations *from ported code*. These are deviations from **Commodore
|
||
BASIC 7.0**, in verbs the reference lists as unimplemented and which therefore had no Go
|
||
behaviour to port. They matter to somebody typing in a listing out of a C128 manual.
|
||
|
||
13. **Hardware verbs reach a device through a backend record, and refuse when there is none.**
|
||
`akbasic_GraphicsBackend`, `_AudioBackend` and `_InputBackend` are records of function
|
||
pointers on the runtime; all three may be `NULL`, which is what the current stdio-only
|
||
standalone driver gives them. That is correct for a default build and unfinished for an
|
||
`AKBASIC_WITH_AKGL` build; see §3. A verb that needs a device it was not given raises
|
||
`AKBASIC_ERR_DEVICE` naming itself. `COLOR`, `LOCATE`, `SCALE`, `ENVELOPE`, `TEMPO` and
|
||
`VOL` deliberately do *not* require one — they change interpreter state, so a program can
|
||
configure itself before the host lends it a renderer.
|
||
|
||
14. **`CIRCLE` is a polygon, always.** BASIC 7.0's `CIRCLE` takes two radii, a start and end
|
||
angle, a rotation and a degree increment, which makes it an inc-degree polygon by
|
||
definition. `akgl_draw_circle` exists and is deliberately **not** used: it draws one
|
||
radius, full sweep, unrotated, so it could serve only the case where every optional
|
||
argument is defaulted — and a shape that changed character depending on whether the two
|
||
radii happened to be equal is worse than one that is uniformly a polygon.
|
||
|
||
15. **`SSHAPE` stores a handle in the string, not the pixels.** A real C128 packs the
|
||
region's bitmap into the string variable. Here a value's string is a fixed 256 bytes
|
||
(§1.2) and a saved region is a device surface, so the variable gets `SHAPE:<n>` and the
|
||
surface stays in a fixed pool on the backend. `GSHAPE` accepts only a string carrying that
|
||
prefix, so a hand-written one is refused rather than pasting an unrelated slot. **What
|
||
this costs:** a shape cannot be written to disk, cannot be concatenated, and `LEN` of it
|
||
is not its size. Nothing in BASIC does any of those to a shape string except a program
|
||
deliberately poking at it.
|
||
|
||
16. **`BOX` fills on a negative angle rather than on a seventh argument.** 7.0's last
|
||
argument selects outline or fill and sits *after* the rotation, which would make a filled
|
||
box a seven-argument call. That is filed rather than implemented; today a rotation of zero
|
||
outlines through the renderer's rectangle and any other rotation draws four lines.
|
||
|
||
17. **`GRAPHIC` records its mode but honours only one consequence of it.** 7.0's five modes
|
||
differ in bitmap resolution and in whether the bottom of the screen stays text. Neither
|
||
means anything against a host's renderer, whose size the interpreter does not know and
|
||
does not own. The mode is stored, an out-of-range one is refused because that is a typo
|
||
worth catching, and the one observable rule is that mode 0 is text.
|
||
|
||
18. **Coordinates reaching a backend are BASIC's 320x200 space, and scaling to the window is
|
||
the host's job.** The interpreter cannot ask the renderer how big it is without owning it.
|
||
`SCALE` maps user coordinates onto that space; with `SCALE` off they pass through.
|
||
|
||
19. **`PLAY` does not block.** On a C128 `PLAY` holds the program until the last note ends.
|
||
§1.6 forbids that outright, so `PLAY` parses the string into a fixed queue and returns;
|
||
`akbasic_runtime_step()` releases one note at a time against whatever time the host last
|
||
passed to `akbasic_runtime_settime()`. **What this changes for a program:** the statement
|
||
after a `PLAY` runs immediately rather than a bar later, so a listing that relied on `PLAY`
|
||
for timing will race ahead. A program that wants to wait should test the queue rather than
|
||
assume the verb waits for it.
|
||
|
||
20. **The host owns the clock, and an unset clock rushes the music.** The library reads no
|
||
clock — it owns no loop and must not block. A host calls `akbasic_runtime_settime()` once a
|
||
frame; the standalone driver calls it every step off `CLOCK_MONOTONIC`. Left unset it reads
|
||
zero forever, so every duration has already expired and the queue drains as fast as
|
||
`step()` is called. Audible, deterministic, and never a hang, which is the right way for
|
||
that to fail.
|
||
|
||
21. **`PLAY`'s `M` (measure) is a no-op and `SOUND`'s frequency sweep is refused.** `M`
|
||
synchronises the three voices on a C128; with one sequential queue there is nothing to
|
||
synchronise, and a listing full of them should still play, so it is accepted and ignored.
|
||
The sweep is the opposite call: `SOUND`'s `dir`/`min`/`step` arguments have no
|
||
`akgl_audio_*` equivalent, and the only way to fake one is to re-issue tones from
|
||
`step()` — which would tie audible pitch to how often the host happens to call us, a tune
|
||
that changes key with the frame rate. Filed upstream as `akgl_audio_sweep`; see §7.
|
||
|
||
22. **`TEMPO`'s constant is a calibration choice, not a transcription.** BASIC 7.0 documents
|
||
`TEMPO` as a relative duration from 1 to 255 defaulting to 8, and does not publish what a
|
||
whole note actually lasts. `src/audio_tables.c` uses 16000 ms at `TEMPO` 1, which puts a
|
||
default-tempo quarter note at 500 ms — 120 beats per minute. If the real constant turns
|
||
up, that is the one line to change.
|
||
|
||
23. **`GETKEY` holds the step loop rather than blocking.** A C128 sits on the keyboard inside
|
||
`GETKEY` until somebody types. §1.6 forbids the library blocking, so the verb sets a flag
|
||
and `akbasic_runtime_step()` declines to advance the program until a key arrives. Every
|
||
step still returns and a bounded `akbasic_runtime_run()` still comes back, so a host keeps
|
||
its frame rate; the program simply does not move past the `GETKEY`, which is the part a
|
||
program author cares about. The `PLAY` queue is serviced *before* this check on purpose —
|
||
music should keep playing while a program waits for a keypress. Withdrawing the input
|
||
device while a `GETKEY` is holding releases it rather than wedging the script forever.
|
||
|
||
24. **`GET` and `GETKEY` into a numeric variable yield the key code.** BASIC 7.0's `GET` takes
|
||
a string variable. Accepting an integer one as well, and giving it the raw code, is what a
|
||
program testing for cursor or function keys needs and costs nothing. No key is code zero,
|
||
matching what a C128 reports. A float variable is refused: it is neither a character nor a
|
||
code.
|
||
|
||
### Deviations in the standalone frontend
|
||
|
||
Items 1–12 are deviations from ported interpreter code and 13–24 from BASIC 7.0. These four are
|
||
deviations from the reference's *program*: `main.go` and the SDL half of
|
||
`basicruntime_graphics.go`.
|
||
|
||
25. **The frame loop is bounded and the reference's is not.** Go's `run()` is a `for {}` that
|
||
owns the process until `MODE_QUIT` (`basicruntime.go:682`), which is why the reference's
|
||
window never answers its close button. Here the driver runs
|
||
`AKBASIC_FRONTEND_STEPS_PER_FRAME` steps, pumps, presents, and goes round again. **What
|
||
this changes for a program:** nothing observable — a bounded run reproduces an unbounded one
|
||
exactly, and the whole golden corpus is driven through this loop to prove it. What it
|
||
changes for a *user* is that the window closes when asked.
|
||
|
||
26. **The frame is not cleared between frames.** The reference blits a text surface onto the
|
||
window surface and calls `UpdateSurface`. Here the graphics verbs draw straight to the
|
||
renderer rather than into a display list, so a `SDL_RenderClear` at the top of each frame
|
||
would erase every `DRAW` at the end of the frame it was issued in. The text layer is drawn
|
||
over whatever is already there. **What this costs:** text that has scrolled away leaves its
|
||
pixels behind wherever a graphics verb has drawn, because nothing repaints that region. A
|
||
`SCNCLR` does not repaint it either — it clears the text grid, not the bitmap. `GRAPHIC 0`
|
||
is the verb that wipes the drawing surface.
|
||
|
||
27. **The window is closed by the host, and that is not `QUIT`.** Closing the window stops the
|
||
frontend and leaves `obj->mode` alone; it does not set `AKBASIC_MODE_QUIT`. The distinction
|
||
is invisible to the standalone program, which exits either way, and load-bearing for an
|
||
embedding host: a game that closes its own window has not decided that the script is
|
||
finished, and a script that ran `QUIT` has. `tests/akgl_frontend.c` asserts both halves.
|
||
|
||
28. **Piped input still works in an AKGL build.** With a terminal on stdin the window is the
|
||
console and lines come from its editor; piped or redirected, they come from the pipe. This
|
||
is not in the reference, which always reads `os.Stdin` in REPL mode. It exists so
|
||
`basic < program.bas` behaves the same in both builds — and so the golden corpus can be
|
||
driven through the SDL binary, which is where most of the frontend's real coverage comes
|
||
from.
|
||
|
||
### Deviations in statement separation
|
||
|
||
29. **A branch decides who owns the rest of its line.** BASIC 7.0 scopes every statement after
|
||
`THEN` to the condition, and the reference has no opinion on the matter because it never
|
||
consumed the `COLON` token at all. The parser here takes exactly one statement for each arm,
|
||
so the rest of the line arrives at the statement loop as ordinary top-level statements and
|
||
something has to say whether to run them. The `BRANCH` case in
|
||
`akbasic_runtime_evaluate()` sets `runtime->skiprestofline`, and the two statement loops
|
||
stop on it.
|
||
|
||
**The rule is not "skip when false"**, which is the reason this is written down:
|
||
|
||
| Line | Condition | What runs |
|
||
|---|---|---|
|
||
| `IF C THEN A : B` | true | `A`, `B` |
|
||
| `IF C THEN A : B` | false | nothing |
|
||
| `IF C THEN A ELSE B : D` | true | `A` |
|
||
| `IF C THEN A ELSE B : D` | false | `B`, `D` |
|
||
|
||
The remainder always belongs to whichever arm was written *last*, so it is skipped exactly
|
||
when that arm is the one not taken — with an `ELSE` the last arm is `ELSE`, without one it
|
||
is `THEN`. That is one line of code and it reads as an oddity without the table above it.
|
||
|
||
---
|
||
|
||
## 6. Reference defects — ported faithfully, fix them deliberately
|
||
|
||
These are real bugs in `deps/basicinterpret`. **Port the behaviour first** so the golden suite
|
||
passes and the port is provably faithful, then fix each one in its own commit with a test that
|
||
asserts the correct contract. Until fixed, the correct-contract test lives in
|
||
`AKBASIC_KNOWN_FAILING_TESTS`.
|
||
|
||
1. **`basicvariable.go:176`** — `toString()` tests `len(self.values) == 0` and then indexes
|
||
`self.values[0]`. Inverted condition; on an empty variable it panics, and on a non-empty one
|
||
it returns the "not implemented for arrays" string. *Consequence:* `PRINT` of a scalar
|
||
through this path is wrong. Fix: `if ( count == 1 )`.
|
||
2. **`basicvariable.go:108`** — `setBoolean` builds a value with `valuetype: TYPE_STRING`.
|
||
*Consequence:* a boolean stored in a variable reads back as a string with an empty
|
||
`stringval`. Fix: `TYPE_BOOLEAN`.
|
||
3. **`basicenvironment.go:157`** — `stopWaiting(command)` ignores `command` and clears the
|
||
wait unconditionally. *Consequence:* an inner block can clear an outer block's wait. Fix:
|
||
compare before clearing, and error on a mismatch.
|
||
4. **`basicvalue.go:181`** — `mathPlus` mutates `self` in place when `self.mutable` is true,
|
||
while every other operator always clones. *Consequence:* `A# + 1` can modify `A#` depending
|
||
on where the value came from. This one is high blast radius; it interacts with
|
||
`eval_clone_identifiers` and with `CommandNEXT`'s increment
|
||
(`basicruntime_commands.go:663`), which **relies** on the mutation. Do not fix it before
|
||
`FOR`/`NEXT` has full test coverage.
|
||
5. **`basicvalue.go:191` and siblings** — every binary operator adds both of the right-hand
|
||
operand's numeric fields: `rval.intval + int64(rval.floatval)`. Works only because the
|
||
unused field is always zero. *Consequence:* none today; it is a landmine for any future
|
||
value that carries both.
|
||
6. **`basicvalue.go`, all comparisons** — `dest` is cloned from `self`, so the resulting
|
||
boolean inherits `self.name`. *Consequence:* a comparison result can be written back to the
|
||
wrong variable through `environment.update()`.
|
||
7. **`basicruntime_commands.go:484`** — `CommandIF` walks `expr.right` to the end of the chain
|
||
looking for a `THEN` command, then dereferences `expr.right` after the loop guaranteed it
|
||
is `nil`. *Consequence:* the "Malformed IF statement" check is unreachable and nested
|
||
`IF ... THEN ... ELSE` is unverified.
|
||
8. **`basicruntime_commands.go:669`** — `CommandEXIT` pops the environment without
|
||
`stopWaiting`, leaving the parent waiting on a `NEXT` that will never come.
|
||
9. **`basicruntime.go:149`** — `newVariable()` and `BasicRuntime.variables[MAX_VARIABLES]` are
|
||
dead; variables live in the environment's map. Do not port them.
|
||
10. ~~**`basicgrammar.go:224`** — `newLiteralInt` selects base 8 whenever the lexeme starts
|
||
with `0`.~~ **Fixed.** Base 10 unless prefixed `0x`; a leading zero in a listing is padding,
|
||
not a radix. `PRINT 010` prints 10 and `PRINT 08` parses. Nothing in the upstream corpus
|
||
had a leading-zero literal, which is why nothing there noticed. Regression tests in
|
||
`tests/grammar_leaves.c` and `tests/language/numeric/octal_literal.bas`, which was rewritten
|
||
from pinning the defect to pinning the fix.
|
||
11. **`basicruntime.go:121` / `basicparser_commands.go:124`** — environments are never freed.
|
||
Addressed structurally by §1.4; no separate fix needed, but the C pool **will** exhaust
|
||
where Go merely leaked, so `tests/environment_scope.c`'s exhaustion assertion matters.
|
||
|
||
### Found during the port
|
||
|
||
Five more, none of which the reference's own corpus catches. **Four are now fixed**, each with
|
||
a regression test in the suite that pinned it; the fifth is described below and is not a coding
|
||
question.
|
||
|
||
12. ~~**`basicparser.go:329` — `subtraction()` returns after one operator where `addition()`
|
||
loops.**~~ **Fixed.** `1 - 2 - 3` computed `1 - 2` and abandoned the rest of the line; the
|
||
remaining `- 3` was left in the token stream, picked up by the statement loop as a second
|
||
statement, evaluated and discarded. A wrong answer, silently, which made this the
|
||
highest-impact item on the list. `exponent()` has the identical shape — a `for` loop with a
|
||
`return` inside it, in both the reference and the port — so `2 ^ 3 ^ 2` had it too. Both
|
||
now fold left, which is what a C128 does with a run of same-precedence operators.
|
||
Regression tests in `tests/parser_expressions.c`.
|
||
|
||
Worth recording for whoever reads the reference next: `exponent()` there is
|
||
`for self.match(CARAT) { ... return expr, nil }` (`basicparser.go:519-539`), so the loop is
|
||
a loop in shape only. `subtraction()` is the same at `:357-377`. Neither is a
|
||
transcription slip in the port.
|
||
|
||
13. ~~**`basicparser.go:565` — a unary-minus argument inflates a function's arity count.**~~
|
||
**Fixed**, and the fix is structural rather than a smarter counter. The counter walked
|
||
`.right`, which is where a unary leaf keeps its operand *and* where an argument list chains
|
||
its arguments; the two meanings were indistinguishable, so `ABS(-9)` counted as two
|
||
arguments and was refused with "function ABS takes 1 arguments, received 2". No builtin
|
||
could be called with a negative literal at all. The corpus hid it — `sgn.bas` assigns `-1`
|
||
to a variable first.
|
||
|
||
**A unary leaf's operand now hangs off `.left`.** A unary leaf had no other use for that
|
||
field, so the two meanings separate for the cost of one nobody was using — and counting
|
||
then needs no special case at all. That also fixes the half of the defect the original
|
||
entry did not mention: chaining the *next* argument through `.right` overwrote the operand,
|
||
so `MOD(-7, 3)` destroyed its own first argument. Three readers moved (`src/runtime.c`,
|
||
`src/grammar.c`'s renderer, `src/runtime_commands.c`'s `LIST` range parser) and that is the
|
||
whole change. Regression tests in `tests/runtime_evaluate.c`, both halves.
|
||
|
||
**The same collision remains for array subscripts**, which is why §4 still lists
|
||
"array references in parameter lists" as outstanding: an identifier keeps its subscript
|
||
list on `.right` too. The same move — onto `.left` — is very likely the fix, and it is not
|
||
made here because it wants its own commit and its own tests.
|
||
|
||
14. ~~**`basicscanner.go:272` — `matchNextChar` returns without setting a token type when it
|
||
cannot peek past the end of the line.**~~ **Fixed.** A comparison operator in the final
|
||
column was silently dropped: `A# =` produced one token, not two, and the parse error that
|
||
followed pointed somewhere else entirely. `falsetype` is now set before returning on the
|
||
peek failure. Regression tests in `tests/scanner_tokens.c`, for `=`, `<` and `>`.
|
||
|
||
15. ~~**`basicscanner.go:318` — hex literals do not survive the scanner.**~~ **Fixed.**
|
||
`matchNumber` let `x` through but not the hex digits after it, so `0xff` lexed as `0x`
|
||
followed by an identifier `ff`, the base-16 branch in `newLiteralInt` was unreachable, and
|
||
the README's hex support did not exist. Once the `x` is seen the run continues over hex
|
||
digits. Accepted anywhere in a number run rather than only after a leading `0`, which is
|
||
the reference's rule and is worth keeping: it is what makes `1x2` one malformed token that
|
||
the line-number conversion diagnoses by name. Regression tests in `tests/scanner_tokens.c`
|
||
and `tests/language/numeric/octal_literal.bas`.
|
||
|
||
16. **`basicscanner.go:349` — the "Reserved word in variable name" check never fires.** The
|
||
lexeme still carries its type suffix when the keyword tables are searched, so `PRINT$`
|
||
misses every table and becomes an ordinary string variable. *Consequence:* the diagnostic
|
||
is dead code and `PRINT$ = 1` is accepted.
|
||
|
||
**Deliberately not fixed, and this is the interesting one.** The fix is four lines — strip
|
||
the suffix before the lookup — and it works. It also breaks a golden case: the reference's
|
||
own `deps/basicinterpret/tests/examples/strreverse.bas` names a variable `INPUT$`, and with
|
||
the check working that line is refused.
|
||
|
||
A real C128 refuses it too, so the reference *program* is the wrong one here, not the
|
||
diagnostic. But the corpus is the acceptance contract (§1.8), it lives in a submodule this
|
||
repository may not edit, and reproducing it byte for byte is goal 1's headline claim. The
|
||
two cannot both hold. Fixing this means deciding that the diagnostic is worth one
|
||
permanently-excluded upstream case, which is a call for the author rather than for whoever
|
||
is next through this file. Until then it stays pinned in
|
||
`tests/known_reference_defects.c`, which is now the only thing left in there.
|
||
|
||
### Ours, not the reference's
|
||
|
||
17. ~~**A host cannot reliably create a script variable while a script is suspended.**~~
|
||
**Fixed.** `akbasic_runtime_global(obj, name, dest)` walks `obj->environment` to the root
|
||
and finds or creates there unconditionally. `README.md` and `examples/hostvars.c` both point
|
||
at it now, and `tests/hostvars.c` asserts it: a global created from inside a `FOR` body and
|
||
from inside a `GOSUB`, still readable by the script after the body pops, plus the
|
||
seed-before / read-after path that always worked.
|
||
|
||
This one was not inherited — it fell out of §1.4's environment pool meeting §1.6's bounded
|
||
`run()`, a combination the reference never had because its `run()` never returned. Both
|
||
failure modes were silent: a variable created through `akbasic_environment_get()` landed in
|
||
the loop's scope and died with it, so the script read it correctly inside the loop and got
|
||
`0` immediately after; and reaching for the root explicitly returned `NULL` through `dest`
|
||
**without raising**, because that function only auto-creates when
|
||
`obj->runtime->environment == obj`.
|
||
|
||
Three things settled while doing it, each worth keeping:
|
||
|
||
- **The design question §6 named has an answer.** Suspended inside a *user function's*
|
||
scope, the root is still right and the walk still gets there: a funcdef's environment is
|
||
initialized with the caller's as its parent (`akbasic_runtime_user_function`), so the
|
||
chain terminates at the same root as any other.
|
||
- **`akbasic_environment_create()` is the new half**, and it searches *only* the scope it
|
||
is given — no walk in either direction. Walking up would find an outer variable and put
|
||
the value somewhere other than where the caller asked for it.
|
||
- **`akbasic_environment_get()` is unchanged.** Declining to create in a non-active scope is
|
||
the right behaviour for what the interpreter uses it for; it is only wrong as a *host*
|
||
API, which is why the answer was a new entry point rather than a change to that one.
|
||
`tests/hostvars.c` asserts the old behaviour too, so a later "tidy-up" has to argue with a
|
||
test.
|
||
|
||
---
|
||
|
||
## 7. Filing gaps against `libakgl`
|
||
|
||
When phase 7 or phase 8 needs something `libakgl` does not have, **stop and file it** in
|
||
`deps/libakgl/TODO.md` in that file's numbered prose style: what the BASIC verb requires, what
|
||
the `akgl_*` entry point should look like, and what tests would cover it. Growing `libakgl` to
|
||
serve the interpreter is a wanted outcome, not a detour.
|
||
|
||
**All four gaps filed so far are now closed upstream**, at `libakgl` 42b60f7. Nothing here is
|
||
blocked on `libakgl` any more; what remains is akbasic-side work.
|
||
|
||
| Was blocking | Closed by |
|
||
|---|---|
|
||
| Text measurement (§3, the akgl sink) | `akgl_text_measure`, `akgl_text_measure_wrapped` |
|
||
| Immediate-mode drawing (§4 group G) | `akgl_draw_point`/`_line`/`_rect`/`_filled_rect`/`_circle`/`_flood_fill`/`_copy_region`/`_paste_region` |
|
||
| Audio (§4 group I) | `akgl_audio_init`/`_tone`/`_envelope`/`_waveform`/`_volume`/`_stop`/`_voice_active`/`_mix` |
|
||
| Console input (§4 group E) | `akgl_controller_poll_key`, `akgl_controller_flush_keys` |
|
||
|
||
Two notes for whoever picks up §3 next. The draw calls take an `akgl_RenderBackend *` as their
|
||
first argument rather than reaching for a global renderer, which fits the rule that the
|
||
interpreter draws through whatever renderer the host already initialized. And the audio API is
|
||
a synthesised-voice one — `akgl_audio_tone(voice, hz, ms)` with a separate ADSR envelope —
|
||
which is the shape `PLAY` and `ENVELOPE` need, rather than the sample playback SDL3_mixer
|
||
would have given.
|
||
|
||
Building against any of it needs `-DAKBASIC_WITH_AKGL=ON`, which pulls in `libakgl`'s own
|
||
submodules (SDL and friends). Those are not initialized in a default clone; `git submodule
|
||
update --init --recursive` gets them.
|
||
|
||
**Seven items are filed upstream and outstanding**, at `deps/libakgl/TODO.md` items 5-10 of "API
|
||
gaps blocking akbasic" and item 14 of "Defects". Four are workarounds this repository carries
|
||
today, each commented at its site with the words "filed upstream" so it can be found and
|
||
deleted: the embedded-dependency CMake trick in our `CMakeLists.txt`, the `akgl/actor.h`
|
||
include in `src/input_akgl.c` and `src/sink_akgl.c`, and the hand-populated vtable and its
|
||
NULL-deref hazard in `tests/akgl_backends.c` and `src/frontend_akgl.c`. §3 describes all four.
|
||
The fifth is `SOUND`'s frequency sweep, the sixth is the missing version bump recorded in §8,
|
||
and the seventh is new:
|
||
|
||
- **The keystroke ring carries a keycode and no modifier state**, so the line editor cannot see
|
||
Shift. Every shifted character — `"`, `!`, `(`, `)`, `:`, `;` — is unreachable, which means a
|
||
string literal cannot be typed at the window at all, and lower case is unreachable too.
|
||
Letters are folded to upper case, which is what a C128 does and is therefore the right answer
|
||
for letters rather than merely the available one; punctuation has no such excuse. It is not
|
||
fixable from here: by the time a caller could ask `SDL_GetModState()` the key is long
|
||
released, and decoupling reads from events is the entire point of a ring. The wanted API is
|
||
an `akgl_controller_poll_keystroke()` returning keycode, modifiers and the composed UTF-8
|
||
text SDL already computes from `SDL_EVENT_TEXT_INPUT` — which is also the only way a keyboard
|
||
layout, a compose key or a dead key can ever work. Filed as item 10, alongside the existing
|
||
`akgl_controller_poll_key()`, which stays exactly as it is: a game asking "was the up arrow
|
||
pressed" wants precisely what it already gets.
|
||
|
||
**One further gap is outstanding, and upstream named it itself.** The commit that added the audio API
|
||
says plainly what it does not cover: *"Still missing for a complete BASIC sound vocabulary:
|
||
`FILTER` (SDL3 has no filter primitive; this would need writing), `TEMPO` and the `PLAY`
|
||
note-string parser, both of which belong in the interpreter rather than here."*
|
||
|
||
- **`TEMPO` and the `PLAY` parser are ours**, not a gap. They are group I work in this
|
||
repository and nothing upstream is waiting on.
|
||
- **`FILTER` is a real gap.** BASIC 7.0's `FILTER freq, lp, bp, hp, res` sets the SID's
|
||
filter cutoff, the three band-pass switches and the 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, `FILTER` is accepted by
|
||
the parser and refused at execution with `AKBASIC_ERR_DEVICE` rather than silently ignored —
|
||
a program that asks for a low-pass and gets an unfiltered square wave has been lied to.
|
||
|
||
A second gap turned up while implementing group I, and is filed alongside it:
|
||
|
||
- **`SOUND` has no frequency sweep.** BASIC 7.0's `SOUND voice, freq, dur, dir, min, step`
|
||
ramps the pitch from `freq` toward `min` in `step` increments per tick, in the direction
|
||
`dir` selects. `akgl_audio_tone(voice, hz, ms)` holds one pitch for one duration and there
|
||
is no entry point that changes pitch over the life of a note. The wanted API is roughly
|
||
`akgl_audio_sweep(int voice, float32_t from_hz, float32_t to_hz, float32_t step_hz, uint32_t ms)`,
|
||
advanced on the mixer's own frame counter — the same place the phase is already derived
|
||
from, and the reason it belongs there rather than here. Faking it in the interpreter means
|
||
re-issuing tones from `akbasic_runtime_step()`, which ties audible pitch to how often a host
|
||
calls us: a tune that changes key with the frame rate. Refused with `AKBASIC_ERR_DEVICE`
|
||
until it lands. Tests would drive `akgl_audio_mix` by hand and assert the frame at which the
|
||
pitch has moved, exactly as `tests/audio.c` already does for the envelope.
|
||
|
||
When the next gap turns up, file it there rather than working around it here.
|
||
|
||
---
|
||
|
||
## 8. Status
|
||
|
||
**The port is done.** The C interpreter reproduces the Go reference byte for byte across its
|
||
entire test corpus and passes clean under ASan and UBSan.
|
||
|
||
| Gate | Result |
|
||
|---|---|
|
||
| `ctest` | 76/76 — 41 upstream golden cases, 9 local ones, 23 unit tests, 2 embedding examples, 1 known-failing |
|
||
| `ctest` with `-DAKBASIC_WITH_AKGL=ON` | 75/75 — the same, minus the three `no_device` cases the SDL driver contradicts, plus `akgl_backends` and `akgl_frontend`; the `akgl_build` CI job |
|
||
| Golden corpus | 41/41 byte-exact, driven in place from the submodule — **and 41/41 again through the SDL binary**, which is most of what proves the frontend changes no output |
|
||
| ASan + UBSan | 76/76 |
|
||
| Line coverage | 94.6% (3857/4076) — above the 90% gate |
|
||
| Function coverage | 98.6% (287/291) |
|
||
| Warnings | none under `-Wall -Wextra` |
|
||
| `doxygen Doxyfile` | clean |
|
||
| Mutation (`src/symtab.c`) | 74.1%, against a gate of 65 — see `.gitea/workflows/ci.yaml` |
|
||
|
||
Coverage of the verb groups added for goal 2, since they are the new surface: `src/audio_tables.c`
|
||
and `src/graphics_tables.c` 100%, `src/runtime_input.c` 100%, `src/runtime_graphics.c` and
|
||
`src/runtime_audio.c` 98%, `src/play.c` 87%. The `akbasic_akgl` target is not in the coverage
|
||
figure — it is not built in a default configuration.
|
||
|
||
**A note on reading that number, because it was misread once while producing it.** A stale
|
||
`build-cov/` left in the source directory from an earlier session was silently folded into the
|
||
report by `gcovr --root .`, which produced the *previous* run's 92.3% for a tree that had grown
|
||
by 800 lines. The number above was produced from a coverage tree outside the source directory
|
||
entirely, after a `find . -name '*.gcda'` came back empty. That is `libakgl` defect #13 happening here rather than there, and `build*/` being
|
||
gitignored is exactly what makes it invisible. **Keep build trees out of the source directory**,
|
||
and if a coverage number looks suspiciously unchanged, `find . -name '*.gcda'` before believing
|
||
it.
|
||
|
||
Branch coverage reads 18.0% and is not a target, for the reason `libakgl/TODO.md` and
|
||
`libakstdlib/TODO.md` both give: the akerror control-flow macros expand into large branch trees
|
||
at every call site, most of them unreachable in normal operation. Track line and function
|
||
coverage.
|
||
|
||
Dependency baseline:
|
||
|
||
| Submodule | Version | Notes |
|
||
|---|---|---|
|
||
| `deps/libakerror` | 1.0.0 | Private ownership-enforced status registry. akbasic reserves 512–767 in `akbasic_error_register()`. Does not namespace its `coverage` target when embedded — worked around in our `CMakeLists.txt`. |
|
||
| `deps/libakstdlib` | 0.2.0 | soname `libakstdlib.so.0.2`. `AKSL_VERSION_CHECK()` asserted in `tests/version_check.c`. This release fixed all six confirmed defects the port was working around — see §1.9, where the bans are now lifted. |
|
||
| `deps/libakgl` | 0.2.0 | soname `libakgl.so.0.2`. Owns status codes 256–260. Linked and tested under `-DAKBASIC_WITH_AKGL=ON`, which still defaults OFF so the core library and its whole suite build on a machine with no SDL. It requires `libakstdlib` 0.2, which is why the two moved together. |
|
||
|
||
**The `libakgl` requirement used to be pinned by commit, and no longer is.** 42b60f7 added 22
|
||
public symbols across four headers and left `project(akgl VERSION 0.1.0)` and the
|
||
`libakgl.so.0.1` soname unchanged, so nothing here could feature-test for the new API and the
|
||
submodule pointer was the only thing expressing the requirement. That was filed upstream as
|
||
`libakgl` defect #14 and fixed by its commit 1066ac7 — the two crossed rather than one
|
||
following the other. `libakgl` is 0.2.0 with an `libakgl.so.0.2` soname, so
|
||
`AKGL_VERSION_AT_LEAST(0, 2, 0)` now means something and an ABI mismatch is refused at load
|
||
time. Kept here because the *reason* is the reusable part: an additive release under an
|
||
unchanged soname costs its consumers the ability to detect it at all.
|
||
|
||
CI is split in two. `.gitea/workflows/ci.yaml` runs on push -- the suite, ASan+UBSan, coverage
|
||
and a two-file mutation run -- and `.gitea/workflows/release.yaml` is manual
|
||
(`workflow_dispatch`), carrying the doxygen gate and the whole-tree mutation run. The split is
|
||
about cost: the full mutation set is 3675 mutants and hours of runner time, which is a release
|
||
gate rather than a per-commit one, and the docs are wanted at release time. Three notes worth
|
||
keeping:
|
||
|
||
- The checkout is `submodules: true`, **not** `recursive`. The build needs `libakerror`,
|
||
`libakstdlib` and `basicinterpret`; it does not need `libakgl`, which is guarded behind
|
||
`AKBASIC_WITH_AKGL` and defaults OFF — and recursing into it would clone SDL, SDL_image,
|
||
SDL_mixer, SDL_ttf and jansson for a target that is never configured. The `docs` job needs
|
||
no submodules at all.
|
||
- The push-path `mutation_test` job is bounded to `src/symtab.c`, scoring 74.1% against a gate
|
||
of 65. It was two files until `src/convert.c` was deleted. `src/audio_tables.c` was measured
|
||
as a replacement and scored 64.7%, below the gate — a real gap rather than a reason to skip
|
||
the file: almost every survivor is in `akbasic_audio_state_init`, where nothing asserts that
|
||
a freshly initialised audio state is actually zeroed and defaulted. That is the same gap this
|
||
job's history records closing for `src/symtab.c`, and closing it is what earns the file its
|
||
place back in CI.
|
||
|
||
Worth knowing before reading a score: the harness's `ICR` operator only rewrites the
|
||
constants `0` and `1`, so a lookup table of other values produces no mutants at all and a
|
||
high score over one says nothing about whether its entries are right. `tests/audio_verbs.c`
|
||
asserts all 32 ADSR entries anyway — that catches a hand-edit typo, which is the real failure
|
||
mode, but it did not and could not move the mutation number. `src/value.c` is the file most
|
||
worth mutating and is deliberately not on that path: 368 mutants at roughly 11 seconds each
|
||
is about 70 minutes, because almost everything links against it. It is covered by
|
||
`release.yaml`, or locally with `cmake --build build --target mutation`.
|
||
- `release.yaml`'s threshold defaults to 65, the same as the push path, rather than something
|
||
stricter. Only two files have ever been measured; a tighter whole-tree number would be a
|
||
guess until a full run establishes a baseline. Raise it through the workflow input once one
|
||
has.
|
||
|
||
What remains, in priority order:
|
||
|
||
1. ~~**The standalone AKGL frontend in §3.**~~ **Done** — `src/frontend_akgl.c` in its own
|
||
`akbasic_frontend` target, `src/sink_tee.c` for the stdout mirror, and
|
||
`tests/akgl_frontend.c`. The build option now changes what the executable does. The one
|
||
thing not machine-verified is typing at a real focused window; §3 records why and what was
|
||
verified instead.
|
||
2. ~~**A line editor for the akgl sink.**~~ **Done** — `readline` in `src/sink_akgl.c`, over
|
||
the keystroke ring, borrowing frames from the host through an `akbasic_AkglPump`. It cannot
|
||
type a shifted character, which is `libakgl` item 10 rather than work outstanding here.
|
||
3. **§4 — the language completion work queue.** Groups G and I, the `GET`/`GETKEY`/`SCNCLR`
|
||
part of E, and **multiple statements per line** are done. What is left is groups A, B, D, F
|
||
and J, none of which need anything from `libakgl`.
|
||
|
||
**Do group A next, and expect it to cost more than the table suggests.** Multiple statements
|
||
per line turned up the wall it will hit: the `waitingForCommand` model skips forward *by
|
||
source line*, so a whole loop written on one line
|
||
(`FOR I# = 1 TO 3 : PRINT I# : NEXT I#`) never reaches its `NEXT`. §4 records it as a known
|
||
limitation rather than a defect, because it is inherited structure and not a slip — but
|
||
`DO`/`LOOP`/`WHILE`/`UNTIL` are exactly the verbs people write on one line, so group A
|
||
probably has to move block skipping from lines to statements before it starts. That is a
|
||
deliberate restructure and wants its own commit and its own test, ahead of any verb.
|
||
4. ~~**CI does not cover `-DAKBASIC_WITH_AKGL=ON`.**~~ **Done** — the `akgl_build` job in
|
||
`.gitea/workflows/ci.yaml`. It turned out much cheaper than the deferral assumed, and the
|
||
two reasons are worth keeping because both were guesses that measurement corrected:
|
||
|
||
- **`submodules: recursive` is not needed and costs 461 MB.** It descends into
|
||
`SDL_image/external`, `SDL_mixer/external` and `SDL_ttf/external` — aom, dav1d, libjxl,
|
||
libtiff, mpg123, opus, flac and a dozen more — and *not one of them is used*. Every one
|
||
configures as "Could NOT find". The job initialises exactly six submodules
|
||
non-recursively instead, which takes about 25 seconds.
|
||
- **The build is about a minute of CPU, not "a long build".** 330 object files, because
|
||
SDL3 compiles out almost every backend that is not wanted here.
|
||
|
||
The one real system dependency is `libfreetype-dev` and `libharfbuzz-dev`: SDL_ttf prefers
|
||
the system copies and would otherwise reach for `external/freetype`, which the checkout
|
||
deliberately does not clone. Two apt packages against two more submodules is an easy trade.
|
||
|
||
Every step was verified from a clean clone before being written down, not inferred.
|
||
5. **§6 item 16.** Items 10, 12–15 and 17 are fixed, each with a regression test in the
|
||
suite that pinned it. What is left is not more of the same:
|
||
- **Item 16** is a *decision*, not a fix. Making the "Reserved word in variable name" check
|
||
fire costs an upstream golden case, because the reference's own `strreverse.bas` names a
|
||
variable `INPUT$`. §6 states the trade; somebody has to make it.
|
||
- ~~**Item 17**~~ **Done** — `akbasic_runtime_global()`, `tests/hostvars.c`, and the README
|
||
and example both pointed at it.
|
||
6. **Mutation survivors in `src/value.c`** — ~~closed~~, with one correction to what was
|
||
written here before. `tests/value_arithmetic.c` grew two functions,
|
||
`test_maximum_length_string()` and `test_pool_starts_empty()`, and each was verified by
|
||
hand-applying the mutant and watching the test fail rather than by assuming it would:
|
||
|
||
| Mutant | Result |
|
||
|---|---|
|
||
| `strncpy(dest->stringval, src, AKBASIC_MAX_STRING_LENGTH - 1)` → `- 2` | killed |
|
||
| `dest->stringval[AKBASIC_MAX_STRING_LENGTH - 1] = '\0'` → `- 2` | killed |
|
||
| `strncpy(..., AKBASIC_MAX_STRING_LENGTH - 1)` → `- 0` | **equivalent — cannot be killed** |
|
||
| `memset(obj, 0, sizeof(*obj))` deleted from `akbasic_valuepool_init` | killed |
|
||
| `obj->next = 0` → `= 1` | killed |
|
||
|
||
**The correction.** This entry used to say all five were real bugs and that "one test that
|
||
round-trips a 255-character string kills all five". Two things about that were wrong.
|
||
|
||
The `- 0` mutant is *equivalent*. `strncpy(dest, src, 256)` into a 256-byte buffer writes at
|
||
most 256 bytes, and `set_string()` has already refused anything longer than 255 two lines
|
||
above, so the extra byte is always the terminator `strncpy` would have written anyway. It
|
||
cannot be killed and should not be counted against the score.
|
||
|
||
And the obvious test does *not* kill the truncating mutant. Joining a full-length string to
|
||
an empty one passes with the mutant in place, because `akbasic_value_math_plus` clones
|
||
`self` into the scratch before writing and the byte a short copy fails to write is already
|
||
the right one. The operands have to sum to the limit **without either of them being the
|
||
answer** — the test uses 200 `X` plus 55 `Z`, and says so at the site, because this is
|
||
exactly the sort of thing that gets "simplified" back into a passing no-op.
|
||
|
||
Two survivors there are genuinely equivalent and cannot be killed: `rval_as_int` and
|
||
`rval_as_float` (`src/value.c:30,35`) tolerate `+` → `-` because the reference's habit of
|
||
adding *both* of the right operand's numeric fields only works when the unused one is zero
|
||
— §6 item 5. Fixing that defect would make these two mutants killable, which is a small
|
||
argument for doing it.
|
||
|
||
`src/value.c` takes about 70 minutes to mutate in full (368 mutants, ~11s each, because
|
||
almost everything links against it), which is why CI runs `src/symtab.c` instead and the
|
||
whole-tree run is a local `cmake --build build --target mutation`.
|