Some checks failed
akbasic CI Build / cmake_build (push) Successful in 3m2s
akbasic CI Build / sanitizers (push) Successful in 3m51s
akbasic CI Build / coverage (push) Failing after 3m24s
akbasic CI Build / akgl_build (push) Failing after 20s
akbasic CI Build / mutation_test (push) Has been cancelled
README.md was 577 lines and answered four different questions at once: what
the project is, how to build it, every verb and function in the language, and
how to maintain the test harness. The verb and function lists had already been
written a second time in docs/11 and docs/12, which is how a list of that size
goes stale -- there is no way to notice the two have drifted apart.
README.md is now 150 lines and holds only what somebody evaluating the project
needs: what it is, the quickstart, why it was rewritten in C, the five rules
embedding imposes on the design, the two ways to use it, and where everything
else lives. Technical detail goes to docs/, maintenance to MAINTENANCE.md.
The akbasic_TextSink struct moved to docs/10-embedding.md rather than being
deleted. It was the corpus's only `c excerpt=` block -- the check that caught
the stale struct two commits ago -- so dropping it with the README would have
quietly retired a test. docs/10 also stopped claiming README.md carries the
full API surface and the pool limits, which the trim made false.
CLAUDE.md went from 458 lines to 62, because almost none of it was
agent-specific. The project goals, the Go reference and its architecture, the
dependency version and ABI rules, the four ways an embedded build collides,
the libakerror convention, the error-code range map and the style rules are
all things a maintainer needs, and they are now in MAINTENANCE.md with one
copy to keep true. CLAUDE.md points there and keeps only the rules no test
enforces: tests in the same commit asserting the correct contract, file a
missing dependency capability upstream, do not edit generated output or
tests/reference/, co-author your commits.
Four claims did not survive the move, having gone stale where nothing could
notice:
- "The repository is currently empty apart from its submodules -- no
commits, no source tree, no build files." There are 43 commits.
- libakgl's target_compile_definitions(akerror PUBLIC AKERR_MAX_ERR_VALUE)
at deps/libakgl/CMakeLists.txt:44, described as inert but present. It is
gone; only a historical mention in a comment remains.
- "akbasic_init() claims 512-767." There is no akbasic_init. It is
akbasic_error_register(), called from akbasic_runtime_init().
- Time-relative phrasing ("libakgl hit two of them in the last week").
Five places pointed at CLAUDE.md for the range map or the file-it-upstream
rule and now point at MAINTENANCE.md: include/akbasic/error.h,
src/runtime_disk.c and three entries in TODO.md. Both source changes are
comments. deps/libakgl/TODO.md cites it too and is left alone; it is a
submodule, and the rule it quotes is still reachable from CLAUDE.md.
ctest is green at 95 of 95, docs_examples included: 36 programs, 9
transcripts, 44 output comparisons, 3 C snippets, 1 excerpt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1743 lines
113 KiB
Markdown
1743 lines
113 KiB
Markdown
# TODO
|
||
|
||
Implementation plan for the Go → C port of `deps/basicinterpret`.
|
||
|
||
This file is written to be executed by AI agents, not read by humans for inspiration. Every
|
||
item names the files it touches, the exact deliverable, and the command that proves it done.
|
||
Work the phases in order. Inside a phase, items with no `Depends:` line may be done in
|
||
parallel.
|
||
|
||
---
|
||
|
||
## 0. Agent protocol
|
||
|
||
### 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/reference/` becomes a regression suite rather than a 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/TODO.md` §2.1 — six **confirmed** defects in the library this port calls
|
||
into. §1.9 below says which calls are cleared for use; that section is not optional
|
||
reading, it bans a family of functions the port would otherwise reach for by reflex.
|
||
5. `deps/libakgl/AGENTS.md` — the no-`malloc` rule and the commit co-author requirement.
|
||
6. `deps/basicinterpret/README.md` — the language reference and the unimplemented list. 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 this file in the same commit as the work: strike the item, or replace it with
|
||
the defect it uncovered. This file holds outstanding items only.
|
||
- **Do** add the agent program name, model name and version as a commit co-author. That rule
|
||
comes from `libakgl` and applies here.
|
||
- **Never** hand-edit generated output. `build/` trees and the generated `akerror.h` are
|
||
off-limits; change the generator.
|
||
- **Never** reformat a file you are not otherwise changing.
|
||
- When a step is blocked because `libakgl` cannot supply a capability, **do not work around
|
||
it here.** File it in `deps/libakgl/TODO.md` in the numbered prose style of that file's
|
||
"Carried over" section and note the block in §7 below.
|
||
|
||
**Style**, restated so nobody has to go look: C99, 4-space indent, tabs at width 8
|
||
(`stroustrup`), function-body braces in column 0 on their own line, control braces on the same
|
||
line, **always brace**, spaces inside control-flow parens — `if ( x == y ) {`. Pointer star
|
||
binds to the identifier: `char *name`. Prefix is `akbasic_` for functions,
|
||
`akbasic_TypeName` for types, `AKBASIC_UPPER_SNAKE` for macros. `static` helpers drop the
|
||
prefix. Parameter names must match between header and source.
|
||
|
||
---
|
||
|
||
## 1. Design decisions already made
|
||
|
||
These are settled. Do not relitigate them mid-port; if evidence says one is wrong, say so in
|
||
a commit that changes it deliberately, and update this section.
|
||
|
||
### 1.1 Reflection becomes one aligned dispatch table
|
||
|
||
The Go runtime resolves verbs with `reflect.MethodByName("Command" + NAME)`
|
||
(`basicruntime.go:401`), functions with `"Function" + NAME`, and special parse paths with
|
||
`"ParseCommand" + NAME` (`basicparser.go:103`). C has no reflection and we are not adding
|
||
any. Replace all three with **one** static table in `src/verbs.c`, sorted by name, searched
|
||
with `bsearch(3)`:
|
||
|
||
```c
|
||
/* name token type parse handler exec handler */
|
||
{ "AUTO", AKBASIC_TOK_CMDIMM, NULL, cmd_auto },
|
||
{ "DATA", AKBASIC_TOK_COMMAND, parse_data, cmd_data },
|
||
{ "DEF", AKBASIC_TOK_COMMAND, parse_def, cmd_def },
|
||
```
|
||
|
||
A `NULL` parse handler means "parse the rval as a plain expression", which is exactly what
|
||
`commandByReflection` returning `(nil, nil)` means today. Adding a verb is adding one row plus
|
||
two functions. **Keep the table column-aligned and one row per verb** — it is a table, so it
|
||
gets laid out as one.
|
||
|
||
This also kills the Go scanner's three separate maps (`reservedwords`, `commands`,
|
||
`functions`, `basicscanner.go:64-67`): the token type lives in the same row.
|
||
|
||
### 1.2 Strings are fixed-size and live inline
|
||
|
||
`libakstdlib` has no string type. `libakgl` has `akgl_String` but it is `PATH_MAX` bytes,
|
||
refcounted, and pool-allocated — wrong shape for a value that gets copied on every
|
||
assignment, and it would drag a `libakgl` dependency into the core interpreter.
|
||
|
||
Define in `include/akbasic/types.h`:
|
||
|
||
```c
|
||
#define AKBASIC_MAX_STRING_LENGTH 256 /* matches AKBASIC_MAX_LINE_LENGTH */
|
||
```
|
||
|
||
and give `akbasic_Value` a `char stringval[AKBASIC_MAX_STRING_LENGTH]` **inline**. `clone()`
|
||
becomes a struct assignment. No allocator, no refcount, no lifetime question.
|
||
|
||
**Tradeoff, stated:** every `akbasic_Value` is ~300 bytes, so one environment's
|
||
`values[AKBASIC_MAX_VALUES]` pool is ~19KB, and 32 environments is ~610KB of BSS. That is
|
||
fine on a PC and is the price of never calling `malloc`. If it ever isn't fine, the knob is
|
||
`AKBASIC_MAX_STRING_LENGTH`, not the allocator.
|
||
|
||
Truncation is an **error**, not a silent clamp: `FAIL_RETURN(e, AKBASIC_ERR_VALUE, ...)`.
|
||
|
||
### 1.3 Maps become fixed-capacity open-addressed tables
|
||
|
||
Five Go maps need replacing:
|
||
|
||
| Go site | Purpose | C replacement |
|
||
|---|---|---|
|
||
| `BasicScanner.reservedwords/commands/functions` | keyword → token type | the §1.1 static table + `bsearch` |
|
||
| `BasicEnvironment.variables` | name → `*BasicVariable` | `akbasic_SymbolTable`, capacity `AKBASIC_MAX_VARIABLES` |
|
||
| `BasicEnvironment.functions` | name → `*BasicFunctionDef` | `akbasic_SymbolTable`, capacity `AKBASIC_MAX_FUNCTIONS` |
|
||
| `BasicEnvironment.labels` | name → line number | `akbasic_SymbolTable`, capacity `AKBASIC_MAX_LABELS` |
|
||
|
||
One implementation, `src/symtab.c`, keyed by `aksl_strhash_djb2()` (already in
|
||
`libakstdlib`) with linear probing and a fixed slot array. **Use the existing hash; do not
|
||
write another one.** Table full is an error, not a resize.
|
||
|
||
Caveat, from `deps/libakstdlib/TODO.md` §1.6/§2.2.6: the wrapper sign-extends `char`, so a
|
||
high-bit byte hashes wrong — `"\xff\xfe"` returns 5859874 where the `unsigned char` answer is
|
||
5868578. BASIC identifiers are 7-bit ASCII (the scanner only accepts `IsLetter`/`IsDigit` plus
|
||
a type suffix), so this cannot bite the symbol tables. It **would** bite if anyone later keys
|
||
a table on a string literal or a filename. Do not work around it here; it is already filed
|
||
upstream.
|
||
|
||
### 1.4 Environments come from a pool and are released
|
||
|
||
Go calls `new(BasicEnvironment)` at `basicruntime.go:121` and `basicparser_commands.go:124`
|
||
and never frees one. A long-running `GOSUB` or `FOR` in Go leaks; the GC eventually catches
|
||
some of it, and nothing in the tests notices.
|
||
|
||
C gets `HEAP_ENVIRONMENT[AKBASIC_MAX_ENVIRONMENTS]` with
|
||
`akbasic_env_acquire()` / `akbasic_env_release()`, in the shape of `akgl_heap_next_*`.
|
||
`akbasic_runtime_prev_environment()` **must** release the environment it pops. Pool
|
||
exhaustion is `AKBASIC_ERR_ENVIRONMENT`, reported with the current line number.
|
||
|
||
Watch the one place this is not a clean stack: `userFunction` (`basicruntime.go:348`) stores
|
||
a `BasicEnvironment` **by value** inside `BasicFunctionDef` and re-`init()`s it on every call.
|
||
In C the funcdef holds an `akbasic_Environment *` acquired at `DEF` time and reset per call —
|
||
it is owned by the funcdef, not the pool's free list, until the funcdef dies.
|
||
|
||
### 1.5 Output goes through a text sink backend
|
||
|
||
`Write()` and `Println()` (`basicruntime_graphics.go:140,148`) mirror every line to stdout
|
||
*and* to an SDL surface. That mirror is the only reason the golden-file suite works. Do not
|
||
reproduce it as a hardcoded pair of calls.
|
||
|
||
Define a record of function pointers, populated by an initializer — the house pattern:
|
||
|
||
```c
|
||
typedef struct akbasic_TextSink
|
||
{
|
||
void *self;
|
||
akerr_ErrorContext AKERR_NOIGNORE *(*write)(struct akbasic_TextSink *self, char *text);
|
||
akerr_ErrorContext AKERR_NOIGNORE *(*writeln)(struct akbasic_TextSink *self, char *text);
|
||
akerr_ErrorContext AKERR_NOIGNORE *(*readline)(struct akbasic_TextSink *self, char *dest, size_t len);
|
||
akerr_ErrorContext AKERR_NOIGNORE *(*clear)(struct akbasic_TextSink *self);
|
||
} akbasic_TextSink;
|
||
```
|
||
|
||
`akbasic_sink_init_stdio()` is in the library and `akbasic_sink_init_akgl()` is in the
|
||
akgl-backed module. The driver is supposed to pick: a default build selects stdio, while an
|
||
`AKBASIC_WITH_AKGL` build selects the SDL text path and mirrors its output to stdout. It does
|
||
not do that yet; §3 records the missing standalone frontend. Cursor arithmetic, wrapping and
|
||
scrolling belong to the akgl sink, not to the interpreter.
|
||
|
||
### 1.6 The interpreter steps; it does not run
|
||
|
||
Go's `run()` (`basicruntime.go:682`) is a `for {}` that owns the process until `MODE_QUIT`.
|
||
Goal 3 forbids that: a host game must be able to bound execution.
|
||
|
||
The library exposes:
|
||
|
||
```c
|
||
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_step(akbasic_Runtime *obj);
|
||
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_run(akbasic_Runtime *obj, int maxsteps);
|
||
```
|
||
|
||
`_step()` does exactly what one iteration of Go's `for {}` body does and returns.
|
||
`_run(obj, maxsteps)` loops `_step()` until the mode is `AKBASIC_MODE_QUIT` or `maxsteps`
|
||
steps have elapsed; `maxsteps <= 0` means unbounded, which is what the standalone driver
|
||
passes. **This is a deliberate restructure, and it must not change a single byte of golden
|
||
output.**
|
||
|
||
`QUIT` sets `AKBASIC_MODE_QUIT` and returns. **Nothing in the library calls `exit()`,
|
||
`abort()`, or `FINISH_NORETURN`.** `FINISH_NORETURN` appears exactly once in the tree, in
|
||
`src/main.c`.
|
||
|
||
### 1.7 Error codes are absolute, reserved from the 1.0.0 registry
|
||
|
||
`deps/libakerror` is at **1.0.0**. Read `deps/libakerror/UPGRADING.md` before writing an error
|
||
code; `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/reference/` was written against, so changing one means changing golden
|
||
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/reference/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/reference/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 reference's own corpus, checked in at `tests/reference/`.** All
|
||
41 `.bas` files are registered as individual CTest cases and byte-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
|
||
byte-identical to `basicinterpreter@d76162c`, and `tests/reference/README.md` records the
|
||
provenance, the cost of the drift nobody is watching for now, and the rule that those
|
||
expectations are never edited to suit this interpreter.
|
||
|
||
**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`.
|
||
|
||
---
|
||
|
||
## 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` fills on a negative angle rather than on a seventh argument.** 7.0's last
|
||
argument selects outline or fill and sits *after* the rotation, which would make a filled
|
||
box a seven-argument call. That is filed rather than implemented; today a rotation of zero
|
||
outlines through the renderer's rectangle and any other rotation draws four lines.
|
||
|
||
17. **`GRAPHIC` records its mode but honours only one consequence of it.** 7.0's five modes
|
||
differ in bitmap resolution and in whether the bottom of the screen stays text. Neither
|
||
means anything against a host's renderer, whose size the interpreter does not know and
|
||
does not own. The mode is stored, an out-of-range one is refused because that is a typo
|
||
worth catching, and the one observable rule is that mode 0 is text.
|
||
|
||
18. **Coordinates reaching a backend are BASIC's 320x200 space, and scaling to the window is
|
||
the host's job.** The interpreter cannot ask the renderer how big it is without owning it.
|
||
`SCALE` maps user coordinates onto that space; with `SCALE` off they pass through.
|
||
|
||
19. **`PLAY` does not block.** On a C128 `PLAY` holds the program until the last note ends.
|
||
§1.6 forbids that outright, so `PLAY` parses the string into a fixed queue and returns;
|
||
`akbasic_runtime_step()` releases one note at a time against whatever time the host last
|
||
passed to `akbasic_runtime_settime()`. **What this changes for a program:** the statement
|
||
after a `PLAY` runs immediately rather than a bar later, so a listing that relied on `PLAY`
|
||
for timing will race ahead. A program that wants to wait should test the queue rather than
|
||
assume the verb waits for it.
|
||
|
||
20. **The host owns the clock, and an unset clock rushes the music.** The library reads no
|
||
clock — it owns no loop and must not block. A host calls `akbasic_runtime_settime()` once a
|
||
frame; the standalone driver calls it every step off `CLOCK_MONOTONIC`. Left unset it reads
|
||
zero forever, so every duration has already expired and the queue drains as fast as
|
||
`step()` is called. Audible, deterministic, and never a hang, which is the right way for
|
||
that to fail.
|
||
|
||
21. **`PLAY`'s `M` (measure) is a no-op, and `SOUND`'s 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/reference/README.md`.
|
||
|
||
### 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)
|
||
|
||
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 in `deps/libakstdlib/TODO.md` 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 BASIC's 320×200, 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.
|
||
|
||
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: it computes its destination *height* from
|
||
the sprite's **width**, which would draw a 24×21 Commodore sprite as a 24×24 square, and an
|
||
actor carries a single scalar `scale` that cannot express `SPRITE`'s separate x- and
|
||
y-expand bits. `src/sprite_akgl.c` installs its own `renderfunc`, which is libakgl's own
|
||
extension point for exactly this. Both defects are filed upstream.
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## 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/reference/`, 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/reference/README.md`'s divergence table, which this is the first entry in.
|
||
|
||
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.** The loop condition is tested against
|
||
the counter *before* the increment, so the increment's result reaches the body before
|
||
anything compares it to the limit. `FOR I = 1 TO 9 STEP 3` runs its body with 1, 4, 7 **and
|
||
then 10**. Invisible whenever the step lands exactly on the limit, which is every case in
|
||
the golden corpus and every case in the reference's own tests.
|
||
|
||
Related, and the same off-by-one from the other end: `FOR I = 1 TO 1` does not run its body
|
||
at all, where every BASIC ever written runs it once. The two errors compensate for a step of
|
||
1, which is why neither was noticed.
|
||
|
||
**Not fixed, because the corpus pins it.**
|
||
`tests/reference/language/flowcontrol/forloopwaitingforcommand.bas` depends on
|
||
`FOR I# = 1 TO 1` skipping its body, and `tests/reference/README.md` forbids editing an
|
||
expectation to suit this interpreter. Registered as `tests/for_semantics.c` in
|
||
`AKBASIC_KNOWN_FAILING_TESTS`, asserting the correct contract. Fixing it means deciding what
|
||
to do with that golden file, and that is a decision rather than a patch.
|
||
|
||
20. **A `FOR` counter does not survive its loop.** It is created in the environment the loop
|
||
pushes, which pops when the loop ends, so reading it afterwards finds a fresh variable
|
||
holding zero. A C128 leaves it at the value that ended the loop and plenty of published
|
||
listings read it there. Same known-failing test as item 19; the fix is a scoping decision
|
||
about where a loop counter is created, not an ordering one.
|
||
|
||
## 7. Filing gaps against `libakgl`
|
||
|
||
When phase 7 or phase 8 needs something `libakgl` does not have, **stop and file it** in
|
||
`deps/libakgl/TODO.md` in that file's numbered prose style: what the BASIC verb requires, what
|
||
the `akgl_*` entry point should look like, and what tests would cover it. Growing `libakgl` to
|
||
serve the interpreter is a wanted outcome, not a detour.
|
||
|
||
**All ten gaps filed so far are closed upstream**, as of `libakgl` 0.3.0. Nothing here is
|
||
blocked on `libakgl`, and this repository carries no `libakgl` workarounds at all — the four
|
||
§3 used to list are deleted.
|
||
|
||
| Was blocking | Closed by |
|
||
|---|---|
|
||
| Text measurement (§3, the akgl sink) | `akgl_text_measure`, `akgl_text_measure_wrapped` |
|
||
| Immediate-mode drawing (§4 group G) | `akgl_draw_point`/`_line`/`_rect`/`_filled_rect`/`_circle`/`_flood_fill`/`_copy_region`/`_paste_region` |
|
||
| Audio (§4 group I) | `akgl_audio_init`/`_tone`/`_envelope`/`_waveform`/`_volume`/`_stop`/`_voice_active`/`_mix` |
|
||
| Console input (§4 group E) | `akgl_controller_poll_key`, `akgl_controller_flush_keys` |
|
||
| Vendored dependencies when embedded | added unconditionally, 0.3.0 |
|
||
| `controller.h` not self-contained | includes `<akgl/actor.h>`, 0.3.0 |
|
||
| Binding a 2D backend to an existing renderer | `akgl_render_bind2d()`, 0.3.0 |
|
||
| `akgl_text_rendertextat()` on an unbound backend | checked, 0.3.0 |
|
||
| `SOUND`'s frequency sweep (§5 item 21) | `akgl_audio_sweep()`, 0.3.0 |
|
||
| The line editor could not see Shift (§3) | `akgl_Keystroke` + `akgl_controller_poll_keystroke()`, 0.3.0 |
|
||
|
||
Three notes for whoever files the next one. The draw calls take an `akgl_RenderBackend *` as
|
||
their first argument rather than reaching for a global renderer, which fits the rule that the
|
||
interpreter draws through whatever renderer the host already initialized. The audio API is a
|
||
synthesised-voice one — `akgl_audio_tone(voice, hz, ms)` with a separate ADSR envelope — which
|
||
is the shape `PLAY` and `ENVELOPE` need, rather than the sample playback SDL3_mixer would have
|
||
given. And `akgl_controller_poll_key()` survives alongside the keystroke form on purpose: `GET`
|
||
and `GETKEY` still use it, because a script asking "was the up arrow pressed" wants a keycode
|
||
and would get the empty string from the other one.
|
||
|
||
**`FILTER` is the one verb still refused**, and it is not filed as a gap because upstream said
|
||
plainly what it would take: `akgl_audio_*` synthesises raw waveforms and mixes them, there is no
|
||
filter stage to configure, and SDL3 supplies no primitive to build one from. Until an
|
||
`akgl_audio_filter()` exists, `FILTER` is accepted by the parser and refused at execution with
|
||
`AKBASIC_ERR_DEVICE` rather than silently ignored — a program that asks for a low-pass and gets
|
||
an unfiltered square wave has been lied to.
|
||
|
||
**One thing went the other way**, and it is worth recording because it is the only defect this
|
||
project has ever sent upstream in a release it also consumed. 0.3.0's vendored-dependency fix
|
||
also made `libakgl`'s `add_test()` shadow unconditional. CMake chains command overrides exactly
|
||
one level deep, so two projects in one tree cannot both shadow it: the second rebinds
|
||
`_add_test` to the first and the builtin becomes unreachable to everyone, and this repository's
|
||
own test registrations recursed until CMake stopped at depth 1000. `akbasic` is the only
|
||
consumer that embeds `libakgl` next to other projects, so it is the only place this could show
|
||
up. Fixed upstream by restoring the top-level guard and filed as `libakgl` defect 27.
|
||
|
||
Building against any of it needs `-DAKBASIC_WITH_AKGL=ON`, which pulls in `libakgl`'s own
|
||
submodules (SDL and friends). Those are not initialized in a default clone; `git submodule
|
||
update --init --recursive` gets them.
|
||
|
||
---
|
||
|
||
## 8. Status
|
||
|
||
**The port is done.** The C interpreter passes the Go reference's entire corpus and passes clean
|
||
under ASan and UBSan. It reproduced that corpus byte for byte until §0.1 retired the
|
||
requirement; exactly one case has diverged on purpose since, and
|
||
`tests/reference/README.md` lists it.
|
||
|
||
| Gate | Result |
|
||
|---|---|
|
||
| `ctest` | 95/95 — 41 reference golden cases, 15 local ones, 36 unit tests, 2 embedding examples, and `docs_examples` |
|
||
| `ctest` with `-DAKBASIC_WITH_AKGL=ON` | 94/94 on a machine with a display; 93 passed + 1 skipped headless. The same set minus the three `no_device` cases the SDL driver contradicts, plus `akgl_backends`, `akgl_frontend` and `akgl_typing` — the last of which is the skip, and the `akgl_build` CI job is where it skips |
|
||
| `docs_examples` | Every fenced block in `README.md`, `MAINTENANCE.md` and `docs/` executed and byte-compared: 37 programs, 9 transcripts, 45 output comparisons, 6 C snippets, 1 excerpt and 2 shell blocks in the default build; 50 programs and 7 C snippets in the AKGL one. `MAINTENANCE.md` documents the fence-tag convention |
|
||
| Golden corpus | 41/41 byte-exact from `tests/reference/` — **and 41/41 again through the SDL binary**, which is most of what proves the frontend changes no output |
|
||
| ASan + UBSan | 77/77 |
|
||
| Line coverage | 94.6% (3857/4076) — above the 90% gate |
|
||
| Function coverage | 98.6% (287/291) |
|
||
| Warnings | none under `-Wall -Wextra` |
|
||
| `doxygen Doxyfile` | clean |
|
||
| Mutation (`src/symtab.c`) | 74.1%, against a gate of 65 — see `.gitea/workflows/ci.yaml` |
|
||
|
||
**Two defects surfaced from writing that harness**, both fixed with a test that fails when the
|
||
fix is reverted:
|
||
|
||
1. **`DLOAD` overwrote the last line of every program it loaded from a prompt.** It scans the
|
||
file through the runtime's own scanner, so on return the tokens of the calling line are gone
|
||
and `hadlinenumber` and `environment->lineno` describe the last line of the *file*. The REPL
|
||
carried on parsing what it believed was still its own buffer, decided the leftovers were
|
||
program text, and filed the `DLOAD` command under that line number.
|
||
`src/runtime_commands.c` now sets `skiprestofline`. `tests/disk_verbs.c`.
|
||
2. **A BASIC error typed at the prompt terminated the driver.** `process_line_run()` had always
|
||
swallowed the context after `interpret()` put the error line on the sink — goal 3, in the
|
||
comment, in so many words — but the direct-mode branch of `process_line_repl()` used a bare
|
||
`PASS`. A `VERIFY` against a file that did not match, which is an ordinary user answer rather
|
||
than a fault, came out as a stack trace and would have taken an embedding host with it.
|
||
`tests/housekeeping_verbs.c`.
|
||
|
||
Neither was on any list. Both were found by writing down what a chapter claimed and then
|
||
running it — which is the argument for the harness rather than a footnote to it.
|
||
|
||
Coverage of the verb groups added for goal 2, since they are the new surface: `src/audio_tables.c`
|
||
and `src/graphics_tables.c` 100%, `src/runtime_input.c` 100%, `src/runtime_graphics.c` and
|
||
`src/runtime_audio.c` 98%, `src/play.c` 87%. The `akbasic_akgl` target is not in the coverage
|
||
figure — it is not built in a default configuration.
|
||
|
||
**A note on reading that number, because it was misread once while producing it.** A stale
|
||
`build-cov/` left in the source directory from an earlier session was silently folded into the
|
||
report by `gcovr --root .`, which produced the *previous* run's 92.3% for a tree that had grown
|
||
by 800 lines. The number above was produced from a coverage tree outside the source directory
|
||
entirely, after a `find . -name '*.gcda'` came back empty. That is `libakgl` defect #13 happening here rather than there, and `build*/` being
|
||
gitignored is exactly what makes it invisible. **Keep build trees out of the source directory**,
|
||
and if a coverage number looks suspiciously unchanged, `find . -name '*.gcda'` before believing
|
||
it.
|
||
|
||
Branch coverage reads 18.0% and is not a target, for the reason `libakgl/TODO.md` and
|
||
`libakstdlib/TODO.md` both give: the akerror control-flow macros expand into large branch trees
|
||
at every call site, most of them unreachable in normal operation. Track line and function
|
||
coverage.
|
||
|
||
Dependency baseline:
|
||
|
||
| Submodule | Version | Notes |
|
||
|---|---|---|
|
||
| `deps/libakerror` | 1.0.0 | Private ownership-enforced status registry. akbasic reserves 512–767 in `akbasic_error_register()`. Does not namespace its `coverage` target when embedded — worked around in our `CMakeLists.txt`. |
|
||
| `deps/libakstdlib` | 0.2.0 | soname `libakstdlib.so.0.2`. `AKSL_VERSION_CHECK()` asserted in `tests/version_check.c`. This release fixed all six confirmed defects the port was working around — see §1.9, where the bans are now lifted. |
|
||
| `deps/libakgl` | 0.4.0 | soname `libakgl.so.0.4`. Owns status codes 256–260. Linked and tested under `-DAKBASIC_WITH_AKGL=ON`, which still defaults OFF so the core library and its whole suite build on a machine with no SDL. **0.3.0 closed every API gap this port had filed** — see §7 — so the four workarounds §3 used to list are gone. 0.4.0 is a leak-and-overread release that changed no public struct, but the soname carries `MAJOR.MINOR` while the major is 0, so rebuild rather than relink. |
|
||
|
||
**The `libakgl` requirement used to be pinned by commit, and no longer is.** 42b60f7 added 22
|
||
public symbols across four headers and left `project(akgl VERSION 0.1.0)` and the
|
||
`libakgl.so.0.1` soname unchanged, so nothing here could feature-test for the new API and the
|
||
submodule pointer was the only thing expressing the requirement. That was filed upstream as
|
||
`libakgl` defect #14 and fixed by its commit 1066ac7 — the two crossed rather than one
|
||
following the other. `libakgl` is 0.2.0 with an `libakgl.so.0.2` soname, so
|
||
`AKGL_VERSION_AT_LEAST(0, 2, 0)` now means something and an ABI mismatch is refused at load
|
||
time. Kept here because the *reason* is the reusable part: an additive release under an
|
||
unchanged soname costs its consumers the ability to detect it at all.
|
||
|
||
CI is split in two. `.gitea/workflows/ci.yaml` runs on push -- the suite, ASan+UBSan, coverage
|
||
and a two-file mutation run -- and `.gitea/workflows/release.yaml` is manual
|
||
(`workflow_dispatch`), carrying the doxygen gate and the whole-tree mutation run. The split is
|
||
about cost: the full mutation set is 3675 mutants and hours of runner time, which is a release
|
||
gate rather than a per-commit one, and the docs are wanted at release time. Three notes worth
|
||
keeping:
|
||
|
||
- The checkout is `submodules: true`, **not** `recursive`. The build needs `libakerror`,
|
||
`libakstdlib` and `basicinterpret`; it does not need `libakgl`, which is guarded behind
|
||
`AKBASIC_WITH_AKGL` and defaults OFF — and recursing into it would clone SDL, SDL_image,
|
||
SDL_mixer, SDL_ttf and jansson for a target that is never configured. The `docs` job needs
|
||
no submodules at all.
|
||
- The push-path `mutation_test` job is bounded to `src/symtab.c`, scoring 74.1% against a gate
|
||
of 65. It was two files until `src/convert.c` was deleted. `src/audio_tables.c` was measured
|
||
as a replacement and scored 64.7%, below the gate — a real gap rather than a reason to skip
|
||
the file: almost every survivor is in `akbasic_audio_state_init`, where nothing asserts that
|
||
a freshly initialised audio state is actually zeroed and defaulted. That is the same gap this
|
||
job's history records closing for `src/symtab.c`, and closing it is what earns the file its
|
||
place back in CI.
|
||
|
||
Worth knowing before reading a score: the harness's `ICR` operator only rewrites the
|
||
constants `0` and `1`, so a lookup table of other values produces no mutants at all and a
|
||
high score over one says nothing about whether its entries are right. `tests/audio_verbs.c`
|
||
asserts all 32 ADSR entries anyway — that catches a hand-edit typo, which is the real failure
|
||
mode, but it did not and could not move the mutation number. `src/value.c` is the file most
|
||
worth mutating and is deliberately not on that path: 368 mutants at roughly 11 seconds each
|
||
is about 70 minutes, because almost everything links against it. It is covered by
|
||
`release.yaml`, or locally with `cmake --build build --target mutation`.
|
||
- `release.yaml`'s threshold defaults to 65, the same as the push path, rather than something
|
||
stricter. Only two files have ever been measured; a tighter whole-tree number would be a
|
||
guess until a full run establishes a baseline. Raise it through the workflow input once one
|
||
has.
|
||
|
||
What remains, in priority order:
|
||
|
||
1. ~~**The standalone AKGL frontend in §3.**~~ **Done** — `src/frontend_akgl.c` in its own
|
||
`akbasic_frontend` target, `src/sink_tee.c` for the stdout mirror, and
|
||
`tests/akgl_frontend.c`. The build option now changes what the executable does. The one
|
||
thing not machine-verified is typing at a real focused window; §3 records why and what was
|
||
verified instead.
|
||
2. ~~**A line editor for the akgl sink.**~~ **Done** — `readline` in `src/sink_akgl.c`, over
|
||
the keystroke ring, borrowing frames from the host through an `akbasic_AkglPump`. It cannot
|
||
type a shifted character, which is `libakgl` item 10 rather than work outstanding here.
|
||
3. **§4 — the language completion work queue**, and it is the only substantial thing left.
|
||
Done: groups G and I, the `GET`/`GETKEY`/`SCNCLR` part of E, group B, multiple statements
|
||
per line, and array references in parameter lists. Left: groups A, D, F and J, plus
|
||
`RESTORE` and `RENUMBER` out of B and the sprite group H. None needs anything from
|
||
`libakgl` except H.
|
||
|
||
**Two pieces of structural work come before the verbs, and both were found rather than
|
||
guessed.** Neither is a verb and neither can be skipped by the group that needs it:
|
||
|
||
- **Block skipping works by source line, and group A needs it to work by statement.**
|
||
`waitingForCommand` skips forward to a verb one *line* at a time, so a whole loop written
|
||
on one line — `FOR I# = 1 TO 3 : PRINT I# : NEXT I#` — never reaches its `NEXT`. That is
|
||
inherited structure rather than a slip, and it did not matter until a line could hold more
|
||
than one statement. `DO`/`LOOP`/`WHILE`/`UNTIL` are exactly the verbs people write on one
|
||
line, so group A starts here or it ships something that does not work.
|
||
- **`READ` has no DATA pointer, and `RESTORE` is the verb that needs one.** §4 has the
|
||
detail; the short version is that `READ` skips forward to the next `DATA` *reached in
|
||
execution order*, so a `DATA` line placed before its `READ` is never found at all and a
|
||
second `READ` re-reads the same line. It is a live defect, not only a blocked verb.
|
||
|
||
Ordering suggestion, unchanged in spirit from the original: A (after the restructure), then
|
||
D and J, which are self-contained, then F, which is 21 verbs of `aksl_f*` and mostly
|
||
mechanical. H needs a look at `libakgl`'s sprite and actor API before anything is filed.
|
||
4. ~~**CI does not cover `-DAKBASIC_WITH_AKGL=ON`.**~~ **Done** — the `akgl_build` job in
|
||
`.gitea/workflows/ci.yaml`. It turned out much cheaper than the deferral assumed, and the
|
||
two reasons are worth keeping because both were guesses that measurement corrected:
|
||
|
||
- **`submodules: recursive` is not needed and costs 461 MB.** It descends into
|
||
`SDL_image/external`, `SDL_mixer/external` and `SDL_ttf/external` — aom, dav1d, libjxl,
|
||
libtiff, mpg123, opus, flac and a dozen more — and *not one of them is used*. Every one
|
||
configures as "Could NOT find". The job initialises exactly six submodules
|
||
non-recursively instead, which takes about 25 seconds.
|
||
- **The build is about a minute of CPU, not "a long build".** 330 object files, because
|
||
SDL3 compiles out almost every backend that is not wanted here.
|
||
|
||
The one real system dependency is `libfreetype-dev` and `libharfbuzz-dev`: SDL_ttf prefers
|
||
the system copies and would otherwise reach for `external/freetype`, which the checkout
|
||
deliberately does not clone. Two apt packages against two more submodules is an easy trade.
|
||
|
||
Every step was verified from a clean clone before being written down, not inferred.
|
||
5. ~~**§6 items 12–17.**~~ **All done**, each with a regression test in the suite that pinned
|
||
it. Item 16 was the last, and it was never a coding problem — it was blocked on the
|
||
fidelity constraint §0.1 retired, and took about ten minutes once that went away.
|
||
`AKBASIC_KNOWN_FAILING_TESTS` is empty as a result and `tests/known_reference_defects.c` is
|
||
gone.
|
||
|
||
**§6 items 1–9 and 11 are now open in a way they were not.** They were reproduced
|
||
deliberately and left alone because faithfulness was the goal; it is not any more. Read them
|
||
as an ordinary defect list. Item 4 (`mathPlus` mutates `self` in place, which `CommandNEXT`
|
||
relies on) is the one with real blast radius and still wants `FOR`/`NEXT` coverage before
|
||
anybody touches it. Item 5 (binary operators add *both* of the right operand's numeric
|
||
fields) is the one worth doing early: it is currently harmless, it makes two mutants in
|
||
`src/value.c` unkillable, and it is a landmine for any future value carrying both.
|
||
6. **Mutation survivors in `src/value.c`** — ~~closed~~, with one correction to what was
|
||
written here before. `tests/value_arithmetic.c` grew two functions,
|
||
`test_maximum_length_string()` and `test_pool_starts_empty()`, and each was verified by
|
||
hand-applying the mutant and watching the test fail rather than by assuming it would:
|
||
|
||
| Mutant | Result |
|
||
|---|---|
|
||
| `strncpy(dest->stringval, src, AKBASIC_MAX_STRING_LENGTH - 1)` → `- 2` | killed |
|
||
| `dest->stringval[AKBASIC_MAX_STRING_LENGTH - 1] = '\0'` → `- 2` | killed |
|
||
| `strncpy(..., AKBASIC_MAX_STRING_LENGTH - 1)` → `- 0` | **equivalent — cannot be killed** |
|
||
| `memset(obj, 0, sizeof(*obj))` deleted from `akbasic_valuepool_init` | killed |
|
||
| `obj->next = 0` → `= 1` | killed |
|
||
|
||
**The correction.** This entry used to say all five were real bugs and that "one test that
|
||
round-trips a 255-character string kills all five". Two things about that were wrong.
|
||
|
||
The `- 0` mutant is *equivalent*. `strncpy(dest, src, 256)` into a 256-byte buffer writes at
|
||
most 256 bytes, and `set_string()` has already refused anything longer than 255 two lines
|
||
above, so the extra byte is always the terminator `strncpy` would have written anyway. It
|
||
cannot be killed and should not be counted against the score.
|
||
|
||
And the obvious test does *not* kill the truncating mutant. Joining a full-length string to
|
||
an empty one passes with the mutant in place, because `akbasic_value_math_plus` clones
|
||
`self` into the scratch before writing and the byte a short copy fails to write is already
|
||
the right one. The operands have to sum to the limit **without either of them being the
|
||
answer** — the test uses 200 `X` plus 55 `Z`, and says so at the site, because this is
|
||
exactly the sort of thing that gets "simplified" back into a passing no-op.
|
||
|
||
Two survivors there are genuinely equivalent and cannot be killed: `rval_as_int` and
|
||
`rval_as_float` (`src/value.c:30,35`) tolerate `+` → `-` because the reference's habit of
|
||
adding *both* of the right operand's numeric fields only works when the unused one is zero
|
||
— §6 item 5. Fixing that defect would make these two mutants killable, which is a small
|
||
argument for doing it.
|
||
|
||
`src/value.c` takes about 70 minutes to mutate in full (368 mutants, ~11s each, because
|
||
almost everything links against it), which is why CI runs `src/symtab.c` instead and the
|
||
whole-tree run is a local `cmake --build build --target mutation`.
|
||
|
||
9. Use the actual full SDL window for graphics operation width
|
||
|
||
The current documentation states that graphics operations always use a 320x200 space regardless of the window's size. These verbs should be able to access the entirety of the SDL window to which they are bound.
|
||
|
||
11. Error codes are undefined for the user
|
||
|
||
In the documentation, include a complete table of all possible values of ER# and EL# in the documentation in an appendix.
|
||
|
||
12. Screenshots for graphics operations
|
||
|
||
Chapter 8 of the documentation should include screenshots of various graphics operations being performed.
|