All checks were successful
akbasic CI Build / cmake_build (push) Successful in 3m34s
akbasic CI Build / coverage (push) Successful in 4m4s
akbasic CI Build / sanitizers (push) Successful in 6m59s
akbasic CI Build / akgl_build (push) Successful in 7m57s
akbasic CI Build / mutation_test (push) Successful in 23m28s
Move every program and expectation out of tests/reference and register the unified tests/language corpus as local cases. Remove the old immutable-corpus protections from build, maintenance, and documentation paths. Co-authored-by: andrew <andrew@aklabs.net>
2967 lines
188 KiB
Markdown
2967 lines
188 KiB
Markdown
# Record
|
||
|
||
**Outstanding work is in the issue tracker, not in this file:**
|
||
<https://source.starfort.tech/andrew/akbasic/issues>
|
||
|
||
This file was the implementation plan for the Go → C port of `deps/basicinterpret`, written to be
|
||
executed by AI agents rather than read for inspiration. **The port is done**, so what it is now is
|
||
the record: the design decisions that are settled, the deviations from the reference and why each
|
||
was taken, the defects that were found and fixed, and the reasoning behind the measurements.
|
||
|
||
Each open item that moved leaves a line saying what it was and which issue carries it, because an
|
||
entry explaining *why* a defect matters is worth keeping beside the work it constrains — but the
|
||
tracking happens there, not here.
|
||
|
||
Issues are labelled by kind and blast radius, and milestoned by what they can land in: `0.1.x` for
|
||
anything that changes no public contract, `0.2.0` for new verbs and observable behaviour changes,
|
||
`1.0.0` for the design decisions. Everything filed carries `status::grooming`.
|
||
|
||
Two gaps this file recorded and deliberately did not file — the missing `HUD` anchors and a
|
||
dismissable dialog — are now `libakgl` #79 and #80. §7's rule is right that *changing* a dependency
|
||
is that repository's decision; it does not follow that reporting the gap is.
|
||
|
||
---
|
||
|
||
## 0. Agent protocol
|
||
|
||
### 0.1 The Go reference is deprecated. Stop matching it.
|
||
|
||
**`deps/basicinterpret` is a dead project. It will not be updated, and this interpreter is no
|
||
longer required to reproduce its behaviour.** Recorded here first because it silently reverses
|
||
the premise several sections of this file were written on, and because an agent that reads them
|
||
without this will park work that is no longer blocked.
|
||
|
||
What it changes:
|
||
|
||
- **§6 is now an ordinary defect list.** "Port the behaviour first so the port is provably
|
||
faithful" is retired. Fix them because they are wrong, not when fidelity permits.
|
||
- **§1.8's message-text contract is now a convention.** Improving a message is allowed; it costs
|
||
a golden file, which is a cost rather than a veto.
|
||
- **§5's bar drops** from "defensible against the golden suite" to defensible on its own merits.
|
||
- **`tests/language/` is the editable language corpus rather than a protected specification.** Diverging from
|
||
it is allowed and must be deliberate and recorded — see its README.
|
||
|
||
What it does **not** change:
|
||
|
||
- The corpus stays and stays green. Forty-one real BASIC programs with known-good output are
|
||
worth having whatever their provenance, and an unexplained change there is still a red flag.
|
||
- The Go source stays readable as documentation. It remains the best answer to "what did the
|
||
original actually do here", which is a question worth being able to answer even once the
|
||
answer stops being binding.
|
||
- Nothing about the `ak*` house rules, which never came from the reference.
|
||
|
||
**Read these before touching anything**, in this order:
|
||
|
||
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.
|
||
4. `deps/libakstdlib`'s issue tracker — the defects and gaps 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. Still
|
||
worth reading for the verb set and the semantics; no longer binding, per §0.1.
|
||
|
||
**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 the tracker in the same commit as the work: close the issue, or replace it with
|
||
the defect it uncovered. Outstanding items live there; this file holds the record.
|
||
- **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 against `libakgl` in its issue tracker -- what the BASIC verb requires,
|
||
what the `akgl_*` entry point should look like, and what tests would cover it -- 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, recorded upstream: 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; `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`.
|
||
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
|
||
`MAINTENANCE.md`.
|
||
|
||
`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:
|
||
|
||
```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 a convention, no longer a 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.
|
||
|
||
**This used to be a hard contract and is now a default.** The Go implementation is deprecated
|
||
and will not be updated, so the two projects are no longer required to match — see §0.1. What
|
||
survives is the practical half: these strings and this newline behaviour are what every
|
||
expectation in `tests/language/` was written against, so changing one means changing the paired
|
||
files, and that is worth doing on purpose rather than by accident. A message that reads
|
||
awkwardly *may* now be improved; do it deliberately, move the expectations in the same commit,
|
||
and add a line to §5.
|
||
|
||
Numeric formatting still matches the reference: integers via `%" PRId64 "`, floats via `%f`
|
||
(Go's `%f` and C's `%f` both give six decimals — `tests/language/arithmetic/float.txt`
|
||
confirms). No reason to change it, which is different from not being allowed to.
|
||
|
||
### 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`, passes the reference's entire corpus, and passes under ASan and UBSan.
|
||
|
||
It *did* reproduce the reference byte for byte, and that claim is retired rather than broken:
|
||
§0.1 released it, and one case has since diverged deliberately (§6 item 16, listed in
|
||
`tests/language/README.md`). Everything else still matches, which is worth knowing but is no
|
||
longer a gate.
|
||
|
||
```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 editable language corpus, checked in at `tests/language/`.** All
|
||
65 `.bas` files are registered as individual CTest cases and compared against their `.txt`
|
||
— including the trailing double newline on an error line (§1.8).
|
||
|
||
It was driven *in place* out of `deps/basicinterpret` until 2026-07-31, on the reasoning that
|
||
copying a submodule's corpus guarantees drift. That reasoning was sound and was overruled
|
||
deliberately: the Go dependency is being deprecated, and a build that cannot run its own
|
||
acceptance suite without cloning the implementation it replaced is not finished. The copy is
|
||
originally byte-identical to `basicinterpreter@d76162c`, and `tests/language/README.md` records the
|
||
provenance and the rule that programs and expectations are edited together deliberately.
|
||
|
||
**Nothing in the build or the suite needs `deps/basicinterpret` any more**, and that is checked
|
||
rather than assumed — both configurations were configured, built and run from scratch with the
|
||
submodule moved out of the tree. The Commodore font moved too, to `assets/fonts/`, which was the
|
||
other thing tying the build to it; `assets/fonts/PROVENANCE.md` carries the licence question that
|
||
came with it.
|
||
|
||
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.
|
||
|
||
**The typing half is no longer manual, and it is no longer a gap.** It used to read "somebody
|
||
should type at it once", because no key-synthesis tool was installed. `xdotool` now is, and
|
||
`tests/akgl_typing.sh` is registered as the `akgl_typing` CTest case: it starts the driver under
|
||
a pty, waits for the window with `xdotool search --sync`, gives it keyboard focus, types a
|
||
program containing a **string literal** and a **lower-case** one, and polls the mirrored stdout
|
||
for what they print.
|
||
|
||
That case earns its keep twice over. It is the only test that covers the path *upstream* of SDL
|
||
— X11 → SDL composition → libakgl's ring → the editor — and everything else synthesises SDL
|
||
events, which is precisely how the missing `SDL_StartTextInput()` shipped with a green suite.
|
||
Confirmed by reverting both halves of that fix and watching it fail with the same symptom that
|
||
was reported: `READY`, and nothing after it.
|
||
|
||
It needs a real X server, a window manager and `xdotool`, and it steals keyboard focus for about
|
||
fifteen seconds. Absent any of those it reports CTest `Skipped` rather than failing, which is
|
||
what it does in CI. `AKBASIC_SKIP_INTERACTIVE=1` skips it deliberately.
|
||
|
||
### The five things that had to be worked around — **all gone**
|
||
|
||
Every one was filed against `libakgl` and every one is resolved in its 0.3.0. What is left here
|
||
is the note that they existed, because the pattern is the point: file it upstream, comment the
|
||
workaround at its site with the words "filed upstream", delete it when it lands.
|
||
|
||
| Was | Resolved by |
|
||
|---|---|
|
||
| An embedded `libakgl` required its vendored SDL, SDL_image, SDL_mixer, SDL_ttf and jansson to be *installed*, so our `CMakeLists.txt` declared all five by hand first | It adds them whether or not it is top-level |
|
||
| `akgl/controller.h` did not compile on its own — it used `akgl_Actor *` and included nothing that declared it | It includes `<akgl/actor.h>` |
|
||
| There was no way to bind a 2D backend to a renderer you already had, so the six vtable pointers were assigned by hand in two files | `akgl_render_bind2d()` |
|
||
| `akgl_text_rendertextat()` dereferenced an unset `draw_texture`, so the first `PRINT` through an unbound backend segfaulted | It checks, and reports `AKERR_NULLPOINTER` like the draw entry points |
|
||
| `SOUND`'s frequency sweep had no equivalent and was refused | `akgl_audio_sweep()` |
|
||
|
||
**0.3.0 also arrived with a regression that this repository is the one that found**, because it
|
||
is the only consumer that embeds `libakgl` alongside other projects: the commit that made the
|
||
vendored dependencies unconditional also made its `add_test()` shadow unconditional, and CMake
|
||
chains command overrides exactly one level deep. Two projects in one tree cannot both shadow
|
||
`add_test` — the second rebinds `_add_test` to the first and the builtin becomes unreachable to
|
||
everybody, so our own registrations recursed to CMake's depth limit. Fixed upstream by putting
|
||
the top-level guard back; filed as `libakgl` defect 27, with the two-line CMake program that
|
||
demonstrates the one-level limit.
|
||
|
||
### Still missing from the sink
|
||
|
||
Nothing that blocks a verb, and as of `libakgl` 0.3.0 nothing that blocks a *character* either.
|
||
The ring now carries the composed UTF-8 text SDL worked out from each keystroke, so the editor
|
||
takes that in preference to the keycode and shifted characters, keyboard layouts, compose keys
|
||
and dead keys all work. **A double quote can be typed, so a BASIC string literal can be typed**,
|
||
which was the sharp end of the old limitation — it used to mean a program with a string in it
|
||
had to be passed on the command line. Letters are no longer folded to upper case, because there
|
||
is no longer any reason to.
|
||
|
||
**The host has to turn text input on**, and forgetting to is how this broke once. SDL3 has it
|
||
off by default and per-window, so it is `akbasic_frontend_akgl_init()` that calls
|
||
`SDL_StartTextInput()` — `akgl/controller.h` says as much. Without it SDL emits no
|
||
`SDL_EVENT_TEXT_INPUT` at all, every keystroke reaches the ring with an empty `text`, and an
|
||
editor that reads that as "not a character" is silently dead: nothing echoes in the window, and
|
||
nothing reaches stdout either, because `RUN` can never be typed.
|
||
|
||
The editor therefore **falls back to the keycode** when a keystroke carries no composed text,
|
||
folded to upper case. A worse keyboard is a great deal better than no keyboard, and it means a
|
||
host that embeds these adaptors and forgets `SDL_StartTextInput()` gets a usable editor rather
|
||
than a dead one. Both halves are asserted in `tests/akgl_frontend.c`, and both were checked by
|
||
reverting each in turn and watching the test fail.
|
||
|
||
Worth knowing about the test suite that missed this: every other keyboard test pushes
|
||
`SDL_EVENT_TEXT_INPUT` into SDL's queue by hand, which is what a real keyboard produces — *once
|
||
text input has been started*. Synthesising the end of a chain cannot test the beginning of it.
|
||
The new test asserts `SDL_TextInputActive()` directly for that reason, and the whole frontend
|
||
suite has been run against a real X11 window as well as the dummy driver.
|
||
|
||
One limit remains, and it is a choice rather than a gap:
|
||
|
||
- **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.
|
||
|
||
A non-ASCII character is accepted by the keyboard and then dropped rather than stored, which is
|
||
§1.2 rather than the editor: the grid is a byte per cell and a value's string is a fixed 256
|
||
bytes, so a multi-byte character has nowhere to go. Dropping it is honest where storing half of
|
||
it is not.
|
||
|
||
`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`~~ | **done** — `src/runtime_structure.c`, `tests/structure_verbs.c` |
|
||
| ~~B. Housekeeping~~ | ~~`NEW`, `CLR`, `CONT`, `SWAP`, `TRON`, `TROFF`, `HELP`~~ | **done** — `src/runtime_housekeeping.c`, `tests/housekeeping_verbs.c` |
|
||
| ~~B′. Housekeeping, deferred~~ | ~~`RESTORE`, `RENUMBER`~~ | **done** — `src/data.c` and `src/renumber.c`, `tests/read_data.c` and `tests/renumber.c` |
|
||
| ~~C. Errors~~ | ~~`TRAP`, `RESUME`, `ER`, `ERR`~~ | **done** — `src/runtime_trap.c`, `tests/trap_verbs.c`. `ER`/`EL` are `ER#`/`EL#`; see §5 |
|
||
| ~~D. Strings/format~~ | ~~`USING`, `PUDEF`, `WIDTH`, `CHAR`~~ | **done** — `src/format.c`, `src/runtime_format.c`, `tests/format_verbs.c` |
|
||
| ~~E. Console~~ | ~~`WINDOW`, `KEY`, `SLEEP`, `WAIT`, `TI`~~ | **done** — `src/runtime_console.c`, `tests/console_verbs.c` |
|
||
| ~~F. Disk~~ | ~~all 21~~ | **done** — `src/runtime_disk.c`, `tests/disk_verbs.c`. Five refuse for want of a drive and `DIRECTORY` for want of an upstream wrapper; see §5 |
|
||
| ~~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`, `SPRSAV`, `COLLISION`~~ | **done** — `src/runtime_sprite.c`, `src/sprite_akgl.c`, `tests/sprite_verbs.c`. `SPRDEF` is out of scope; see below |
|
||
| ~~I. Audio~~ | ~~`PLAY`, `SOUND`, `ENVELOPE`, `VOL`, `TEMPO`~~ | **done** — `src/runtime_audio.c`, `src/play.c`, `tests/audio_verbs.c` |
|
||
| I′. Audio, still gapped | `FILTER` | `libakgl` has no filter stage and SDL3 supplies no primitive; refused with `AKBASIC_ERR_DEVICE`. `SOUND`'s sweep arguments used to be here and landed with `akgl_audio_sweep` in libakgl 0.3.0 |
|
||
| ~~E′. Console input~~ | ~~`GET`, `GETKEY`, `SCNCLR`~~ | **done** — `src/runtime_input.c`, `tests/input_verbs.c` |
|
||
| ~~J. Machine~~ | ~~`SYS`, `FETCH`, `STASH`~~ | **done** — `src/runtime_machine.c`, `tests/machine_verbs.c`. `SYS` is refused by name; see §5 |
|
||
|
||
**`RESTORE` and `RENUMBER` are deferred, and neither is a small job.**
|
||
|
||
- ~~**`RESTORE` needs a DATA pointer, and there isn't one.**~~ **Done.** Every `DATA` statement
|
||
is pre-scanned into one flat list before the program runs -- `src/data.c`, called from
|
||
`akbasic_runtime_set_mode()` beside the label prescan -- and `READ` walks a cursor along it.
|
||
`RESTORE` resets the cursor; `RESTORE (line)` moves it to the first item on or after that
|
||
line.
|
||
|
||
It fixed two defects on the way, both of which were consequences of `READ` not reading.
|
||
**A `DATA` line above its `READ` is now found**, where skipping forward from the `READ` used
|
||
to run off the end of the program in silence. And **the lines between a `READ` and its `DATA`
|
||
now execute**, where the skip used to swallow them -- a C128 runs them, because `READ` takes
|
||
the next item and execution carries on. The one corpus case has its `DATA` immediately after
|
||
its `READ`, so neither was observable there. `tests/read_data.c`.
|
||
|
||
`DATA` at run time is now a no-op: it is a declaration, and reaching the statement means
|
||
walking past it.
|
||
|
||
- ~~**`RENUMBER` has to rewrite references or it is worse than useless.**~~ **Done.**
|
||
`src/renumber.c` builds the whole old-to-new map first, then rewrites every line -- including
|
||
lines *before* the renumbered region, which can branch into it -- and only then moves them.
|
||
|
||
The rewrite is textual and understands exactly one thing about the rest of the language:
|
||
where a string literal starts and stops, so `PRINT "GOTO 10"` survives. `GOTO`, `GOSUB`,
|
||
`RUN`, `RESTORE` and `TRAP` targets are rewritten, and so is the comma-separated list after
|
||
`ON X GOTO` -- for free, since the targets follow the `GOTO`. `COLLISION` is special-cased
|
||
because its handler is its *second* argument.
|
||
|
||
Two deliberate non-rewrites. **A target naming a line that does not exist is left alone**:
|
||
`GOTO 9999` in a program with no line 9999 is already broken, and inventing a destination
|
||
would hide that. And **`THEN`/`ELSE` do not take bare line numbers in this dialect** -- the
|
||
corpus writes `THEN GOTO 100` -- so a number after `THEN` is an expression, not a target.
|
||
|
||
A renumbering that would land a moved line on a kept one is refused before anything is
|
||
written, because losing a line to a renumbering is not recoverable.
|
||
|
||
**A program written with labels needs none of this.** `GOTO DONE` is unaffected by any
|
||
renumbering, which is the strongest argument for `LABEL` there is.
|
||
|
||
`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). **`SPRDEF` joins them** on the same
|
||
reasoning: it is not a programmable verb but an interactive full-screen sprite editor, driven
|
||
by single keystrokes and its own cursor. A program cannot call it usefully and this interpreter
|
||
does not own the screen it would take over. The three `SPRSAV` source forms are what replace it.
|
||
|
||
**The `libakgl` JSON layer turned out not to be in the way at all.** Group H was parked on
|
||
"check the sprite/actor API before filing", and the concern was that libakgl's sprites are
|
||
described by JSON documents that would be cumbersome to reach through BASIC.
|
||
`akgl_sprite_load_json()` is a thin wrapper over `akgl_spritesheet_initialize` +
|
||
`akgl_sprite_initialize` + writes to public struct fields, and every field it fills from a
|
||
document — frame list, animation speed, loop flags, state-to-sprite map — is something a
|
||
Commodore sprite does not have. So `src/sprite_akgl.c` builds the same four objects directly.
|
||
The one field that *did* survive into the verb syntax is the spritesheet's filename:
|
||
`SPRSAV "ship.png", 1` goes through `akgl_path_relative()` and `akgl_spritesheet_initialize()`,
|
||
which is the same pair of calls the JSON loader makes.
|
||
|
||
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 31 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**~~ **Done**, and it turned out to be §6 item 13 a
|
||
third time rather than a separate defect. An identifier's subscript list moved to `.expr`, and
|
||
`akbasic_ASTLeaf` grew a `next` field so an argument list chains through a link of its own.
|
||
That last part is the real fix: the reference chains arguments through each argument's own
|
||
`.right`, and *every* leaf type that can be an argument already uses `.right` for something,
|
||
so moving one operand out of the way only shifted the collision along. Covered by
|
||
`tests/language/arrays_in_parameter_lists.bas` and `tests/runtime_evaluate.c`.
|
||
|
||
### What a demoscene program wanted and could not have
|
||
|
||
`examples/megademo/megademo.bas` was written to push the interpreter to its edges, and these are
|
||
the edges it hit. **None of them blocked the demo** — every one has a workaround and the program
|
||
carries all six — but each workaround is a routine every future game will carry too, and that is
|
||
the argument for the verb.
|
||
|
||
Ordered by how much BASIC each one would delete: `RND` and `ASC` (#16), the unwritable palette
|
||
(#17), `GSHAPE`'s missing blend modes (#18), no way to ask whether `PLAY` has drained (#19),
|
||
`PLAY` being monophonic (#20), `DATA` being too small a pipe for assets (#21), and the `TI#` spin
|
||
that starves the audio queue (#22).
|
||
|
||
**The last of those is the one worth reading before writing a game loop.** Polling `TI#` does not
|
||
merely waste CPU: the host calls `settime()` once per `runtime_run()` batch, so a batch of spin
|
||
statements drops the note release rate below what a sixteenth-note tune needs, the queue never
|
||
reclaims slots until it drains completely, and the backlog compounds until `PLAY queue is full`.
|
||
Reproduced both ways in isolation. Sleep a frame instead.
|
||
|
||
---
|
||
|
||
## 5. Deliberate deviations from the reference
|
||
|
||
Keep this list current. Most of these change structure without changing observable output;
|
||
the ones that *do* change output say so and carry the golden file they moved.
|
||
|
||
The bar has dropped since this list was started. It used to be "defensible against the golden
|
||
suite", because matching the Go implementation byte for byte was goal 1's headline claim. That
|
||
implementation is now deprecated (§0.1), so the bar is the ordinary one: defensible on its own
|
||
merits, recorded here, and tested.
|
||
|
||
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` cannot fill at all, and the plan for it is to spell fill as a negative angle.**
|
||
7.0's last argument selects outline or fill and sits *after* the rotation, which would
|
||
make a filled box a seven-argument call — one more than `akbasic_cmd_box()` collects.
|
||
Spelling it as a negative rotation instead is the intended answer and **is not
|
||
implemented**: today a rotation of zero outlines through the renderer's rectangle and any
|
||
other rotation, negative included, draws four lines.
|
||
|
||
**This entry used to claim in bold that it fills**, with the body then saying it was
|
||
filed rather than implemented — a headline that contradicted its own paragraph, which is
|
||
exactly how a reader ends up believing a feature exists. Found by writing a
|
||
documentation figure for `BOX` and getting an outline back. `PAINT` is the fill a
|
||
program has today.
|
||
|
||
**`akbasic_GraphicsBackend::filled_rect` is dead from the language's side** as a direct
|
||
consequence: `src/graphics_akgl.c` implements it and `tests/mockdevice.h` records it, but
|
||
no BASIC verb reaches it. It is the entry point this fix would call, so it is waiting
|
||
rather than unused — worth knowing before somebody tidies it away.
|
||
|
||
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 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. The mode is readable as `RGR(0)`, which
|
||
is 7.0's whole `RGR` and the one field of ours that is.
|
||
|
||
18. **A coordinate reaching a backend is a device pixel, and the whole of the host's window
|
||
is reachable from BASIC.** This used to read "coordinates are BASIC's 320x200 space and
|
||
scaling to the window is the host's job", on the reasoning that the interpreter cannot
|
||
ask the renderer how big it is without owning it. **That reasoning was wrong twice over**,
|
||
which is what §8 item 9 came to say.
|
||
|
||
It was wrong about the mechanism: not owning a thing is no bar to *asking* it, and the
|
||
backend record is how everything else here asks. `akbasic_GraphicsBackend` grew a `size`
|
||
entry point, `require_graphics()` calls it before every verb that draws -- so a resized
|
||
window is honoured between two statements rather than at attach -- and 320x200 became
|
||
the fallback for a backend that leaves `size` NULL. It is the record's one optional
|
||
entry point, so a host written against the old header keeps working and gets exactly the
|
||
behaviour it used to get.
|
||
|
||
And it was wrong about the description: nothing ever stretched anything. With `SCALE`
|
||
off a coordinate was passed straight to `akgl_draw_*` as a pixel address, so an 800x600
|
||
window drew a C128 listing into its top-left 320x200 corner and left the rest unused.
|
||
The documentation described a coordinate transform that did not exist.
|
||
|
||
**What changed observably** is `SCALE`, which now maps user coordinates onto the device
|
||
rather than onto the constants, and `RGR(1)` / `RGR(2)`, which report the drawing
|
||
surface's width and height so a program can use a window whose size it did not choose.
|
||
A C128 listing draws in the corner as it always did; `SCALE 1, 319, 199` gives it the
|
||
whole window.
|
||
|
||
**One fix rode along, in the same line of code and too small to defer.** `SCALE` mapped
|
||
`xmax` onto the *width*, which put the user space's far corner one pixel past the
|
||
surface: `SCALE 1, 319, 199` then `DRAW 1, 319, 199` drew nothing at all. It maps onto
|
||
the last pixel now, which is what makes that sentence above true rather than nearly
|
||
true. `tests/graphics_verbs.c` and `tests/akgl_backends.c`, the latter against a
|
||
128x128 target chosen because it is *smaller* than the old constants -- a `SCALE` still
|
||
dividing by them misses it entirely rather than landing somewhere plausible.
|
||
|
||
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 sweep runs once rather than
|
||
oscillating.** `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 *used* to be refused outright — there was no `akgl_audio_*` equivalent and faking
|
||
one would have tied audible pitch to how often the host calls us. `libakgl` 0.3.0 added
|
||
`akgl_audio_sweep()` and `SOUND voice, freq, dur, dir, min, step` now reaches it. What does
|
||
not survive is `dir` 3, "oscillate": `akgl_audio_sweep` runs one pass between two endpoints,
|
||
and a real oscillation needs the mixer to turn around at them. **What this changes for a
|
||
program:** a `dir` 3 siren rises or falls once instead of warbling. `dir` 1 and 2 are exact,
|
||
and so is the direction — both the SID and `akgl_audio_sweep` take it from which endpoint is
|
||
higher, so a `min` above the starting frequency rises whatever `dir` says.
|
||
|
||
`step` is converted as a *delta* rather than a position: it is a register increment, and the
|
||
register-to-hertz table maps positions, so it goes across as the distance between register 0
|
||
and register `step`. A step that converts to zero would never arrive and is raised to one
|
||
hertz, which is the smallest move that still gets there.
|
||
|
||
A backend whose record has no `sweep` — a host written against `libakgl` 0.2.0 — still
|
||
refuses a swept note with `AKBASIC_ERR_DEVICE` and plays a held one normally.
|
||
`tests/audio_verbs.c` asserts both halves.
|
||
|
||
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 text layer repaints every row it owns, every frame.** Not just the rows that changed.
|
||
|
||
That was tried — a `drawn[]` array marking which rows had carried glyphs, so an untouched
|
||
row could be skipped — and it is **wrong on real hardware**. `SDL_RenderPresent` swaps
|
||
buffers, so a frame does not inherit the frame before it; it inherits the one *two* back,
|
||
and on some backends something undefined. A row erased once is clean in one buffer and still
|
||
dirty in the other, and presenting alternates between them.
|
||
|
||
That is what a backspace across a line wrap looked like: the first character of the wrapped
|
||
tail flickering in and out forever after the rest had gone. It reproduces on X11 and never
|
||
under the dummy driver or a software renderer, which is exactly why the suite was green
|
||
through two rounds of fixing it. `tests/akgl_frontend.c` now paints stale pixels by hand —
|
||
which is what a swapped-in buffer hands back — so the case is covered without needing real
|
||
hardware.
|
||
|
||
There is no cheaper correct answer available: a frame either owns every pixel it presents or
|
||
it inherits pixels it cannot reason about.
|
||
|
||
**What this costs, and it is more than it used to.** The host still does not call
|
||
`SDL_RenderClear`, but the text area is now repainted in full every frame, so anything a
|
||
graphics verb drew underneath it is erased every frame rather than only where text lands. In
|
||
the standalone driver the text area is the whole window, so `DRAW` and `PRINT` no longer
|
||
coexist there.
|
||
|
||
**And the same buffer swap means the graphics verbs were never reliable across frames
|
||
anyway**, which is worth stating plainly rather than leaving as a surprise: a one-shot `DRAW`
|
||
lands in one buffer and the next present shows the other. It looked fine in a single
|
||
screenshot and would have flickered exactly as the text did. Making graphics survive needs a
|
||
persistent surface the frame is composited from, which is real work and its own commit —
|
||
filed here rather than half-done.
|
||
|
||
27. **The cursor is a blinking block.** The reference draws an underscore glyph (`drawCursor`,
|
||
`basicruntime_graphics.go:33`), and so did this until the underscore turned out to sit
|
||
*under* the text being typed and make it hard to read. A block occupies the cell after the
|
||
text instead of the space beneath it.
|
||
|
||
Half a second per blink (`AKBASIC_SINK_CURSOR_BLINK_MS`), which is about a Commodore's and
|
||
slow enough to read under. `akbasic_AkglSink.cursorperiodms` set to zero holds it solid,
|
||
which is what makes a frame deterministic for a test.
|
||
|
||
Two details that are decisions rather than accidents. The clock is **SDL's**, not the
|
||
host's: everywhere else the host owns the clock because the library owns no loop and must
|
||
not block, but this is an adaptor that already links SDL and threading a timestamp through
|
||
the sink interface to animate a cursor would be a poor trade. And a line that exactly fills
|
||
a row leaves the cursor one column past the end, because `putchar_at` wraps only when the
|
||
next character arrives — the cursor is drawn at the start of the next row instead, which is
|
||
where the next character will actually land and where a C128 puts it.
|
||
|
||
28. **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.
|
||
|
||
29. **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 that change what a program is allowed to do
|
||
|
||
30. **A verb name with a type suffix is not a variable name.** `PRINT$ = 1` is refused where
|
||
the reference accepts it, because its reserved-word check searched the keyword tables with
|
||
the suffix still attached and therefore never matched (§6 item 16). A real C128 refuses it
|
||
too, so this is the reference being wrong rather than this interpreter being strict.
|
||
|
||
**What it costs a program:** a listing that used `INPUT$`, `LEN#` or `GOTO%` as a variable
|
||
stops parsing, and the fix is to rename the variable. One case in the reference's own corpus
|
||
did exactly that; see `tests/language/examples/strreverse.bas`.
|
||
|
||
### Deviations in statement separation
|
||
|
||
31. **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.
|
||
|
||
### Deviations in the structure and error verbs (groups A and C)
|
||
|
||
61. **Structures are an addition, not a port.** BASIC 7.0 has no records at all, so
|
||
`TYPE`/`END TYPE`, `DIM X@ AS T`, `.` and `->`, `PTR TO` and `POINT ... AT` are
|
||
invented here. The `@` suffix was not: the Go reference reserved
|
||
`IDENTIFIER_STRUCT` and never used it, and `src/grammar.c` rendered such a leaf as
|
||
`"NOT IMPLEMENTED"` until this landed. `docs/16-structures.md` is the whole feature.
|
||
|
||
**Assignment copies and `POINT` shares**, which is the decision everything else rests
|
||
on. A structure is a value like every other value here, so a program that never writes
|
||
`POINT` can never be surprised by aliasing — and the two field operators are kept
|
||
apart so a reader always knows from the spelling which they are looking at. `.` on a
|
||
pointer and `->` on a value are both errors, each naming the other.
|
||
|
||
**A declared type is what buys the storage model.** An instance has a known slot count
|
||
and is laid out exactly as an array is, out of the same value pool, so nothing new
|
||
holds data — only a table of descriptors. It also bounds copy depth statically, since a
|
||
`TYPE` cannot contain itself by value.
|
||
|
||
**Three limits are ours and are stated rather than derived:** 16 types, 16 fields per
|
||
type, and four levels of nesting in `PRINT`. The last is not a safety bound on copying —
|
||
copy stops at pointers by construction — but on *rendering*, which must follow a
|
||
pointer and would not come back on a cycle. Four rather than eight because the bound
|
||
has to bite before the 256-byte render buffer does, or a cycle stops because it ran out
|
||
of room rather than because it was told to.
|
||
|
||
**A type name shares a namespace with verbs and labels**, since all three are bare
|
||
words. Refused at declaration with a message that says so, because the parser's own
|
||
answer was "Expected expression or literal" pointing at the line rather than the
|
||
problem. Field names follow the same reserved-word rule variable names already do,
|
||
enforced by the loader's scan — `TO@` is a bad field name for exactly the reason `TO#`
|
||
is a bad variable name.
|
||
|
||
62. **A host's C struct is the same thing with its bytes somewhere else.**
|
||
`akbasic_host_register_type()` puts it in the *same* table a `TYPE` fills, so copy,
|
||
`PTR TO`, `.` and `->` all work across the boundary with no second set of rules — and
|
||
the language's own copy-versus-`POINT` distinction turns out to be exactly the
|
||
snapshot-versus-share distinction a host needs, so there is one API rather than two.
|
||
|
||
A binding takes a **shadow run** of slots; a read refreshes from host memory and a
|
||
write converts back. Conversion **refuses rather than truncates** — 70000 into an
|
||
`int16_t` names the field — and a field name's suffix must agree with the C type it
|
||
describes, refused at registration.
|
||
|
||
**It is the only pointer this interpreter holds that it did not allocate**, and that
|
||
is the one thing a host has to think about. `akbasic_host_unbind()` exists for it.
|
||
`examples/hoststruct.c` is a working host, built and run by every build.
|
||
|
||
44. **A whole loop on one line does not loop.** `DO : PRINT 1 : LOOP` runs once, exactly as
|
||
`FOR I=1 TO 3 : PRINT I : NEXT I` does. Block skipping walks *source lines*, which is the
|
||
reference's `waitingForCommand` model (§1.6), so a `LOOP` on the same line as its `DO` is
|
||
never reached as a separate step. Fixing it means making the skip operate on statements
|
||
rather than lines — a deliberate piece of work, already noted in §4 against `FOR`.
|
||
|
||
45. **`ER` and `EL` are `ER#` and `EL#`.** A C128 exposes them as bare reserved names. This
|
||
dialect cannot: an identifier carries its type in a suffix, and a name without one is a
|
||
*label*. They are written into the global scope when a trap fires, so `PRINT ER#` reads the
|
||
way the rest of the language does and needs no new syntax.
|
||
|
||
`ER#` carries the `libakerror` status code, not a Commodore error number — 515 for an
|
||
out-of-bounds subscript rather than C128 error 9. There is no correspondence to reproduce:
|
||
the errors this interpreter raises are not the errors a 1985 ROM raised. `ERR(ER#)` gives
|
||
the name, and that is the part a program should be printing anyway.
|
||
|
||
46. **`END` does not arm `CONT`.** A C128 lets `CONT` resume after `END` as well as after
|
||
`STOP`. Here `END` means the program finished, and continuing from a finished program
|
||
resumes at whatever line happens to follow — so `CONT` refuses instead. `STOP` still arms it.
|
||
|
||
### Deviations in the format verbs (group D)
|
||
|
||
47. **`PRINT USING` renders one field per statement.** BASIC 7.0 lets a format string hold
|
||
several fields and `PRINT USING` take a list of values against them. Here the *first* field
|
||
is used and any literal text around it is copied through, so `PRINT USING "A: ###"; X` works
|
||
and `PRINT USING "### ###"; X, Y` does not. Multiple fields need `PRINT` to take a value
|
||
list, which it does not yet — a separate piece of work from the field rendering itself,
|
||
which `src/format.c` does properly.
|
||
|
||
Exponential fields (`^^^^`) are not implemented either. Nothing in the language produces a
|
||
value that needs one, since `PRINT` renders a float with `%f`.
|
||
|
||
48. **`WIDTH` is emulated with parallel passes.** It sets the thickness of drawn lines, and
|
||
neither the graphics backend record nor `akgl_draw_line` takes a thickness — so a `WIDTH 2`
|
||
line is drawn twice, offset one pixel along whichever axis the line does *not* mostly run
|
||
along. Visually right at the only other value the verb accepts; a general thickness would
|
||
want a real perpendicular offset and an API that takes one.
|
||
|
||
49. **`CHAR` ignores its colour argument, and needs a sink that has a cursor.** The text sink
|
||
draws in one colour, chosen by the host when it built the sink, so a per-call colour would
|
||
have to become part of the sink interface. The argument is accepted and ignored rather than
|
||
refused, because refusing it would make every published `CHAR` line a syntax error.
|
||
|
||
Positioning is a new optional `moveto` on the sink record. A stdio sink leaves it NULL and
|
||
`CHAR` refuses by name — a terminal's cursor is not this library's to move and a pipe has no
|
||
cursor at all — so `CHAR` works in an AKGL build and says why it cannot in a stdio one.
|
||
|
||
### Deviations in the console verbs (group E)
|
||
|
||
50. **`SLEEP` and `WAIT` hold the step loop; they do not block.** §1.6 forbids blocking, so a
|
||
waiting program is one that does not *advance* — `akbasic_runtime_step()` still returns, a
|
||
bounded `akbasic_runtime_run()` still comes back on time, and an embedded game keeps its
|
||
frame rate while a script sleeps.
|
||
|
||
`SLEEP` with no host clock does nothing at all rather than waiting forever. A deadline
|
||
computed from a clock that never advances is never reached, and the first version of this
|
||
hung the test suite proving it.
|
||
|
||
51. **`TI` and `TI$` are `TI#` and `TI$`.** Same reason as `ER#`/`EL#`: no bare variable names.
|
||
They are ordinary globals refreshed once per step from the host's clock, rather than
|
||
pseudo-variables, because this dialect has no mechanism for a name that computes itself.
|
||
A host that never sets a clock gets a stopped clock rather than a wrong one.
|
||
|
||
52. **`WAIT` polls ordinary process memory.** On a C128 the byte is a hardware register an
|
||
interrupt is changing. Here nothing changes it but the host, another thread, or a `POKE`
|
||
from a `TRAP` handler — so a program that waits on a byte nobody writes waits forever, which
|
||
is exactly what the same program does on a C128 with the wrong address. Read through a
|
||
`volatile` pointer, because the whole point is that something outside this program writes it.
|
||
|
||
53. **`KEY` stores its macros and nothing expands them.** The definitions are kept and bare
|
||
`KEY` lists them, which is the half that is this library's business. Expanding a macro when
|
||
a function key is pressed belongs to whatever owns the keyboard — the frontend's line editor
|
||
— and the input backend delivers keycodes rather than editor commands.
|
||
|
||
### Deviations in the machine verbs (group J)
|
||
|
||
54. **`FETCH` and `STASH` are the same byte copy.** On a C128 they differ by which side is the
|
||
RAM Expansion Unit — `STASH` writes out to it, `FETCH` reads back. There is no expansion
|
||
unit and there are no banks, so both are `memmove` between two addresses and saying so is
|
||
better than inventing a distinction. `memmove` rather than `memcpy` because a program
|
||
shifting a buffer along by a few bytes overlaps its own source, which is a normal ask.
|
||
|
||
Addresses are real process addresses, which is the decision `POKE`, `PEEK` and `POINTER`
|
||
already made. There is no bounds check and there cannot be one: a wrong address is a
|
||
segmentation fault, not an error message.
|
||
|
||
55. **`SYS` is refused by name.** It calls machine code at an address. There is no 6502 and no
|
||
ROM to call, so there is nothing to jump to and nothing sensible to emulate — and jumping to
|
||
a real address in this process would be a way to crash it on purpose. It parses first and
|
||
refuses second, so a listing containing `SYS` still loads and still lists; only running it
|
||
fails, and it says why.
|
||
|
||
Same reasoning as `BANK`, `FAST` and `MONITOR`, with one difference: those have no table row
|
||
at all, and `SYS` has a handler because it is common enough in published listings to deserve
|
||
a specific message rather than "Unknown command".
|
||
|
||
### Deviations in the disk verbs (group F)
|
||
|
||
56. **There is no 1541, so the verbs split three ways.** The ones that mean something on a
|
||
filesystem are implemented against `aksl_f*` — `DOPEN`, `DCLOSE`, `APPEND`, `RECORD`,
|
||
`SCRATCH`, `RENAME`, `COPY`, `CONCAT`, `BSAVE`, `BLOAD`, `VERIFY`. The ones that are
|
||
spellings of verbs already here are aliases: `SAVE` is `DSAVE`, `LOAD` is `DLOAD`, `CATALOG`
|
||
is `DIRECTORY`, `DVERIFY` is `VERIFY`.
|
||
|
||
**`HEADER`, `COLLECT`, `BACKUP` and `BOOT` are refused by name**, with the reason. Each
|
||
operates on a physical disk — formatting one, validating its block allocation map,
|
||
duplicating it, booting from it — and a filesystem has no equivalent that is not a lie.
|
||
`HEADER` would have to mean "delete everything in this directory", which is a
|
||
spectacularly bad thing to do to somebody who typed a C128 verb. `DCLEAR` is the exception:
|
||
resetting a drive also closes its channels, and closing the channels is real, so that is
|
||
what it does.
|
||
|
||
57. **`DIRECTORY` is refused for want of an upstream wrapper.** `libakstdlib` has no
|
||
`aksl_opendir`, and this project's rule is that a missing capability is filed upstream
|
||
rather than worked around — so it is filed against `libakstdlib` and the verb says
|
||
so. Calling `opendir(3)` here would mean reporting through `errno` in a file where
|
||
everything else reports through an `akerr_ErrorContext *`.
|
||
|
||
58. **`PRINT #` and `INPUT #` need the space before the `#`.** A C128 writes `PRINT#1,A$`. The
|
||
scanner reads `#` as a type suffix, so `PRINT#` comes out as an identifier named `PRINT`
|
||
with an integer suffix — and a verb name carrying a suffix is refused (deviation 30). With
|
||
a space the `#` is its own token and the parse handler reads it.
|
||
|
||
59. **`RECORD` counts lines, not fixed-length records.** A C128's relative file has a record
|
||
length fixed when the file was created; a filesystem file has none. So a record is a line
|
||
and `RECORD` rewinds and reads forward to it, which is slower than a seek and is the only
|
||
definition that does not invent a record length the file does not have.
|
||
|
||
60. **`BLOAD` requires a length.** A C128 reads until the file ends. Here the address is a real
|
||
process address, so a file longer than the caller expected would write past whatever it was
|
||
pointed at with no way to notice. Refusing to guess is the only safe answer.
|
||
|
||
### Deviations in conditions
|
||
|
||
41. **A lone `=` is equality inside a condition.** `IF A# = 2 THEN` works, which it did not
|
||
before: the scanner reads `==` as EQUAL and a single `=` as ASSIGNMENT, because at that
|
||
point it cannot know whether it is looking at a statement or a condition, and the reference
|
||
never resolved the ambiguity anywhere. A condition is the one context where it has an
|
||
answer — BASIC has no assignment expression — so `akbasic_Parser::comparing` is set around
|
||
the condition and the relation parser offers ASSIGNMENT as a comparison operator only while
|
||
it is. `==` keeps working, and the whole checked-in corpus is written in it.
|
||
|
||
Outside a condition `=` has to stay an assignment, which is what the flag is for: the first
|
||
attempt put ASSIGNMENT in the operator list unconditionally, and `FOR I# = 1 TO 5` promptly
|
||
stopped initializing its counter.
|
||
|
||
42. **A condition is a whole expression, so `AND` and `OR` work in one.**
|
||
`IF A# = 5 AND B# = 3 THEN` used to report "Incomplete IF statement". The reference parses a
|
||
single *relation* after `IF`, and a relation sits below `AND` and `OR` in the grammar chain,
|
||
so the `AND` was never consumed and the `THEN` check found the wrong token.
|
||
`akbasic_parse_if()` calls `akbasic_parser_expression()` now.
|
||
|
||
43. **Truth is nonzero, not "a boolean holding true".** `akbasic_value_is_truthy()` is what a
|
||
branch tests. Commodore BASIC has no boolean type: a comparison yields -1 or 0, `AND` and
|
||
`OR` *are* the bitwise operators, and `IF A THEN` is legal for any numeric A. The reference
|
||
tests `boolvalue == -1` regardless of the value's type, so `IF A# = 1 OR B# = 2 THEN` — whose
|
||
`OR` yields an integer — was silently always false, and so was `IF A# THEN`.
|
||
|
||
The bitwise operators accept a truth value as an operand for the same reason: -1 is every
|
||
bit set precisely so that `AND` and `OR` double as the logical pair. A string is still never
|
||
true; a C128 raises a type mismatch there, which is a stricter answer this could adopt later.
|
||
|
||
### Deviations in the sprite verbs (group H)
|
||
|
||
32. **Labels are filed before the program runs, not as each `LABEL` executes.**
|
||
`akbasic_runtime_scan_labels()` walks the stored source on every entry into
|
||
`AKBASIC_MODE_RUN` and files every `LABEL <name>` it finds, textually, without parsing the
|
||
lines around it. The reference resolves a label only once its `LABEL` statement has run, so
|
||
`GOTO` and `GOSUB` reached backwards and never forwards.
|
||
|
||
**This was not a nice-to-have.** An interrupt handler by definition sits on a line normal
|
||
flow does not fall into, so `COLLISION 1, BUMPED` could not be made to work at all under the
|
||
old rule — neither resolving at arm time nor at fire time helps when the `LABEL` line is
|
||
never executed. Forward `GOTO` is the improvement that came with it, covered by
|
||
`tests/language/statements/label_forward.bas`.
|
||
|
||
`LABEL` still executes and still files itself, so a name that appears twice resolves to the
|
||
last one in the source until one of them runs. The scan is textual on purpose: parsing every
|
||
line up front would raise on lines the program would never have reached.
|
||
|
||
33. **Sprite coordinates are device pixels, not the VIC-II's 0–511 by 0–255.** `MOVSPR` takes
|
||
the same coordinate space `DRAW` does. A C128's sprite coordinates are the raster's, offset
|
||
so that (24, 50) is the top-left of the visible screen; reproducing that would give the
|
||
language two coordinate systems and make `MOVSPR 1, 0, 0` put a sprite off-screen. Consistent
|
||
with deviation 18, which is where "the same space `DRAW` uses" is defined — and it moved,
|
||
so this one moved with it.
|
||
|
||
**`SCALE` deliberately does not apply to sprites.** It is a graphics-verb transform and
|
||
the sprite verbs do not call it, which was true before deviation 18 was rewritten and is
|
||
worth writing down now that the two spaces can differ: with `SCALE 1, 319, 199` on, a
|
||
`DRAW` at 160,100 and a `MOVSPR` to 160,100 land in different places. Arguably they
|
||
should agree; not changed here because it is a decision about what `SCALE` means rather
|
||
than a slip, and it wants its own commit.
|
||
|
||
34. **`MOVSPR`'s speed unit is ours.** BASIC 7.0 documents speed 0–15 with 15 fastest and says
|
||
nothing about what a unit is worth. Here one unit is
|
||
`AKBASIC_SPRITE_SPEED_PIXELS_PER_SECOND` (5) pixels per second, so speed 15 crosses the
|
||
320-pixel screen in about four seconds. Paced off `akbasic_runtime_settime()` from
|
||
`akbasic_runtime_step()`, exactly as the `PLAY` queue is, so a host that never sets a clock
|
||
gets a still picture rather than a hang — same trade as deviation 20.
|
||
|
||
35. **`SPRSAV` takes an integer array where a C128 takes a string, and takes a file path as
|
||
well.** Three source forms, one of which is the C128's:
|
||
|
||
| Form | Source |
|
||
|---|---|
|
||
| `SPRSAV A$, n` | a region `SSHAPE` saved. The C128 documents `SPRSAV`'s string as *the `SSHAPE` data format at a fixed 24×21*, so sharing the shape pool is faithful rather than a shortcut |
|
||
| `SPRSAV A#, n` | 63 elements of a DIMmed integer array — the DATA-driven path a type-in listing uses |
|
||
| `SPRSAV "ship.png", n` | an image file |
|
||
|
||
**The array form exists because a string here cannot hold a sprite.** A value carries a
|
||
NUL-terminated `char[256]` (§1.2) and a pattern is 63 raw bytes including zeros, so the
|
||
C128's own spelling is unrepresentable. `DIM P#(63)` is exactly the right size.
|
||
|
||
**The file form is a deliberate addition, not a fallback.** A string source is a path unless
|
||
it starts with `SHAPE:`, which is the prefix `SSHAPE` mints, so the two never collide. It
|
||
resolves through `akgl_path_relative()` — the working directory first, then the directory the
|
||
running program was loaded from — and loads through `akgl_spritesheet_initialize()`, which
|
||
are the same two calls `akgl_sprite_load_json()` makes for a spritesheet. A sprite loaded
|
||
this way takes **the image's own size** rather than being forced to 24×21; a modern PC has
|
||
no reason to throw away art that is not sprite-shaped.
|
||
|
||
`akbasic_runtime_set_source_path()` is what tells the interpreter where the program came
|
||
from. Group F's disk verbs will want the same thing.
|
||
|
||
**The reverse direction is refused.** `SPRSAV n, A$` on a C128 copies a sprite *out*; here
|
||
that would mean writing an image file, which is a disk operation, and group F is
|
||
unimplemented as a whole. Refused by name rather than half-built.
|
||
|
||
36. **`COLLISION` implements type 1 only, and types 2 and 3 are refused by name.**
|
||
Sprite-to-background needs the whole render target read back and compared against each
|
||
sprite every frame; a light pen has no meaning on a machine with no light pen. Both raise
|
||
`AKBASIC_ERR_DEVICE` with the reason rather than being accepted and never firing.
|
||
|
||
Collision is **bounding-box, not pixel**. A C128's VIC-II collides on set pixels, so two
|
||
sprites whose boxes overlap but whose art does not are reported as colliding here and would
|
||
not be there. Pixel-exact collision would mean keeping every sprite's unpacked bitmap and
|
||
testing the overlap rectangle a pixel at a time; the box test is four comparisons.
|
||
`akgl_collide_rectangles()` is deliberately not used — it has a documented
|
||
corner-containment defect that misses a plus-shaped overlap, and nothing in libakgl calls it.
|
||
|
||
37. **A collision handler is entered between source lines, and must end in `RETURN`.**
|
||
`akbasic_runtime_service_interrupts()` runs at the top of `akbasic_runtime_step()` and
|
||
injects what amounts to a `GOSUB` the program did not write. Between lines is the only safe
|
||
place: a handler entered mid-statement would have to return into the middle of a line and
|
||
the parser keeps no state that could resume there. That is the same granularity block
|
||
skipping already works at (§1.6).
|
||
|
||
An interrupt does not interrupt an interrupt. A collision that is still true while its own
|
||
handler runs would otherwise re-enter on the next line until the environment pool was gone.
|
||
The event is not lost — it is taken as soon as the handler's `RETURN` lands.
|
||
|
||
38. **Sprite priority is recorded and not honoured.** `SPRITE n,,,1` sets the bit, `RSPRITE`
|
||
reads it back, and nothing draws differently. A C128 draws a low-priority sprite behind the
|
||
bitmap plane; here the text layer, the drawing surface and the sprites share one render
|
||
target and sprites are composited on top of it every frame, so there is nothing to go
|
||
behind. Honouring it would mean a separate composited surface per plane — which is the same
|
||
piece of work deviation 19 already needs.
|
||
|
||
39. **Multicolour mode is recorded and not honoured.** Same shape as priority: `SPRITE`'s
|
||
seventh argument and `SPRCOLOR`'s two registers are kept, `RSPRITE` and `RSPCOLOR` read them
|
||
back, and no sprite is drawn in multicolour. Multicolour packs *two* bitmap bits per pixel
|
||
and selects between the sprite's own colour and the two shared registers; every `SPRSAV`
|
||
source form here carries one bit per pixel or a full-colour image, so there is no second bit
|
||
to select with. The argument positions are kept honest for the day a multicolour pattern
|
||
format exists.
|
||
|
||
40. **A sprite is a real `libakgl` actor, and it is drawn by a renderfunc of ours.** Each of the
|
||
eight becomes a `SpriteSheet` + `Sprite` + `Character` + `Actor` registered in
|
||
`AKGL_REGISTRY_ACTOR`, so an embedding game sees BASIC's sprites alongside its own (goal 3).
|
||
But `akgl_actor_render()` is not what draws them. There were two reasons and **libakgl
|
||
0.5.0 fixed one**: the default used to compute its destination *height* from the sprite's
|
||
**width**, drawing a 24×21 Commodore sprite as a 24×24 square, and it now uses the height
|
||
— libakgl defect 26, filed from here and closed there.
|
||
|
||
**The reason that remains is the one that keeps this file.** An actor carries a single
|
||
scalar `scale` applied to both axes, which cannot express `SPRITE`'s separate x- and
|
||
y-expand bits, so `MOVSPR`'s twice-as-wide-same-height is unrepresentable. libakgl records
|
||
it as open and names this interpreter as the caller that needs it; the fix is a design
|
||
choice over there — `scale_x`/`scale_y` beside `scale`, or replacing `scale` and taking
|
||
the ABI break while the major is still 0 — rather than a patch. Until then
|
||
`src/sprite_akgl.c` installs its own `renderfunc`, which is libakgl's own extension point
|
||
for exactly this.
|
||
|
||
Worth keeping as a pattern: both defects were found by *using* the library for something
|
||
it had not been used for, and both were filed rather than worked around silently. One came
|
||
back fixed.
|
||
|
||
63. **Line numbers are optional in a loaded program, and a blank line is not a program line.**
|
||
**This one moved a golden file.** A line with no number used to be filed under the loader's
|
||
cursor unchanged — that is, on top of the line before it — so two unnumbered lines in a row
|
||
silently lost the first, and a *blank* line erased whatever preceded it. The reference does
|
||
the same, and `tests/language/arithmetic/integer.bas` is the proof: four `PRINT`
|
||
statements, an expectation with three values, and a trailing blank line that erased
|
||
`40 PRINT 4 - 2` before the program ran. The expectation is now `4 4 2 2` and
|
||
`tests/language/README.md` records it.
|
||
|
||
In its place: `akbasic_runtime_file_line()` (`src/runtime.c`) is the one implementation of
|
||
the rule, shared by `akbasic_runtime_load()`, RUNSTREAM and `DLOAD`. A numbered line is
|
||
filed under its number and moves the cursor; an unnumbered one takes the slot after it;
|
||
blank lines are skipped by all three. A collision involving an assigned number is refused
|
||
with `AKBASIC_ERR_BOUNDS` rather than overwriting. `akbasic_SourceLine` grew a `numbered`
|
||
flag so deviation 64 can tell the two apart.
|
||
|
||
**The prompt is deliberately untouched.** A line typed without a number is direct mode and
|
||
runs now; that is the only thing separating program text from a statement at a REPL, and it
|
||
is why this is a *loading* feature rather than a language-wide one. `AUTO` remains the way
|
||
to enter program text without typing numbers.
|
||
|
||
Consequence worth stating: an unnumbered program is capped at 9998 lines, the cap that
|
||
already existed, and its assigned numbers are increment-1 — so `LIST` and `DSAVE` show a
|
||
listing with no gaps to insert into. `RENUMBER` before `DSAVE` gives the gaps back, and
|
||
marks every line numbered.
|
||
|
||
**Not changed:** two lines that both carry the *same* written number still silently keep
|
||
the last, as they always have. That is a separate decision with its own corpus risk and it
|
||
is not this one.
|
||
|
||
64. **Branching by number to a line the program did not number is refused before it runs.**
|
||
The other half of deviation 63, and the reason `akbasic_SourceLine` carries a flag rather
|
||
than the loader just picking slots quietly. In a script written without line numbers
|
||
`GOTO 100` finds the hundredth line and branches there: plausible, silent and wrong, which
|
||
is the worst thing to hand somebody at run time.
|
||
|
||
`akbasic_runtime_check_targets()` is a fourth prescan, run beside the label, `DATA` and
|
||
`TYPE` ones on every entry into `AKBASIC_MODE_RUN` — the earliest the check can be made and
|
||
the only place all four ways a program arrives pass through. It refuses with
|
||
`AKBASIC_ERR_SYNTAX`, naming the line and saying what to do instead:
|
||
|
||
```text
|
||
? 1 : PARSE ERROR Line 1: branch to line 4, which the program did not number.
|
||
Branch by LABEL, or RENUMBER first
|
||
```
|
||
|
||
Two things are deliberately **not** refused. A target naming an *empty* line, for the same
|
||
reason `RENUMBER` leaves one alone — `GOTO 9999` in a program with no line 9999 is already
|
||
broken and inventing a rule about it would hide that. And a target in a fully numbered
|
||
program, which is every program that existed before this, so nothing checked in changed.
|
||
|
||
**It shares `RENUMBER`'s walk rather than repeating it.** `src/renumber.c` grew an
|
||
`akbasic_TargetWalk` — a `self` pointer and a `visit` function — and `rewrite_line()` now
|
||
takes one. `RENUMBER`'s visitor substitutes the number a line moved to; the check's
|
||
substitutes the number unchanged and raises if it names an assigned line. One walk, so the
|
||
two cannot disagree about what a branch target is, which they would have within a release.
|
||
|
||
Worth knowing: the check points `environment->lineno` at the line it is walking, so the
|
||
`? N :` prefix names the offending line. **The other three prescans do not**, so a
|
||
malformed `TYPE` or a bad `DATA` item still reports whichever line the loader stopped on —
|
||
usually the last line of the program. They work around it by putting `Line %d:` in the
|
||
message text, which is why the message above says the number twice. Worth fixing in
|
||
`akbasic_runtime_set_mode()`'s wrapper rather than in four places; not fixed here.
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## 6. Reference defects — **fix them; fidelity is no longer a reason not to**
|
||
|
||
These are real bugs in `deps/basicinterpret`, reproduced faithfully during the port.
|
||
|
||
**The rule that governed this section is gone.** It used to read "port the behaviour first so
|
||
the golden suite passes and the port is provably faithful, then fix each one deliberately" —
|
||
and faithfulness was the reason several of these are still here. The Go implementation is
|
||
deprecated and will not be updated (§0.1), so there is no longer anything to be faithful *to*.
|
||
Each of these is now an ordinary defect, to be judged on whether the fix is right rather than on
|
||
whether it matches.
|
||
|
||
What has not changed is the working method: one fix per commit, with a test that asserts the
|
||
correct contract, and a golden file moved in the same commit if the fix changes output.
|
||
`AKBASIC_KNOWN_FAILING_TESTS` is empty now and is kept declared for the next defect that has to
|
||
be reproduced before it can be fixed.
|
||
|
||
1. ~~**`basicvariable.go:176`** — `toString()` tests `len(self.values) == 0` and then indexes
|
||
`self.values[0]`.~~ **Never ported.** There is no `akbasic_variable_to_string`: rendering a
|
||
value is `akbasic_value_to_string()`, which switches on the value type and has no count test
|
||
to invert. The buggy function had no counterpart to reproduce.
|
||
2. ~~**`basicvariable.go:108`** — `setBoolean` builds a value with `valuetype: TYPE_STRING`.~~
|
||
**Fixed in the port.** `akbasic_value_set_bool()` sets `AKBASIC_TYPE_BOOLEAN`, and
|
||
`tests/value_compare.c` reads a boolean back through every comparison operator.
|
||
3. ~~**`basicenvironment.go:157`** — `stopWaiting(command)` ignores `command`.~~ **Fixed in
|
||
the port.** `akbasic_environment_stop_waiting()` walks the scope chain and clears the first
|
||
environment actually waiting for *that* verb, so an inner block can no longer clear an outer
|
||
block's wait. A miss is tolerated rather than raised, which is the one part of the suggested
|
||
fix not taken: `EXIT` and the end of a `DEF` body both call it speculatively.
|
||
4. ~~**`basicvalue.go:181`** — `mathPlus` mutates `self` in place when `self.mutable` is
|
||
true.~~ **Fixed.** It clones like every other operator now, so `A# + 1` cannot modify `A#`.
|
||
|
||
The gate on this item was `FOR`/`NEXT` coverage, because `NEXT`'s increment *relied* on the
|
||
mutation to advance the counter. `tests/for_next.c` is that coverage -- `STEP`, a negative
|
||
step, a float counter, a body that assigns to the counter, `EXIT`, and nesting, none of which
|
||
the golden corpus reaches -- and `akbasic_cmd_next()` now writes the incremented value back
|
||
with `akbasic_variable_set_subscript()`. Removing that write-back does not fail the suite, it
|
||
*hangs* it: the counter stops advancing and the loop never ends.
|
||
5. ~~**`basicvalue.go:191` and siblings** — every binary operator adds both of the right-hand
|
||
operand's numeric fields.~~ **Fixed.** `rval_as_int()` and `rval_as_float()` select on the
|
||
operand's type instead of summing `intval + (int64_t)floatval`.
|
||
|
||
It was filed as a landmine with no consequence today, and that is why it needed a test
|
||
written against the value API rather than against the language: no BASIC program can build a
|
||
value carrying both fields, and the old code passes every other test in the tree.
|
||
`tests/value_arithmetic.c` constructs one directly.
|
||
6. ~~**`basicvalue.go`, all comparisons** — `dest` is cloned from `self`, so the resulting
|
||
boolean inherits `self.name`.~~ **Moot.** `BasicValue.name` was dropped as dead during the
|
||
port along with `BasicEnvironment.update()`, its only reader — deviation 9 above. There is no
|
||
name for a comparison result to inherit.
|
||
7. ~~**`basicruntime_commands.go:484`** — `CommandIF` dereferences `expr.right` after the loop
|
||
guaranteed it is `nil`, so the "Malformed IF statement" check is unreachable.~~ **Fixed in
|
||
the port.** `akbasic_parse_if()` matches the `THEN` token and compares its lexeme directly,
|
||
so the check runs on every `IF`.
|
||
8. ~~**`basicruntime_commands.go:669`** — `CommandEXIT` pops the environment without
|
||
`stopWaiting`.~~ **Fixed**, and then fixed again — see item 18, which is what clearing the
|
||
wait left behind.
|
||
9. ~~**`basicruntime.go:149`** — `newVariable()` and `BasicRuntime.variables[MAX_VARIABLES]`
|
||
are dead.~~ **Not ported as dead code.** The pair is live here and is the storage model:
|
||
`akbasic_environment_create()` takes a slot from `akbasic_runtime_new_variable()`, because a
|
||
pool is what replaced Go's per-environment map (§1.3). The reference's versions were dead;
|
||
these are not.
|
||
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.~~ **Fixed in the port.** They come from a fixed pool and
|
||
`akbasic_runtime_prev_environment()` releases each one — deviation 3 above. An unreleased
|
||
scope shows up as pool exhaustion rather than as unbounded growth, which is how item 18
|
||
announced itself.
|
||
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.**~~
|
||
**Fixed**, and it is the item §0.1 unblocked. The keyword tables are now searched on the
|
||
*base* name with any type suffix stripped, so `PRINT$`, `LEN#` and `GOTO%` are refused as
|
||
variable names and the reference's own diagnostic stops being dead code. A name that merely
|
||
contains a verb — `PRINTER$` — is still fine, which is what says the check compares the
|
||
whole base name rather than a prefix. Regression tests in `tests/scanner_tokens.c`.
|
||
|
||
**It cost one golden case, exactly as predicted, and the cost turned out to be nothing.**
|
||
The reference's `examples/strreverse.bas` names a variable `INPUT$`; the variable is renamed
|
||
to `SOURCE$` in `tests/language/examples/strreverse.bas`, the expectation is byte-for-byte unchanged — the program
|
||
still prints `REVERSED: OLLEH` — and the case keeps every bit of its coverage. Recorded in
|
||
`tests/language/README.md` records the corpus editing rule.
|
||
|
||
This item sat parked because the corpus was the acceptance contract and lived in a submodule
|
||
this repository could not edit. Both premises are gone: the corpus is checked in, and
|
||
matching the Go implementation is no longer a goal. Worth keeping the history, because it is
|
||
a clean example of a fix that was correct all along and blocked entirely by a constraint
|
||
that has since expired.
|
||
|
||
### 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.
|
||
|
||
---
|
||
|
||
|
||
### Found while auditing this section against the C port
|
||
|
||
Writing the `FOR`/`NEXT` coverage that item 4 was gated on turned these up. The first two are
|
||
the reference's semantics reproduced faithfully; the third is the port's own.
|
||
|
||
18. ~~**`EXIT` on the first pass through a loop restarts the program.**~~ **Fixed.** `EXIT`
|
||
jumped to `loopExitLine`, which is only ever written by `NEXT` — so an `EXIT` before any
|
||
`NEXT` had run jumped to line 0. Each restart entered the `FOR` again and took another
|
||
environment and another variable, so what a reader saw was "Maximum runtime variables
|
||
reached" reported against the `FOR`, on a four-line program.
|
||
|
||
It now skips forward to the `NEXT` using the same `waitingForCommand` machinery a
|
||
zero-iteration body uses, and the `NEXT` pops on an `exiting` flag. Neither verb has to know
|
||
where the loop ends. `tests/for_next.c`.
|
||
|
||
19. **A `FOR` body runs once with the overshot counter**, `FOR I = 1 TO 1` does not run its
|
||
body at all, and the counter does not survive the loop. The first two compensate for a step
|
||
of 1, which is why neither was noticed. Pinned by `tests/for_semantics.c` in
|
||
`AKBASIC_KNOWN_FAILING_TESTS`; fixing it means deciding what to do with
|
||
`forloopwaitingforcommand.bas`, which depends on the current behaviour. **Issue #5.**
|
||
|
||
### Found while fixing the DEF recursion hang
|
||
|
||
27. ~~**A call leaked one variable slot per parameter.**~~ **Fixed.**
|
||
`akbasic_runtime_prev_environment()` released the scope but not the variables the scope
|
||
created, so a subroutine with a local of its own exhausted the 128-slot pool after
|
||
about 128 calls -- `Maximum runtime variables reached`, on a four-line program that
|
||
does nothing unusual.
|
||
|
||
**It was there for `GOSUB` all along**, measured on a build stashed back rather than
|
||
assumed: `FOR I# = 1 TO 300 : GOSUB 100 : NEXT` where the subroutine assigns a local
|
||
failed before this work and passes after it. Giving each `DEF` call its own scope
|
||
(item 26) simply made it reachable a second way, which is how it was found.
|
||
|
||
The release is safe because a scope's own table holds *only* what it created --
|
||
`akbasic_environment_get()` walks up to find an outer variable and returns it without
|
||
caching a reference. That is worth knowing before anyone adds caching there, and the
|
||
loop says so. It is the variable *slot* that comes back, not its storage: the value
|
||
pool never frees, so a pointer into a record `DIM`med in a scope stays sound after the
|
||
scope is gone, which is a documented property of structures rather than an accident
|
||
this could have taken away.
|
||
|
||
28. ~~**A function could not take a structure.**~~ **Done.**
|
||
`DEF AREA(S@ AS RECT)` and `DEF POKEIT(P@ AS PTR TO RECT)` now work, and a bare
|
||
`DEF F(S@)` is refused: `@` says "a structure" without saying which, so it does not
|
||
state a contract the way `S$` does. Accepting it would mean checking fields at the
|
||
call rather than at the declaration -- duck typing, and the hole naming the type
|
||
closes. **The cost is that there are no generic functions**, one `AREA` cannot serve
|
||
two types, and that is a real loss recorded rather than glossed.
|
||
|
||
Passing is by value because a parameter is bound by assignment and assignment copies;
|
||
a pointer parameter copies its reference. Neither is a special rule. A structure
|
||
parameter needs its storage prepared before the copy, which is the one thing a
|
||
primitive parameter does not -- a structure variable is a run of slots and there is
|
||
nothing to copy into until the run exists.
|
||
|
||
**`akbasic_value_is_truthy()` learned that a pointer is true when it points at
|
||
something**, which had to come with it: without it there is no way to test for the end
|
||
of a list at all, and comparing a pointer to 0 reads a numeric field it does not carry.
|
||
A *structure* is deliberately given no truth value -- it always exists, so the question
|
||
has no answer worth guessing at.
|
||
|
||
25. **A statement containing a failed multi-line `DEF` call still completes, and prints a junk
|
||
value.** `akbasic_runtime_process_line_run()` swallows a script's error by design, so the
|
||
loop inside `akbasic_runtime_user_function()` sees the run end normally. It is a decision
|
||
about what a failed call *evaluates to*. **Issue #7.**
|
||
|
||
### Found while building structures
|
||
|
||
23. ~~**A host could not run one script twice.**~~ **Fixed.**
|
||
`akbasic_runtime_start()` never rewound: `RUN` sets `nextline = 0` and clears
|
||
`stopped`, and `start()` set neither. That cost nothing while a host started a script
|
||
once, because `nextline` is already 0 the first time — and cost everything to a host
|
||
running one script per enemy, where the second `start()` began past the end and
|
||
silently did nothing at all. `start` means start now. Found by writing
|
||
`examples/hoststruct.c`, which is exactly that shape.
|
||
|
||
24. ~~**The prescan wiped host-registered types.**~~ **Fixed.** It runs again on every
|
||
`RUN` and used to reinitialise the whole table, so the first `RUN` unregistered
|
||
everything the host had registered before loading — with no way for the host to know
|
||
it had to register them again. It now drops what the *script* declared and keeps what
|
||
the *host* registered, which works because host types are always a prefix and so every
|
||
index a field already recorded keeps pointing at the same type.
|
||
`tests/hoststruct.c` pins it.
|
||
|
||
26. ~~**A `DEF` call was not re-entrant, which cost a wrong answer and a hang.**~~
|
||
**Fixed.** The function's environment was owned by the funcdef and re-initialised on
|
||
every call, so a second call trampled the first:
|
||
|
||
- **Two calls in one expression aliased one slot.** `DBL(10) + DBL(1)` was 4 rather
|
||
than 22 -- both operands became the last call's answer, because the result was a
|
||
pointer into the funcdef's own environment. Two *different* functions were fine,
|
||
which is most of why it was invisible. Same severity class as §6 item 12, and the
|
||
same shape: a silent wrong answer from a two-line program.
|
||
- **Recursion never came back.** The recursive call re-initialised the environment the
|
||
outer call was still using, so the loop waiting for control could not see it return.
|
||
No error, no bound, no diagnostic -- the one place in this interpreter that looped
|
||
forever instead of raising.
|
||
|
||
A call takes an environment from the pool now, exactly as `GOSUB` does, and the result
|
||
is copied into a caller-scope scratch before that environment goes back. `RETURN`
|
||
parks its result on the *parent* rather than on the environment about to be released,
|
||
so nothing reads a freed slot. Recursion depth answers to `AKBASIC_MAX_ENVIRONMENTS`
|
||
like every other nesting, so too deep is `Environment pool exhausted` -- a diagnosis
|
||
where there was none.
|
||
|
||
`akbasic_FunctionDef.environment` is gone with it, as dead state.
|
||
`tests/user_functions.c` and `tests/language/functions/recursion.bas`.
|
||
|
||
### Found while auditing `akbasic_value_clone()` for the structures work
|
||
|
||
22. ~~**The numeric operators' `else` was a catch-all, not a float branch.**~~ **Fixed.**
|
||
`math_minus`, `math_multiply` and `math_divide` were `if ( INTEGER ) … else <treat as
|
||
float>`, and that `else` read `floatval` from whatever it was handed. A truth value
|
||
keeps its payload in `boolvalue` and leaves `floatval` zero, so a truth value on the
|
||
*left* computed into a field nothing reads and kept its `BOOLEAN` type:
|
||
|
||
```
|
||
(A# == 1) - 1 printed true should be -2
|
||
(A# == 1) * 3 printed true should be -3
|
||
(A# == 1) + 1 correctly refused
|
||
```
|
||
|
||
Wrong in value and in type, and silent. `math_plus` escaped only because it enumerates
|
||
its cases and ends in an error; the other three now share one `require_numeric()` guard
|
||
so a type added later is refused by all of them at once instead of quietly taking the
|
||
float branch in each.
|
||
|
||
**The two operands have different rules, deliberately.** The left one picks the branch
|
||
and must be a number. The right one is read through `rval_as_int()`, which handles a
|
||
truth value on purpose — `5 - (A# == 1)` is 6, and that is the same property that lets
|
||
`AND` and `OR` double as logical operators. Refusing it on the right would have broken
|
||
every condition in the language, and `tests/language/numeric/truth_value_arithmetic.bas`
|
||
pins both halves.
|
||
|
||
**Found by asking what a structure operand would do**, since `AKBASIC_TYPE_STRUCT` would
|
||
have taken exactly this path and read a `floatval` it does not carry. The structures
|
||
work did not create this defect; it made an already-reachable one worth chasing. Same
|
||
shape as item 5 and fixed the same way, with the assertions against the value API where
|
||
the bad value can actually be constructed.
|
||
|
||
Two stale comments went with it: `src/value.c`'s file header and a duplicate block above
|
||
`rval_as_int()` both cited "TODO.md section 12", which does not exist — the defect list
|
||
is §6 — and the duplicate described the summing behaviour item 5 had already removed.
|
||
|
||
### Found while writing the error-code appendix
|
||
|
||
21. **A dependency's status code reaches `ER#` where the interpreter has one of its own.**
|
||
`VAL("XYZ")` reports `AKERR_VALUE` rather than `AKBASIC_ERR_VALUE`, and the errno-derived
|
||
number is not portable across machines where 517 would have been. A decision about a
|
||
boundary rather than a patch -- the disk verbs are the counter-case, since `ENOENT` out of
|
||
`DOPEN` is more useful than a flat 519. **Issue #13.**
|
||
|
||
### Found while making line numbers optional
|
||
|
||
26. **`akbasic_environment_set_label()` files into the active scope, not the root**, so a
|
||
prescanned label and a re-filed one behave differently. One condition, plus a test. Wants
|
||
checking against deviation 32 first. **Issue #9.**
|
||
|
||
27. **Three of the four prescans report against the wrong line**, and work around it by
|
||
putting `Line %d:` in the message text -- so the message names the number twice and the
|
||
prefix is wrong. `akbasic_runtime_check_targets()` shows the fix. **Issue #10.**
|
||
|
||
28. **Two lines carrying the same written line number still silently keep the last.** The one
|
||
collision that can appear in an existing program, so it carries corpus risk the other three
|
||
did not. **Issue #11.**
|
||
|
||
29. **`akbasic_renumber()` keeps two 9999-entry `static` locals**, so `RENUMBER` and the target
|
||
prescan are not reentrant across two runtimes in one process. Hanging them off the runtime
|
||
costs ~2.5MB per interpreter for something one verb uses; both answers are defensible.
|
||
**Issue #12.**
|
||
|
||
### Mutation-testing gaps in the files the game work touched
|
||
|
||
Recorded the way §8's CI note records `src/audio_tables.c`: a real test gap rather than a
|
||
reason to avoid the file.
|
||
|
||
34. **`src/runtime.c` has never had a complete mutation run** -- the one attempted was cut off
|
||
at 551 of 997 mutants. What it covered has been read rather than filed unopened, and three
|
||
of the four skipped-block survivors are equivalent mutants. **Issue #24.**
|
||
|
||
### Found while writing a game against the interpreter
|
||
|
||
A complete Breakout, sprites and text grid and keyboard, running for minutes at a time. Nothing
|
||
in either corpus runs for minutes, which is why the first of these had never been seen.
|
||
|
||
30. ~~**A variable created inside a scope costs pool slots that never come back, so a
|
||
long-running program has a hard budget of 4096 name creations.**~~ **Done** — a scalar now
|
||
lives in the variable rather than in the pool (`akbasic_Variable::inlinevalue`,
|
||
`src/variable.c`), so a `GOSUB` local, a `FOR` counter and a `DEF` parameter all cost
|
||
nothing at all. Twenty thousand scoped creations where four thousand used to be the whole
|
||
run. `tests/value_pool.c` is the coverage, and it asserts the mechanism as well as the
|
||
consequence — a later change that moved arrays inline too would pass every behavioural
|
||
case and break the pointer guarantee. **An array created in a scope still leaks**, which is
|
||
deliberate and is the same statement `akbasic_ValuePool` has always made: a pointer into a
|
||
record outlives the scope that DIMmed it. The record below is what was found and why.
|
||
|
||
**Also fixed with it:** `SWAP` exchanges whole variable records, so the `values` pointer
|
||
that came over named the other variable's inline slot and SWAP silently did nothing. Caught
|
||
by `tests/language/housekeeping/verbs.bas`, which is the golden corpus earning its keep.
|
||
|
||
The original report: `akbasic_ValuePool` is a bump allocator
|
||
on purpose (`include/akbasic/value.h:30`), and the reason given is that "nothing in BASIC
|
||
destroys a variable". **Scope exit destroys variables.**
|
||
`akbasic_runtime_prev_environment()` (`src/runtime.c:121`) marks a popped environment's
|
||
variables `used = false`; `akbasic_runtime_new_variable()` (`src/runtime.c:31`) `memset`s
|
||
the slot it hands back, clearing `values` and `valuecount`; and `akbasic_variable_init()`
|
||
(`src/variable.c:98`) therefore takes *fresh* slots for a variable whose old ones are still
|
||
counted against `obj->next`. Nothing decrements it, so every `GOSUB` that creates a local
|
||
leaks the local's slots for the life of the run.
|
||
|
||
Reduced, and it fails on both builds:
|
||
|
||
```basic
|
||
X# = 0
|
||
FOR T# = 1 TO 6000
|
||
GOSUB SUBA
|
||
NEXT T#
|
||
PRINT "OK " + X#
|
||
END
|
||
|
||
LABEL SUBA
|
||
LOC# = 1
|
||
X# = X# + LOC#
|
||
RETURN
|
||
```
|
||
|
||
`? 110 : RUNTIME ERROR Array of 1 elements does not fit in the 0 remaining value slots`,
|
||
at `LOC# = 1`, on the 4091st call -- 4096 less the handful of names the program itself
|
||
declared. Both builds, same line, same count. Declare `LOC#` alongside `X#` and the same
|
||
6000 calls cost nothing at all. A `FOR` counter behaves the same way: name one that does
|
||
not already exist and every entry to the loop spends a slot.
|
||
|
||
**What it costs a program:** a game loop that creates one name per tick is dead in half a
|
||
minute, and the error names the line that happened to be unlucky rather than the line that
|
||
caused it. Breakout ran twenty-five seconds before this was understood. The workaround is
|
||
real and not onerous -- declare every name, loop counters included, at the top -- but it
|
||
has to be *known*, and right now the only way to learn it is to hit it.
|
||
|
||
Three ways out, in increasing order of work. Reclaim on scope exit: give the pool a mark
|
||
and release, take the mark when an environment is pushed and rewind to it when it pops,
|
||
which is exactly the discipline the environments themselves already have and is why they
|
||
are a pool in the first place. Or keep the slice on the recycled variable: stop the
|
||
`memset` in `new_variable()` from clearing `values`/`valuecount` and let
|
||
`variable_init()`'s existing "reuse the slice when it is already big enough" branch do the
|
||
work -- much smaller, and it fixes the common case of the same-sized scalar being recreated
|
||
over and over. Or, at minimum, say so in the manual and in `value.h`'s comment, which
|
||
currently states the opposite.
|
||
|
||
Whichever is chosen, the test is a CTest program that enters and leaves a scope creating a
|
||
variable tens of thousands of times and returns zero -- and it wants to exist even for the
|
||
documentation-only outcome, pinned at whatever the real budget turns out to be. Item 33
|
||
has to be settled with it: while the pool is dry the failure cannot be trapped, so a
|
||
program cannot even report this one against itself.
|
||
|
||
31. ~~**`WINDOW` cannot be reached in the standalone AKGL build.**~~ **Done** — `tee_window()`
|
||
(`src/sink_tee.c` |