Nothing here is built. Each is written down so the reasoning does not have to be reconstructed by whoever picks it up. **§9 item 9: the sprite breakout spends two of its eight sprites on the screen.** It draws its play field, captures the whole 800x540 region with SSHAPE, and installs the capture as sprite 2 -- and does the same for the HUD strip as sprite 1. That reads as a silly thing to do. It is not: it is the only thing that works, and it is working around items 3 and 5 together rather than choosing anything. Item 3 says the text layer owns every row by default, and that half is now answerable since WINDOW became reachable. Item 5 is what WINDOW does not fix -- the frontend never clears and SDL double-buffers, so a drawing has to be re-issued every frame and fit inside one 256-line batch. Breakout's field is sixty GSHAPE stamps plus two BOXes plus a drawn banner; it does not fit and never will. A sprite is the one thing the interpreter redraws from its own state for nothing. What that costs is now measured rather than asserted: two of eight sprite slots, which is the root of every design compromise in that game and the reason chapter 18 opens with a budget table; **4.5% of a frame in collision alone**, because sprite 2's box covers the whole field so every moving sprite overlaps it permanently and the broad-phase reject can never throw those pairs out -- 211.8 ns a scan against 54.9 for eight sprites that do not overlap, on every one of 256 scans a frame, to collide with the backdrop; and a chapter section that exists only to teach the workaround. The fix is somewhere to draw that persists and is not a sprite -- a layer the sink composites under the text and the sprites, that a program writes once and the frontend does not discard. Nothing in libakgl 0.8.0 supplies it; there is no render-to-texture layer and `frame_start` clears. Filed rather than fixed because it is a design decision about what a frame owns. **§6 items 38-40**, the follow-ups to the collision integration: - Finishing the COLLISION/BUMP migration. The proxies are deliberately not registered with a partitioner -- a uniform grid over eight of them costs more than an all-pairs loop over 28 pairs saves. What is worth recording is the *threshold*, so that raising AKBASIC_MAX_SPRITES is a decision made with the number in front of it. - Converting both breakout listings and chapters 17 and 18 to the new verbs. About 200 of their 230 collision lines are a sprite against a BASIC array, which static collision geometry is what changes. Separate because both chapters were rewritten and validated immediately before this work, their examples are executed by ctest, and folding a listing rewrite into a library integration would make neither reviewable. - `akbasic_runtime_call_function()`. A verb cannot take a BASIC function today. The finding worth keeping is that the hard half already exists at src/runtime.c:1031 -- the multi-line DEF path already re-enters the line loop from inside expression evaluation -- so this is splitting one argument-binding loop, not building a mechanism. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
3149 lines
198 KiB
Markdown
3149 lines
198 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` cannot fill at all, and the plan for it is to spell fill as a negative angle.**
|
||
7.0's last argument selects outline or fill and sits *after* the rotation, which would
|
||
make a filled box a seven-argument call — one more than `akbasic_cmd_box()` collects.
|
||
Spelling it as a negative rotation instead is the intended answer and **is not
|
||
implemented**: today a rotation of zero outlines through the renderer's rectangle and any
|
||
other rotation, negative included, draws four lines.
|
||
|
||
**This entry used to claim in bold that it fills**, with the body then saying it was
|
||
filed rather than implemented — a headline that contradicted its own paragraph, which is
|
||
exactly how a reader ends up believing a feature exists. Found by writing a
|
||
documentation figure for `BOX` and getting an outline back. `PAINT` is the fill a
|
||
program has today.
|
||
|
||
**`akbasic_GraphicsBackend::filled_rect` is dead from the language's side** as a direct
|
||
consequence: `src/graphics_akgl.c` implements it and `tests/mockdevice.h` records it, but
|
||
no BASIC verb reaches it. It is the entry point this fix would call, so it is waiting
|
||
rather than unused — worth knowing before somebody tidies it away.
|
||
|
||
17. **`GRAPHIC` records its mode but honours only one consequence of it.** 7.0's five modes
|
||
differ in bitmap resolution and in whether the bottom of the screen stays text. Neither
|
||
means anything against a host's renderer, whose size the interpreter does not own. The
|
||
mode is stored, an out-of-range one is refused because that is a typo worth catching, and
|
||
the one observable rule is that mode 0 is text. The mode is readable as `RGR(0)`, which
|
||
is 7.0's whole `RGR` and the one field of ours that is.
|
||
|
||
18. **A coordinate reaching a backend is a device pixel, and the whole of the host's window
|
||
is reachable from BASIC.** This used to read "coordinates are BASIC's 320x200 space and
|
||
scaling to the window is the host's job", on the reasoning that the interpreter cannot
|
||
ask the renderer how big it is without owning it. **That reasoning was wrong twice over**,
|
||
which is what §8 item 9 came to say.
|
||
|
||
It was wrong about the mechanism: not owning a thing is no bar to *asking* it, and the
|
||
backend record is how everything else here asks. `akbasic_GraphicsBackend` grew a `size`
|
||
entry point, `require_graphics()` calls it before every verb that draws -- so a resized
|
||
window is honoured between two statements rather than at attach -- and 320x200 became
|
||
the fallback for a backend that leaves `size` NULL. It is the record's one optional
|
||
entry point, so a host written against the old header keeps working and gets exactly the
|
||
behaviour it used to get.
|
||
|
||
And it was wrong about the description: nothing ever stretched anything. With `SCALE`
|
||
off a coordinate was passed straight to `akgl_draw_*` as a pixel address, so an 800x600
|
||
window drew a C128 listing into its top-left 320x200 corner and left the rest unused.
|
||
The documentation described a coordinate transform that did not exist.
|
||
|
||
**What changed observably** is `SCALE`, which now maps user coordinates onto the device
|
||
rather than onto the constants, and `RGR(1)` / `RGR(2)`, which report the drawing
|
||
surface's width and height so a program can use a window whose size it did not choose.
|
||
A C128 listing draws in the corner as it always did; `SCALE 1, 319, 199` gives it the
|
||
whole window.
|
||
|
||
**One fix rode along, in the same line of code and too small to defer.** `SCALE` mapped
|
||
`xmax` onto the *width*, which put the user space's far corner one pixel past the
|
||
surface: `SCALE 1, 319, 199` then `DRAW 1, 319, 199` drew nothing at all. It maps onto
|
||
the last pixel now, which is what makes that sentence above true rather than nearly
|
||
true. `tests/graphics_verbs.c` and `tests/akgl_backends.c`, the latter against a
|
||
128x128 target chosen because it is *smaller* than the old constants -- a `SCALE` still
|
||
dividing by them misses it entirely rather than landing somewhere plausible.
|
||
|
||
19. **`PLAY` does not block.** On a C128 `PLAY` holds the program until the last note ends.
|
||
§1.6 forbids that outright, so `PLAY` parses the string into a fixed queue and returns;
|
||
`akbasic_runtime_step()` releases one note at a time against whatever time the host last
|
||
passed to `akbasic_runtime_settime()`. **What this changes for a program:** the statement
|
||
after a `PLAY` runs immediately rather than a bar later, so a listing that relied on `PLAY`
|
||
for timing will race ahead. A program that wants to wait should test the queue rather than
|
||
assume the verb waits for it.
|
||
|
||
20. **The host owns the clock, and an unset clock rushes the music.** The library reads no
|
||
clock — it owns no loop and must not block. A host calls `akbasic_runtime_settime()` once a
|
||
frame; the standalone driver calls it every step off `CLOCK_MONOTONIC`. Left unset it reads
|
||
zero forever, so every duration has already expired and the queue drains as fast as
|
||
`step()` is called. Audible, deterministic, and never a hang, which is the right way for
|
||
that to fail.
|
||
|
||
21. **`PLAY`'s `M` (measure) is a no-op, and `SOUND`'s sweep runs once rather than
|
||
oscillating.** `M` synchronises the three voices on a C128; with one sequential queue there
|
||
is nothing to synchronise, and a listing full of them should still play, so it is accepted
|
||
and ignored.
|
||
|
||
The sweep *used* to be refused outright — there was no `akgl_audio_*` equivalent and faking
|
||
one would have tied audible pitch to how often the host calls us. `libakgl` 0.3.0 added
|
||
`akgl_audio_sweep()` and `SOUND voice, freq, dur, dir, min, step` now reaches it. What does
|
||
not survive is `dir` 3, "oscillate": `akgl_audio_sweep` runs one pass between two endpoints,
|
||
and a real oscillation needs the mixer to turn around at them. **What this changes for a
|
||
program:** a `dir` 3 siren rises or falls once instead of warbling. `dir` 1 and 2 are exact,
|
||
and so is the direction — both the SID and `akgl_audio_sweep` take it from which endpoint is
|
||
higher, so a `min` above the starting frequency rises whatever `dir` says.
|
||
|
||
`step` is converted as a *delta* rather than a position: it is a register increment, and the
|
||
register-to-hertz table maps positions, so it goes across as the distance between register 0
|
||
and register `step`. A step that converts to zero would never arrive and is raised to one
|
||
hertz, which is the smallest move that still gets there.
|
||
|
||
A backend whose record has no `sweep` — a host written against `libakgl` 0.2.0 — still
|
||
refuses a swept note with `AKBASIC_ERR_DEVICE` and plays a held one normally.
|
||
`tests/audio_verbs.c` asserts both halves.
|
||
|
||
22. **`TEMPO`'s constant is a calibration choice, not a transcription.** BASIC 7.0 documents
|
||
`TEMPO` as a relative duration from 1 to 255 defaulting to 8, and does not publish what a
|
||
whole note actually lasts. `src/audio_tables.c` uses 16000 ms at `TEMPO` 1, which puts a
|
||
default-tempo quarter note at 500 ms — 120 beats per minute. If the real constant turns
|
||
up, that is the one line to change.
|
||
|
||
23. **`GETKEY` holds the step loop rather than blocking.** A C128 sits on the keyboard inside
|
||
`GETKEY` until somebody types. §1.6 forbids the library blocking, so the verb sets a flag
|
||
and `akbasic_runtime_step()` declines to advance the program until a key arrives. Every
|
||
step still returns and a bounded `akbasic_runtime_run()` still comes back, so a host keeps
|
||
its frame rate; the program simply does not move past the `GETKEY`, which is the part a
|
||
program author cares about. The `PLAY` queue is serviced *before* this check on purpose —
|
||
music should keep playing while a program waits for a keypress. Withdrawing the input
|
||
device while a `GETKEY` is holding releases it rather than wedging the script forever.
|
||
|
||
24. **`GET` and `GETKEY` into a numeric variable yield the key code.** BASIC 7.0's `GET` takes
|
||
a string variable. Accepting an integer one as well, and giving it the raw code, is what a
|
||
program testing for cursor or function keys needs and costs nothing. No key is code zero,
|
||
matching what a C128 reports. A float variable is refused: it is neither a character nor a
|
||
code.
|
||
|
||
### Deviations in the standalone frontend
|
||
|
||
Items 1–12 are deviations from ported interpreter code and 13–24 from BASIC 7.0. These four are
|
||
deviations from the reference's *program*: `main.go` and the SDL half of
|
||
`basicruntime_graphics.go`.
|
||
|
||
25. **The frame loop is bounded and the reference's is not.** Go's `run()` is a `for {}` that
|
||
owns the process until `MODE_QUIT` (`basicruntime.go:682`), which is why the reference's
|
||
window never answers its close button. Here the driver runs
|
||
`AKBASIC_FRONTEND_STEPS_PER_FRAME` steps, pumps, presents, and goes round again. **What
|
||
this changes for a program:** nothing observable — a bounded run reproduces an unbounded one
|
||
exactly, and the whole golden corpus is driven through this loop to prove it. What it
|
||
changes for a *user* is that the window closes when asked.
|
||
|
||
26. **The text layer repaints every row it owns, every frame.** Not just the rows that changed.
|
||
|
||
That was tried — a `drawn[]` array marking which rows had carried glyphs, so an untouched
|
||
row could be skipped — and it is **wrong on real hardware**. `SDL_RenderPresent` swaps
|
||
buffers, so a frame does not inherit the frame before it; it inherits the one *two* back,
|
||
and on some backends something undefined. A row erased once is clean in one buffer and still
|
||
dirty in the other, and presenting alternates between them.
|
||
|
||
That is what a backspace across a line wrap looked like: the first character of the wrapped
|
||
tail flickering in and out forever after the rest had gone. It reproduces on X11 and never
|
||
under the dummy driver or a software renderer, which is exactly why the suite was green
|
||
through two rounds of fixing it. `tests/akgl_frontend.c` now paints stale pixels by hand —
|
||
which is what a swapped-in buffer hands back — so the case is covered without needing real
|
||
hardware.
|
||
|
||
There is no cheaper correct answer available: a frame either owns every pixel it presents or
|
||
it inherits pixels it cannot reason about.
|
||
|
||
**What this costs, and it is more than it used to.** The host still does not call
|
||
`SDL_RenderClear`, but the text area is now repainted in full every frame, so anything a
|
||
graphics verb drew underneath it is erased every frame rather than only where text lands. In
|
||
the standalone driver the text area is the whole window, so `DRAW` and `PRINT` no longer
|
||
coexist there.
|
||
|
||
**And the same buffer swap means the graphics verbs were never reliable across frames
|
||
anyway**, which is worth stating plainly rather than leaving as a surprise: a one-shot `DRAW`
|
||
lands in one buffer and the next present shows the other. It looked fine in a single
|
||
screenshot and would have flickered exactly as the text did. Making graphics survive needs a
|
||
persistent surface the frame is composited from, which is real work and its own commit —
|
||
filed here rather than half-done.
|
||
|
||
27. **The cursor is a blinking block.** The reference draws an underscore glyph (`drawCursor`,
|
||
`basicruntime_graphics.go:33`), and so did this until the underscore turned out to sit
|
||
*under* the text being typed and make it hard to read. A block occupies the cell after the
|
||
text instead of the space beneath it.
|
||
|
||
Half a second per blink (`AKBASIC_SINK_CURSOR_BLINK_MS`), which is about a Commodore's and
|
||
slow enough to read under. `akbasic_AkglSink.cursorperiodms` set to zero holds it solid,
|
||
which is what makes a frame deterministic for a test.
|
||
|
||
Two details that are decisions rather than accidents. The clock is **SDL's**, not the
|
||
host's: everywhere else the host owns the clock because the library owns no loop and must
|
||
not block, but this is an adaptor that already links SDL and threading a timestamp through
|
||
the sink interface to animate a cursor would be a poor trade. And a line that exactly fills
|
||
a row leaves the cursor one column past the end, because `putchar_at` wraps only when the
|
||
next character arrives — the cursor is drawn at the start of the next row instead, which is
|
||
where the next character will actually land and where a C128 puts it.
|
||
|
||
28. **The window is closed by the host, and that is not `QUIT`.** Closing the window stops the
|
||
frontend and leaves `obj->mode` alone; it does not set `AKBASIC_MODE_QUIT`. The distinction
|
||
is invisible to the standalone program, which exits either way, and load-bearing for an
|
||
embedding host: a game that closes its own window has not decided that the script is
|
||
finished, and a script that ran `QUIT` has. `tests/akgl_frontend.c` asserts both halves.
|
||
|
||
29. **Piped input still works in an AKGL build.** With a terminal on stdin the window is the
|
||
console and lines come from its editor; piped or redirected, they come from the pipe. This
|
||
is not in the reference, which always reads `os.Stdin` in REPL mode. It exists so
|
||
`basic < program.bas` behaves the same in both builds — and so the golden corpus can be
|
||
driven through the SDL binary, which is where most of the frontend's real coverage comes
|
||
from.
|
||
|
||
### Deviations that change what a program is allowed to do
|
||
|
||
30. **A verb name with a type suffix is not a variable name.** `PRINT$ = 1` is refused where
|
||
the reference accepts it, because its reserved-word check searched the keyword tables with
|
||
the suffix still attached and therefore never matched (§6 item 16). A real C128 refuses it
|
||
too, so this is the reference being wrong rather than this interpreter being strict.
|
||
|
||
**What it costs a program:** a listing that used `INPUT$`, `LEN#` or `GOTO%` as a variable
|
||
stops parsing, and the fix is to rename the variable. One case in the reference's own corpus
|
||
did exactly that; see `tests/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)
|
||
|
||
61. **Structures are an addition, not a port.** BASIC 7.0 has no records at all, so
|
||
`TYPE`/`END TYPE`, `DIM X@ AS T`, `.` and `->`, `PTR TO` and `POINT ... AT` are
|
||
invented here. The `@` suffix was not: the Go reference reserved
|
||
`IDENTIFIER_STRUCT` and never used it, and `src/grammar.c` rendered such a leaf as
|
||
`"NOT IMPLEMENTED"` until this landed. `docs/16-structures.md` is the whole feature.
|
||
|
||
**Assignment copies and `POINT` shares**, which is the decision everything else rests
|
||
on. A structure is a value like every other value here, so a program that never writes
|
||
`POINT` can never be surprised by aliasing — and the two field operators are kept
|
||
apart so a reader always knows from the spelling which they are looking at. `.` on a
|
||
pointer and `->` on a value are both errors, each naming the other.
|
||
|
||
**A declared type is what buys the storage model.** An instance has a known slot count
|
||
and is laid out exactly as an array is, out of the same value pool, so nothing new
|
||
holds data — only a table of descriptors. It also bounds copy depth statically, since a
|
||
`TYPE` cannot contain itself by value.
|
||
|
||
**Three limits are ours and are stated rather than derived:** 16 types, 16 fields per
|
||
type, and four levels of nesting in `PRINT`. The last is not a safety bound on copying —
|
||
copy stops at pointers by construction — but on *rendering*, which must follow a
|
||
pointer and would not come back on a cycle. Four rather than eight because the bound
|
||
has to bite before the 256-byte render buffer does, or a cycle stops because it ran out
|
||
of room rather than because it was told to.
|
||
|
||
**A type name shares a namespace with verbs and labels**, since all three are bare
|
||
words. Refused at declaration with a message that says so, because the parser's own
|
||
answer was "Expected expression or literal" pointing at the line rather than the
|
||
problem. Field names follow the same reserved-word rule variable names already do,
|
||
enforced by the loader's scan — `TO@` is a bad field name for exactly the reason `TO#`
|
||
is a bad variable name.
|
||
|
||
62. **A host's C struct is the same thing with its bytes somewhere else.**
|
||
`akbasic_host_register_type()` puts it in the *same* table a `TYPE` fills, so copy,
|
||
`PTR TO`, `.` and `->` all work across the boundary with no second set of rules — and
|
||
the language's own copy-versus-`POINT` distinction turns out to be exactly the
|
||
snapshot-versus-share distinction a host needs, so there is one API rather than two.
|
||
|
||
A binding takes a **shadow run** of slots; a read refreshes from host memory and a
|
||
write converts back. Conversion **refuses rather than truncates** — 70000 into an
|
||
`int16_t` names the field — and a field name's suffix must agree with the C type it
|
||
describes, refused at registration.
|
||
|
||
**It is the only pointer this interpreter holds that it did not allocate**, and that
|
||
is the one thing a host has to think about. `akbasic_host_unbind()` exists for it.
|
||
`examples/hoststruct.c` is a working host, built and run by every build.
|
||
|
||
44. **A whole loop on one line does not loop.** `DO : PRINT 1 : LOOP` runs once, exactly as
|
||
`FOR I=1 TO 3 : PRINT I : NEXT I` does. Block skipping walks *source lines*, which is the
|
||
reference's `waitingForCommand` model (§1.6), so a `LOOP` on the same line as its `DO` is
|
||
never reached as a separate step. Fixing it means making the skip operate on statements
|
||
rather than lines — a deliberate piece of work, already noted in §4 against `FOR`.
|
||
|
||
45. **`ER` and `EL` are `ER#` and `EL#`.** A C128 exposes them as bare reserved names. This
|
||
dialect cannot: an identifier carries its type in a suffix, and a name without one is a
|
||
*label*. They are written into the global scope when a trap fires, so `PRINT ER#` reads the
|
||
way the rest of the language does and needs no new syntax.
|
||
|
||
`ER#` carries the `libakerror` status code, not a Commodore error number — 515 for an
|
||
out-of-bounds subscript rather than C128 error 9. There is no correspondence to reproduce:
|
||
the errors this interpreter raises are not the errors a 1985 ROM raised. `ERR(ER#)` gives
|
||
the name, and that is the part a program should be printing anyway.
|
||
|
||
46. **`END` does not arm `CONT`.** A C128 lets `CONT` resume after `END` as well as after
|
||
`STOP`. Here `END` means the program finished, and continuing from a finished program
|
||
resumes at whatever line happens to follow — so `CONT` refuses instead. `STOP` still arms it.
|
||
|
||
### Deviations in the format verbs (group D)
|
||
|
||
47. **`PRINT USING` renders one field per statement.** BASIC 7.0 lets a format string hold
|
||
several fields and `PRINT USING` take a list of values against them. Here the *first* field
|
||
is used and any literal text around it is copied through, so `PRINT USING "A: ###"; X` works
|
||
and `PRINT USING "### ###"; X, Y` does not. Multiple fields need `PRINT` to take a value
|
||
list, which it does not yet — a separate piece of work from the field rendering itself,
|
||
which `src/format.c` does properly.
|
||
|
||
Exponential fields (`^^^^`) are not implemented either. Nothing in the language produces a
|
||
value that needs one, since `PRINT` renders a float with `%f`.
|
||
|
||
48. **`WIDTH` is emulated with parallel passes.** It sets the thickness of drawn lines, and
|
||
neither the graphics backend record nor `akgl_draw_line` takes a thickness — so a `WIDTH 2`
|
||
line is drawn twice, offset one pixel along whichever axis the line does *not* mostly run
|
||
along. Visually right at the only other value the verb accepts; a general thickness would
|
||
want a real perpendicular offset and an API that takes one.
|
||
|
||
49. **`CHAR` ignores its colour argument, and needs a sink that has a cursor.** The text sink
|
||
draws in one colour, chosen by the host when it built the sink, so a per-call colour would
|
||
have to become part of the sink interface. The argument is accepted and ignored rather than
|
||
refused, because refusing it would make every published `CHAR` line a syntax error.
|
||
|
||
Positioning is a new optional `moveto` on the sink record. A stdio sink leaves it NULL and
|
||
`CHAR` refuses by name — a terminal's cursor is not this library's to move and a pipe has no
|
||
cursor at all — so `CHAR` works in an AKGL build and says why it cannot in a stdio one.
|
||
|
||
### Deviations in the console verbs (group E)
|
||
|
||
50. **`SLEEP` and `WAIT` hold the step loop; they do not block.** §1.6 forbids blocking, so a
|
||
waiting program is one that does not *advance* — `akbasic_runtime_step()` still returns, a
|
||
bounded `akbasic_runtime_run()` still comes back on time, and an embedded game keeps its
|
||
frame rate while a script sleeps.
|
||
|
||
`SLEEP` with no host clock does nothing at all rather than waiting forever. A deadline
|
||
computed from a clock that never advances is never reached, and the first version of this
|
||
hung the test suite proving it.
|
||
|
||
51. **`TI` and `TI$` are `TI#` and `TI$`.** Same reason as `ER#`/`EL#`: no bare variable names.
|
||
They are ordinary globals refreshed once per step from the host's clock, rather than
|
||
pseudo-variables, because this dialect has no mechanism for a name that computes itself.
|
||
A host that never sets a clock gets a stopped clock rather than a wrong one.
|
||
|
||
52. **`WAIT` polls ordinary process memory.** On a C128 the byte is a hardware register an
|
||
interrupt is changing. Here nothing changes it but the host, another thread, or a `POKE`
|
||
from a `TRAP` handler — so a program that waits on a byte nobody writes waits forever, which
|
||
is exactly what the same program does on a C128 with the wrong address. Read through a
|
||
`volatile` pointer, because the whole point is that something outside this program writes it.
|
||
|
||
53. **`KEY` stores its macros and nothing expands them.** The definitions are kept and bare
|
||
`KEY` lists them, which is the half that is this library's business. Expanding a macro when
|
||
a function key is pressed belongs to whatever owns the keyboard — the frontend's line editor
|
||
— and the input backend delivers keycodes rather than editor commands.
|
||
|
||
### Deviations in the machine verbs (group J)
|
||
|
||
54. **`FETCH` and `STASH` are the same byte copy.** On a C128 they differ by which side is the
|
||
RAM Expansion Unit — `STASH` writes out to it, `FETCH` reads back. There is no expansion
|
||
unit and there are no banks, so both are `memmove` between two addresses and saying so is
|
||
better than inventing a distinction. `memmove` rather than `memcpy` because a program
|
||
shifting a buffer along by a few bytes overlaps its own source, which is a normal ask.
|
||
|
||
Addresses are real process addresses, which is the decision `POKE`, `PEEK` and `POINTER`
|
||
already made. There is no bounds check and there cannot be one: a wrong address is a
|
||
segmentation fault, not an error message.
|
||
|
||
55. **`SYS` is refused by name.** It calls machine code at an address. There is no 6502 and no
|
||
ROM to call, so there is nothing to jump to and nothing sensible to emulate — and jumping to
|
||
a real address in this process would be a way to crash it on purpose. It parses first and
|
||
refuses second, so a listing containing `SYS` still loads and still lists; only running it
|
||
fails, and it says why.
|
||
|
||
Same reasoning as `BANK`, `FAST` and `MONITOR`, with one difference: those have no table row
|
||
at all, and `SYS` has a handler because it is common enough in published listings to deserve
|
||
a specific message rather than "Unknown command".
|
||
|
||
### Deviations in the disk verbs (group F)
|
||
|
||
56. **There is no 1541, so the verbs split three ways.** The ones that mean something on a
|
||
filesystem are implemented against `aksl_f*` — `DOPEN`, `DCLOSE`, `APPEND`, `RECORD`,
|
||
`SCRATCH`, `RENAME`, `COPY`, `CONCAT`, `BSAVE`, `BLOAD`, `VERIFY`. The ones that are
|
||
spellings of verbs already here are aliases: `SAVE` is `DSAVE`, `LOAD` is `DLOAD`, `CATALOG`
|
||
is `DIRECTORY`, `DVERIFY` is `VERIFY`.
|
||
|
||
**`HEADER`, `COLLECT`, `BACKUP` and `BOOT` are refused by name**, with the reason. Each
|
||
operates on a physical disk — formatting one, validating its block allocation map,
|
||
duplicating it, booting from it — and a filesystem has no equivalent that is not a lie.
|
||
`HEADER` would have to mean "delete everything in this directory", which is a
|
||
spectacularly bad thing to do to somebody who typed a C128 verb. `DCLEAR` is the exception:
|
||
resetting a drive also closes its channels, and closing the channels is real, so that is
|
||
what it does.
|
||
|
||
57. **`DIRECTORY` is refused for want of an upstream wrapper.** `libakstdlib` has no
|
||
`aksl_opendir`, and this project's rule is that a missing capability is filed upstream
|
||
rather than worked around — so it is filed 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 device pixels, not the VIC-II's 0–511 by 0–255.** `MOVSPR` takes
|
||
the same coordinate space `DRAW` does. A C128's sprite coordinates are the raster's, offset
|
||
so that (24, 50) is the top-left of the visible screen; reproducing that would give the
|
||
language two coordinate systems and make `MOVSPR 1, 0, 0` put a sprite off-screen. Consistent
|
||
with deviation 18, which is where "the same space `DRAW` uses" is defined — and it moved,
|
||
so this one moved with it.
|
||
|
||
**`SCALE` deliberately does not apply to sprites.** It is a graphics-verb transform and
|
||
the sprite verbs do not call it, which was true before deviation 18 was rewritten and is
|
||
worth writing down now that the two spaces can differ: with `SCALE 1, 319, 199` on, a
|
||
`DRAW` at 160,100 and a `MOVSPR` to 160,100 land in different places. Arguably they
|
||
should agree; not changed here because it is a decision about what `SCALE` means rather
|
||
than a slip, and it wants its own commit.
|
||
|
||
34. **`MOVSPR`'s speed unit is ours.** BASIC 7.0 documents speed 0–15 with 15 fastest and says
|
||
nothing about what a unit is worth. Here one unit is
|
||
`AKBASIC_SPRITE_SPEED_PIXELS_PER_SECOND` (5) pixels per second, so speed 15 crosses the
|
||
320-pixel screen in about four seconds. Paced off `akbasic_runtime_settime()` from
|
||
`akbasic_runtime_step()`, exactly as the `PLAY` queue is, so a host that never sets a clock
|
||
gets a still picture rather than a hang — same trade as deviation 20.
|
||
|
||
35. **`SPRSAV` takes an integer array where a C128 takes a string, and takes a file path as
|
||
well.** Three source forms, one of which is the C128's:
|
||
|
||
| Form | Source |
|
||
|---|---|
|
||
| `SPRSAV A$, n` | a region `SSHAPE` saved. The C128 documents `SPRSAV`'s string as *the `SSHAPE` data format at a fixed 24×21*, so sharing the shape pool is faithful rather than a shortcut |
|
||
| `SPRSAV A#, n` | 63 elements of a DIMmed integer array — the DATA-driven path a type-in listing uses |
|
||
| `SPRSAV "ship.png", n` | an image file |
|
||
|
||
**The array form exists because a string here cannot hold a sprite.** A value carries a
|
||
NUL-terminated `char[256]` (§1.2) and a pattern is 63 raw bytes including zeros, so the
|
||
C128's own spelling is unrepresentable. `DIM P#(63)` is exactly the right size.
|
||
|
||
**The file form is a deliberate addition, not a fallback.** A string source is a path unless
|
||
it starts with `SHAPE:`, which is the prefix `SSHAPE` mints, so the two never collide. It
|
||
resolves through `akgl_path_relative()` — the working directory first, then the directory the
|
||
running program was loaded from — and loads through `akgl_spritesheet_initialize()`, which
|
||
are the same two calls `akgl_sprite_load_json()` makes for a spritesheet. A sprite loaded
|
||
this way takes **the image's own size** rather than being forced to 24×21; a modern PC has
|
||
no reason to throw away art that is not sprite-shaped.
|
||
|
||
`akbasic_runtime_set_source_path()` is what tells the interpreter where the program came
|
||
from. Group F's disk verbs will want the same thing.
|
||
|
||
**The reverse direction is refused.** `SPRSAV n, A$` on a C128 copies a sprite *out*; here
|
||
that would mean writing an image file, which is a disk operation, and group F is
|
||
unimplemented as a whole. Refused by name rather than half-built.
|
||
|
||
36. **`COLLISION` implements type 1 only, and types 2 and 3 are refused by name.**
|
||
Sprite-to-background needs the whole render target read back and compared against each
|
||
sprite every frame; a light pen has no meaning on a machine with no light pen. Both raise
|
||
`AKBASIC_ERR_DEVICE` with the reason rather than being accepted and never firing.
|
||
|
||
Collision is **bounding-box, not pixel**. A C128's VIC-II collides on set pixels, so two
|
||
sprites whose boxes overlap but whose art does not are reported as colliding here and would
|
||
not be there. Pixel-exact collision would mean keeping every sprite's unpacked bitmap and
|
||
testing the overlap rectangle a pixel at a time; the box test is four comparisons.
|
||
`akgl_collide_rectangles()` is deliberately not used — it has a documented
|
||
corner-containment defect that misses a plus-shaped overlap, and nothing in libakgl calls it.
|
||
|
||
37. **A collision handler is entered between source lines, and must end in `RETURN`.**
|
||
`akbasic_runtime_service_interrupts()` runs at the top of `akbasic_runtime_step()` and
|
||
injects what amounts to a `GOSUB` the program did not write. Between lines is the only safe
|
||
place: a handler entered mid-statement would have to return into the middle of a line and
|
||
the parser keeps no state that could resume there. That is the same granularity block
|
||
skipping already works at (§1.6).
|
||
|
||
An interrupt does not interrupt an interrupt. A collision that is still true while its own
|
||
handler runs would otherwise re-enter on the next line until the environment pool was gone.
|
||
The event is not lost — it is taken as soon as the handler's `RETURN` lands.
|
||
|
||
38. **Sprite priority is recorded and not honoured.** `SPRITE n,,,1` sets the bit, `RSPRITE`
|
||
reads it back, and nothing draws differently. A C128 draws a low-priority sprite behind the
|
||
bitmap plane; here the text layer, the drawing surface and the sprites share one render
|
||
target and sprites are composited on top of it every frame, so there is nothing to go
|
||
behind. Honouring it would mean a separate composited surface per plane — which is the same
|
||
piece of work deviation 19 already needs.
|
||
|
||
39. **Multicolour mode is recorded and not honoured.** Same shape as priority: `SPRITE`'s
|
||
seventh argument and `SPRCOLOR`'s two registers are kept, `RSPRITE` and `RSPCOLOR` read them
|
||
back, and no sprite is drawn in multicolour. Multicolour packs *two* bitmap bits per pixel
|
||
and selects between the sprite's own colour and the two shared registers; every `SPRSAV`
|
||
source form here carries one bit per pixel or a full-colour image, so there is no second bit
|
||
to select with. The argument positions are kept honest for the day a multicolour pattern
|
||
format exists.
|
||
|
||
40. **A sprite is a real `libakgl` actor, and it is drawn by a renderfunc of ours.** Each of the
|
||
eight becomes a `SpriteSheet` + `Sprite` + `Character` + `Actor` registered in
|
||
`AKGL_REGISTRY_ACTOR`, so an embedding game sees BASIC's sprites alongside its own (goal 3).
|
||
But `akgl_actor_render()` is not what draws them. There were two reasons and **libakgl
|
||
0.5.0 fixed one**: the default used to compute its destination *height* from the sprite's
|
||
**width**, drawing a 24×21 Commodore sprite as a 24×24 square, and it now uses the height
|
||
— libakgl defect 26, filed from here and closed there.
|
||
|
||
**The reason that remains is the one that keeps this file.** An actor carries a single
|
||
scalar `scale` applied to both axes, which cannot express `SPRITE`'s separate x- and
|
||
y-expand bits, so `MOVSPR`'s twice-as-wide-same-height is unrepresentable. libakgl records
|
||
it as open and names this interpreter as the caller that needs it; the fix is a design
|
||
choice over there — `scale_x`/`scale_y` beside `scale`, or replacing `scale` and taking
|
||
the ABI break while the major is still 0 — rather than a patch. Until then
|
||
`src/sprite_akgl.c` installs its own `renderfunc`, which is libakgl's own extension point
|
||
for exactly this.
|
||
|
||
Worth keeping as a pattern: both defects were found by *using* the library for something
|
||
it had not been used for, and both were filed rather than worked around silently. One came
|
||
back fixed.
|
||
|
||
63. **Line numbers are optional in a loaded program, and a blank line is not a program line.**
|
||
**This one moved a golden file.** A line with no number used to be filed under the loader's
|
||
cursor unchanged — that is, on top of the line before it — so two unnumbered lines in a row
|
||
silently lost the first, and a *blank* line erased whatever preceded it. The reference does
|
||
the same, and `tests/reference/language/arithmetic/integer.bas` is the proof: four `PRINT`
|
||
statements, an expectation with three values, and a trailing blank line that erased
|
||
`40 PRINT 4 - 2` before the program ran. The expectation is now `4 4 2 2` and
|
||
`tests/reference/README.md` records it.
|
||
|
||
In its place: `akbasic_runtime_file_line()` (`src/runtime.c`) is the one implementation of
|
||
the rule, shared by `akbasic_runtime_load()`, RUNSTREAM and `DLOAD`. A numbered line is
|
||
filed under its number and moves the cursor; an unnumbered one takes the slot after it;
|
||
blank lines are skipped by all three. A collision involving an assigned number is refused
|
||
with `AKBASIC_ERR_BOUNDS` rather than overwriting. `akbasic_SourceLine` grew a `numbered`
|
||
flag so deviation 64 can tell the two apart.
|
||
|
||
**The prompt is deliberately untouched.** A line typed without a number is direct mode and
|
||
runs now; that is the only thing separating program text from a statement at a REPL, and it
|
||
is why this is a *loading* feature rather than a language-wide one. `AUTO` remains the way
|
||
to enter program text without typing numbers.
|
||
|
||
Consequence worth stating: an unnumbered program is capped at 9998 lines, the cap that
|
||
already existed, and its assigned numbers are increment-1 — so `LIST` and `DSAVE` show a
|
||
listing with no gaps to insert into. `RENUMBER` before `DSAVE` gives the gaps back, and
|
||
marks every line numbered.
|
||
|
||
**Not changed:** two lines that both carry the *same* written number still silently keep
|
||
the last, as they always have. That is a separate decision with its own corpus risk and it
|
||
is not this one.
|
||
|
||
64. **Branching by number to a line the program did not number is refused before it runs.**
|
||
The other half of deviation 63, and the reason `akbasic_SourceLine` carries a flag rather
|
||
than the loader just picking slots quietly. In a script written without line numbers
|
||
`GOTO 100` finds the hundredth line and branches there: plausible, silent and wrong, which
|
||
is the worst thing to hand somebody at run time.
|
||
|
||
`akbasic_runtime_check_targets()` is a fourth prescan, run beside the label, `DATA` and
|
||
`TYPE` ones on every entry into `AKBASIC_MODE_RUN` — the earliest the check can be made and
|
||
the only place all four ways a program arrives pass through. It refuses with
|
||
`AKBASIC_ERR_SYNTAX`, naming the line and saying what to do instead:
|
||
|
||
```text
|
||
? 1 : PARSE ERROR Line 1: branch to line 4, which the program did not number.
|
||
Branch by LABEL, or RENUMBER first
|
||
```
|
||
|
||
Two things are deliberately **not** refused. A target naming an *empty* line, for the same
|
||
reason `RENUMBER` leaves one alone — `GOTO 9999` in a program with no line 9999 is already
|
||
broken and inventing a rule about it would hide that. And a target in a fully numbered
|
||
program, which is every program that existed before this, so nothing checked in changed.
|
||
|
||
**It shares `RENUMBER`'s walk rather than repeating it.** `src/renumber.c` grew an
|
||
`akbasic_TargetWalk` — a `self` pointer and a `visit` function — and `rewrite_line()` now
|
||
takes one. `RENUMBER`'s visitor substitutes the number a line moved to; the check's
|
||
substitutes the number unchanged and raises if it names an assigned line. One walk, so the
|
||
two cannot disagree about what a branch target is, which they would have within a release.
|
||
|
||
Worth knowing: the check points `environment->lineno` at the line it is walking, so the
|
||
`? N :` prefix names the offending line. **The other three prescans do not**, so a
|
||
malformed `TYPE` or a bad `DATA` item still reports whichever line the loader stopped on —
|
||
usually the last line of the program. They work around it by putting `Line %d:` in the
|
||
message text, which is why the message above says the number twice. Worth fixing in
|
||
`akbasic_runtime_set_mode()`'s wrapper rather than in four places; not fixed here.
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## 6. Reference defects — **fix them; fidelity is no longer a reason not to**
|
||
|
||
These are real bugs in `deps/basicinterpret`, reproduced faithfully during the port.
|
||
|
||
**The rule that governed this section is gone.** It used to read "port the behaviour first so
|
||
the golden suite passes and the port is provably faithful, then fix each one deliberately" —
|
||
and faithfulness was the reason several of these are still here. The Go implementation is
|
||
deprecated and will not be updated (§0.1), so there is no longer anything to be faithful *to*.
|
||
Each of these is now an ordinary defect, to be judged on whether the fix is right rather than on
|
||
whether it matches.
|
||
|
||
What has not changed is the working method: one fix per commit, with a test that asserts the
|
||
correct contract, and a golden file moved in the same commit if the fix changes output.
|
||
`AKBASIC_KNOWN_FAILING_TESTS` is empty now and is kept declared for the next defect that has to
|
||
be reproduced before it can be fixed.
|
||
|
||
1. ~~**`basicvariable.go:176`** — `toString()` tests `len(self.values) == 0` and then indexes
|
||
`self.values[0]`.~~ **Never ported.** There is no `akbasic_variable_to_string`: rendering a
|
||
value is `akbasic_value_to_string()`, which switches on the value type and has no count test
|
||
to invert. The buggy function had no counterpart to reproduce.
|
||
2. ~~**`basicvariable.go:108`** — `setBoolean` builds a value with `valuetype: TYPE_STRING`.~~
|
||
**Fixed in the port.** `akbasic_value_set_bool()` sets `AKBASIC_TYPE_BOOLEAN`, and
|
||
`tests/value_compare.c` reads a boolean back through every comparison operator.
|
||
3. ~~**`basicenvironment.go:157`** — `stopWaiting(command)` ignores `command`.~~ **Fixed in
|
||
the port.** `akbasic_environment_stop_waiting()` walks the scope chain and clears the first
|
||
environment actually waiting for *that* verb, so an inner block can no longer clear an outer
|
||
block's wait. A miss is tolerated rather than raised, which is the one part of the suggested
|
||
fix not taken: `EXIT` and the end of a `DEF` body both call it speculatively.
|
||
4. ~~**`basicvalue.go:181`** — `mathPlus` mutates `self` in place when `self.mutable` is
|
||
true.~~ **Fixed.** It clones like every other operator now, so `A# + 1` cannot modify `A#`.
|
||
|
||
The gate on this item was `FOR`/`NEXT` coverage, because `NEXT`'s increment *relied* on the
|
||
mutation to advance the counter. `tests/for_next.c` is that coverage -- `STEP`, a negative
|
||
step, a float counter, a body that assigns to the counter, `EXIT`, and nesting, none of which
|
||
the golden corpus reaches -- and `akbasic_cmd_next()` now writes the incremented value back
|
||
with `akbasic_variable_set_subscript()`. Removing that write-back does not fail the suite, it
|
||
*hangs* it: the counter stops advancing and the loop never ends.
|
||
5. ~~**`basicvalue.go:191` and siblings** — every binary operator adds both of the right-hand
|
||
operand's numeric fields.~~ **Fixed.** `rval_as_int()` and `rval_as_float()` select on the
|
||
operand's type instead of summing `intval + (int64_t)floatval`.
|
||
|
||
It was filed as a landmine with no consequence today, and that is why it needed a test
|
||
written against the value API rather than against the language: no BASIC program can build a
|
||
value carrying both fields, and the old code passes every other test in the tree.
|
||
`tests/value_arithmetic.c` constructs one directly.
|
||
6. ~~**`basicvalue.go`, all comparisons** — `dest` is cloned from `self`, so the resulting
|
||
boolean inherits `self.name`.~~ **Moot.** `BasicValue.name` was dropped as dead during the
|
||
port along with `BasicEnvironment.update()`, its only reader — deviation 9 above. There is no
|
||
name for a comparison result to inherit.
|
||
7. ~~**`basicruntime_commands.go:484`** — `CommandIF` dereferences `expr.right` after the loop
|
||
guaranteed it is `nil`, so the "Malformed IF statement" check is unreachable.~~ **Fixed in
|
||
the port.** `akbasic_parse_if()` matches the `THEN` token and compares its lexeme directly,
|
||
so the check runs on every `IF`.
|
||
8. ~~**`basicruntime_commands.go:669`** — `CommandEXIT` pops the environment without
|
||
`stopWaiting`.~~ **Fixed**, and then fixed again — see item 18, which is what clearing the
|
||
wait left behind.
|
||
9. ~~**`basicruntime.go:149`** — `newVariable()` and `BasicRuntime.variables[MAX_VARIABLES]`
|
||
are dead.~~ **Not ported as dead code.** The pair is live here and is the storage model:
|
||
`akbasic_environment_create()` takes a slot from `akbasic_runtime_new_variable()`, because a
|
||
pool is what replaced Go's per-environment map (§1.3). The reference's versions were dead;
|
||
these are not.
|
||
10. ~~**`basicgrammar.go:224`** — `newLiteralInt` selects base 8 whenever the lexeme starts
|
||
with `0`.~~ **Fixed.** Base 10 unless prefixed `0x`; a leading zero in a listing is padding,
|
||
not a radix. `PRINT 010` prints 10 and `PRINT 08` parses. Nothing in the upstream corpus
|
||
had a leading-zero literal, which is why nothing there noticed. Regression tests in
|
||
`tests/grammar_leaves.c` and `tests/language/numeric/octal_literal.bas`, which was rewritten
|
||
from pinning the defect to pinning the fix.
|
||
11. ~~**`basicruntime.go:121` / `basicparser_commands.go:124`** — environments are never
|
||
freed.~~ **Fixed in the port.** They come from a fixed pool and
|
||
`akbasic_runtime_prev_environment()` releases each one — deviation 3 above. An unreleased
|
||
scope shows up as pool exhaustion rather than as unbounded growth, which is how item 18
|
||
announced itself.
|
||
12. ~~**`basicparser.go:329` — `subtraction()` returns after one operator where `addition()`
|
||
loops.**~~ **Fixed.** `1 - 2 - 3` computed `1 - 2` and abandoned the rest of the line; the
|
||
remaining `- 3` was left in the token stream, picked up by the statement loop as a second
|
||
statement, evaluated and discarded. A wrong answer, silently, which made this the
|
||
highest-impact item on the list. `exponent()` has the identical shape — a `for` loop with a
|
||
`return` inside it, in both the reference and the port — so `2 ^ 3 ^ 2` had it too. Both
|
||
now fold left, which is what a C128 does with a run of same-precedence operators.
|
||
Regression tests in `tests/parser_expressions.c`.
|
||
|
||
Worth recording for whoever reads the reference next: `exponent()` there is
|
||
`for self.match(CARAT) { ... return expr, nil }` (`basicparser.go:519-539`), so the loop is
|
||
a loop in shape only. `subtraction()` is the same at `:357-377`. Neither is a
|
||
transcription slip in the port.
|
||
|
||
13. ~~**`basicparser.go:565` — a unary-minus argument inflates a function's arity count.**~~
|
||
**Fixed**, and the fix is structural rather than a smarter counter. The counter walked
|
||
`.right`, which is where a unary leaf keeps its operand *and* where an argument list chains
|
||
its arguments; the two meanings were indistinguishable, so `ABS(-9)` counted as two
|
||
arguments and was refused with "function ABS takes 1 arguments, received 2". No builtin
|
||
could be called with a negative literal at all. The corpus hid it — `sgn.bas` assigns `-1`
|
||
to a variable first.
|
||
|
||
**A unary leaf's operand now hangs off `.left`.** A unary leaf had no other use for that
|
||
field, so the two meanings separate for the cost of one nobody was using — and counting
|
||
then needs no special case at all. That also fixes the half of the defect the original
|
||
entry did not mention: chaining the *next* argument through `.right` overwrote the operand,
|
||
so `MOD(-7, 3)` destroyed its own first argument. Three readers moved (`src/runtime.c`,
|
||
`src/grammar.c`'s renderer, `src/runtime_commands.c`'s `LIST` range parser) and that is the
|
||
whole change. Regression tests in `tests/runtime_evaluate.c`, both halves.
|
||
|
||
**The same collision remains for array subscripts**, which is why §4 still lists
|
||
"array references in parameter lists" as outstanding: an identifier keeps its subscript
|
||
list on `.right` too. The same move — onto `.left` — is very likely the fix, and it is not
|
||
made here because it wants its own commit and its own tests.
|
||
|
||
14. ~~**`basicscanner.go:272` — `matchNextChar` returns without setting a token type when it
|
||
cannot peek past the end of the line.**~~ **Fixed.** A comparison operator in the final
|
||
column was silently dropped: `A# =` produced one token, not two, and the parse error that
|
||
followed pointed somewhere else entirely. `falsetype` is now set before returning on the
|
||
peek failure. Regression tests in `tests/scanner_tokens.c`, for `=`, `<` and `>`.
|
||
|
||
15. ~~**`basicscanner.go:318` — hex literals do not survive the scanner.**~~ **Fixed.**
|
||
`matchNumber` let `x` through but not the hex digits after it, so `0xff` lexed as `0x`
|
||
followed by an identifier `ff`, the base-16 branch in `newLiteralInt` was unreachable, and
|
||
the README's hex support did not exist. Once the `x` is seen the run continues over hex
|
||
digits. Accepted anywhere in a number run rather than only after a leading `0`, which is
|
||
the reference's rule and is worth keeping: it is what makes `1x2` one malformed token that
|
||
the line-number conversion diagnoses by name. Regression tests in `tests/scanner_tokens.c`
|
||
and `tests/language/numeric/octal_literal.bas`.
|
||
|
||
16. ~~**`basicscanner.go:349` — the "Reserved word in variable name" check never fires.**~~
|
||
**Fixed**, and it is the item §0.1 unblocked. The keyword tables are now searched on the
|
||
*base* name with any type suffix stripped, so `PRINT$`, `LEN#` and `GOTO%` are refused as
|
||
variable names and the reference's own diagnostic stops being dead code. A name that merely
|
||
contains a verb — `PRINTER$` — is still fine, which is what says the check compares the
|
||
whole base name rather than a prefix. Regression tests in `tests/scanner_tokens.c`.
|
||
|
||
**It cost one golden case, exactly as predicted, and the cost turned out to be nothing.**
|
||
The reference's `examples/strreverse.bas` names a variable `INPUT$`; the variable is renamed
|
||
to `SOURCE$` in `tests/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.
|
||
|
||
### Found while fixing the DEF recursion hang
|
||
|
||
27. ~~**A call leaked one variable slot per parameter.**~~ **Fixed.**
|
||
`akbasic_runtime_prev_environment()` released the scope but not the variables the scope
|
||
created, so a subroutine with a local of its own exhausted the 128-slot pool after
|
||
about 128 calls -- `Maximum runtime variables reached`, on a four-line program that
|
||
does nothing unusual.
|
||
|
||
**It was there for `GOSUB` all along**, measured on a build stashed back rather than
|
||
assumed: `FOR I# = 1 TO 300 : GOSUB 100 : NEXT` where the subroutine assigns a local
|
||
failed before this work and passes after it. Giving each `DEF` call its own scope
|
||
(item 26) simply made it reachable a second way, which is how it was found.
|
||
|
||
The release is safe because a scope's own table holds *only* what it created --
|
||
`akbasic_environment_get()` walks up to find an outer variable and returns it without
|
||
caching a reference. That is worth knowing before anyone adds caching there, and the
|
||
loop says so. It is the variable *slot* that comes back, not its storage: the value
|
||
pool never frees, so a pointer into a record `DIM`med in a scope stays sound after the
|
||
scope is gone, which is a documented property of structures rather than an accident
|
||
this could have taken away.
|
||
|
||
28. ~~**A function could not take a structure.**~~ **Done.**
|
||
`DEF AREA(S@ AS RECT)` and `DEF POKEIT(P@ AS PTR TO RECT)` now work, and a bare
|
||
`DEF F(S@)` is refused: `@` says "a structure" without saying which, so it does not
|
||
state a contract the way `S$` does. Accepting it would mean checking fields at the
|
||
call rather than at the declaration -- duck typing, and the hole naming the type
|
||
closes. **The cost is that there are no generic functions**, one `AREA` cannot serve
|
||
two types, and that is a real loss recorded rather than glossed.
|
||
|
||
Passing is by value because a parameter is bound by assignment and assignment copies;
|
||
a pointer parameter copies its reference. Neither is a special rule. A structure
|
||
parameter needs its storage prepared before the copy, which is the one thing a
|
||
primitive parameter does not -- a structure variable is a run of slots and there is
|
||
nothing to copy into until the run exists.
|
||
|
||
**`akbasic_value_is_truthy()` learned that a pointer is true when it points at
|
||
something**, which had to come with it: without it there is no way to test for the end
|
||
of a list at all, and comparing a pointer to 0 reads a numeric field it does not carry.
|
||
A *structure* is deliberately given no truth value -- it always exists, so the question
|
||
has no answer worth guessing at.
|
||
|
||
25. **A statement containing a failed multi-line `DEF` call still completes, and prints a
|
||
junk value.** The call's error is reported and the run stops, but the *enclosing*
|
||
statement carries on with whatever `*dest` was left holding:
|
||
|
||
```
|
||
10 DEF BAD(N#)
|
||
20 RETURN N# / 0
|
||
30 PRINT 99
|
||
40 PRINT BAD(1)
|
||
```
|
||
|
||
prints `99`, the division-by-zero line, and then
|
||
`(UNDEFINED STRING REPRESENTATION FOR 0)`.
|
||
|
||
**Pre-existing, and measured as such**: identical before and after the re-entrancy
|
||
fix, on a build stashed back to compare. It is visible more often now only because
|
||
runaway recursion reaches it where it used to hang instead.
|
||
|
||
The cause is that `akbasic_runtime_process_line_run()` swallows a script's error by
|
||
design -- goal 3 -- so the loop inside `akbasic_runtime_user_function()` sees the run
|
||
end normally and hands back a value nobody should use. The fix is for the multi-line
|
||
path to notice the run did not return through `RETURN` and refuse rather than produce
|
||
a value; it is not done here because it is a decision about what a failed call
|
||
*evaluates to*, and `tests/language/functions/recursion.bas` deliberately does not
|
||
pin the current answer.
|
||
|
||
### Found while building structures
|
||
|
||
23. ~~**A host could not run one script twice.**~~ **Fixed.**
|
||
`akbasic_runtime_start()` never rewound: `RUN` sets `nextline = 0` and clears
|
||
`stopped`, and `start()` set neither. That cost nothing while a host started a script
|
||
once, because `nextline` is already 0 the first time — and cost everything to a host
|
||
running one script per enemy, where the second `start()` began past the end and
|
||
silently did nothing at all. `start` means start now. Found by writing
|
||
`examples/hoststruct.c`, which is exactly that shape.
|
||
|
||
24. ~~**The prescan wiped host-registered types.**~~ **Fixed.** It runs again on every
|
||
`RUN` and used to reinitialise the whole table, so the first `RUN` unregistered
|
||
everything the host had registered before loading — with no way for the host to know
|
||
it had to register them again. It now drops what the *script* declared and keeps what
|
||
the *host* registered, which works because host types are always a prefix and so every
|
||
index a field already recorded keeps pointing at the same type.
|
||
`tests/hoststruct.c` pins it.
|
||
|
||
26. ~~**A `DEF` call was not re-entrant, which cost a wrong answer and a hang.**~~
|
||
**Fixed.** The function's environment was owned by the funcdef and re-initialised on
|
||
every call, so a second call trampled the first:
|
||
|
||
- **Two calls in one expression aliased one slot.** `DBL(10) + DBL(1)` was 4 rather
|
||
than 22 -- both operands became the last call's answer, because the result was a
|
||
pointer into the funcdef's own environment. Two *different* functions were fine,
|
||
which is most of why it was invisible. Same severity class as §6 item 12, and the
|
||
same shape: a silent wrong answer from a two-line program.
|
||
- **Recursion never came back.** The recursive call re-initialised the environment the
|
||
outer call was still using, so the loop waiting for control could not see it return.
|
||
No error, no bound, no diagnostic -- the one place in this interpreter that looped
|
||
forever instead of raising.
|
||
|
||
A call takes an environment from the pool now, exactly as `GOSUB` does, and the result
|
||
is copied into a caller-scope scratch before that environment goes back. `RETURN`
|
||
parks its result on the *parent* rather than on the environment about to be released,
|
||
so nothing reads a freed slot. Recursion depth answers to `AKBASIC_MAX_ENVIRONMENTS`
|
||
like every other nesting, so too deep is `Environment pool exhausted` -- a diagnosis
|
||
where there was none.
|
||
|
||
`akbasic_FunctionDef.environment` is gone with it, as dead state.
|
||
`tests/user_functions.c` and `tests/language/functions/recursion.bas`.
|
||
|
||
### Found while auditing `akbasic_value_clone()` for the structures work
|
||
|
||
22. ~~**The numeric operators' `else` was a catch-all, not a float branch.**~~ **Fixed.**
|
||
`math_minus`, `math_multiply` and `math_divide` were `if ( INTEGER ) … else <treat as
|
||
float>`, and that `else` read `floatval` from whatever it was handed. A truth value
|
||
keeps its payload in `boolvalue` and leaves `floatval` zero, so a truth value on the
|
||
*left* computed into a field nothing reads and kept its `BOOLEAN` type:
|
||
|
||
```
|
||
(A# == 1) - 1 printed true should be -2
|
||
(A# == 1) * 3 printed true should be -3
|
||
(A# == 1) + 1 correctly refused
|
||
```
|
||
|
||
Wrong in value and in type, and silent. `math_plus` escaped only because it enumerates
|
||
its cases and ends in an error; the other three now share one `require_numeric()` guard
|
||
so a type added later is refused by all of them at once instead of quietly taking the
|
||
float branch in each.
|
||
|
||
**The two operands have different rules, deliberately.** The left one picks the branch
|
||
and must be a number. The right one is read through `rval_as_int()`, which handles a
|
||
truth value on purpose — `5 - (A# == 1)` is 6, and that is the same property that lets
|
||
`AND` and `OR` double as logical operators. Refusing it on the right would have broken
|
||
every condition in the language, and `tests/language/numeric/truth_value_arithmetic.bas`
|
||
pins both halves.
|
||
|
||
**Found by asking what a structure operand would do**, since `AKBASIC_TYPE_STRUCT` would
|
||
have taken exactly this path and read a `floatval` it does not carry. The structures
|
||
work did not create this defect; it made an already-reachable one worth chasing. Same
|
||
shape as item 5 and fixed the same way, with the assertions against the value API where
|
||
the bad value can actually be constructed.
|
||
|
||
Two stale comments went with it: `src/value.c`'s file header and a duplicate block above
|
||
`rval_as_int()` both cited "TODO.md section 12", which does not exist — the defect list
|
||
is §6 — and the duplicate described the summing behaviour item 5 had already removed.
|
||
|
||
### Found while writing the error-code appendix
|
||
|
||
21. **A dependency's status code reaches `ER#` where the interpreter has one of its own.**
|
||
`VAL("XYZ")` reports `AKERR_VALUE` — 144 on this machine — rather than
|
||
`AKBASIC_ERR_VALUE` (517), because `akbasic_fn_val()` (`src/runtime_functions.c:261`)
|
||
hands the string to `aksl_atof()` and returns whatever comes back. Both register the
|
||
name `Value Error`, so `ERR(ER#)` cannot tell them apart and only the number can.
|
||
|
||
**The consequence is that the number is not portable.** `libakerror`'s codes are offsets
|
||
from the platform's largest `errno`, so a program testing `IF ER# = 144` is right on one
|
||
machine and wrong on the next, where 517 — which is absolute — is what it should have
|
||
been able to test. `docs/15-error-codes.md` documents the behaviour as it stands and
|
||
tells a program to compare the code rather than the text.
|
||
|
||
Not fixed here because it is a decision about a boundary rather than a patch: every
|
||
place the interpreter calls into `libakstdlib` on a *program's* behalf could translate
|
||
the failure into the akbasic band, and the list of those places wants collecting before
|
||
the first one is changed. The disk verbs are the interesting counter-case — `ENOENT` out
|
||
of `DOPEN` is genuinely more useful to a program than a flat 519 — so this is not
|
||
"translate everything", which is exactly why it needs deciding rather than doing.
|
||
|
||
### Found while making line numbers optional
|
||
|
||
26. **`akbasic_environment_set_label()` files into the active scope, not the root.**
|
||
`src/environment.c:246` walks up until `obj->runtime->environment == obj`, which is the
|
||
*currently executing* scope, despite the comment above it saying "Only the top-level
|
||
environment creates labels". So a `LABEL` reached inside a `GOSUB` or `FOR` body is filed
|
||
into that scope and dies when the body pops, while `akbasic_runtime_scan_labels()`
|
||
(`src/runtime.c:1286`) files the same label into the real root.
|
||
|
||
**The consequence is that a prescanned label and a re-filed one behave differently**, and
|
||
which one you get depends on whether control has passed through the `LABEL` statement
|
||
inside a scope. Nothing in either corpus does that, so nothing catches it. The fix is one
|
||
condition -- walk to `parent == NULL` -- plus a test that `GOSUB`s past a `LABEL` and then
|
||
branches to it from the top level. Wants checking against deviation 32 first, because
|
||
"`LABEL` still executes and re-files itself" is deliberate and the re-filing is the part
|
||
worth keeping.
|
||
|
||
27. **Three of the four prescans report against the wrong line.**
|
||
`akbasic_runtime_set_mode()`'s handler builds its BASIC error line from
|
||
`environment->lineno`, which after a load is wherever the loader stopped -- usually the
|
||
last line of the program. The label, `DATA` and `TYPE` scans therefore print `? 47 :` for
|
||
a fault on line 3, and work around it by putting `Line %d:` in the message text
|
||
(`src/structtype.c:342`), so the message names the number twice and the prefix is wrong.
|
||
|
||
`akbasic_runtime_check_targets()` does not have the problem because it points
|
||
`environment->lineno` at the line it is walking. That is the fix, and it belongs in the
|
||
wrapper rather than in four places: give each prescan a way to say which line it failed
|
||
on, or have each set the cursor as the target check does. Cosmetic, but it is the first
|
||
number a person reads when a program will not start.
|
||
|
||
28. **Two lines carrying the same written line number still silently keep the last.**
|
||
`akbasic_runtime_file_line()` refuses a collision that involves an *assigned* number, but
|
||
leaves numbered-onto-numbered alone, which is what a file with `10 PRINT "A"` twice has
|
||
always done. A duplicate in a hand-written file is a mistake rather than an intent, and
|
||
refusing it would be consistent with the other three cases.
|
||
|
||
Not changed with the rest because it is the one collision that can appear in an existing
|
||
program, so it carries corpus risk the others do not: 41 reference cases and 22 local ones
|
||
would all have to be shown clean first. `src/runtime.c`, in the `FAIL_NONZERO_RETURN` that
|
||
already names the slot.
|
||
|
||
29. **`akbasic_renumber()` keeps two 9999-entry `static` locals.**
|
||
`src/renumber.c` holds `map[]` and `rewritten[]` at file scope, and
|
||
`akbasic_runtime_check_targets()` now adds a `static` scratch buffer beside them. That is
|
||
against the no-file-scope-mutable-state rule in `MAINTENANCE.md`, and it means `RENUMBER`
|
||
and the target prescan are not reentrant across two `akbasic_Runtime`s in one process --
|
||
the exact thing that rule exists to guarantee.
|
||
|
||
They are `static` because they will not fit on a default stack, which is the same reason
|
||
`akbasic_Runtime` itself is too big for one. The honest fix is to hang them off the
|
||
runtime like every other pool, which costs another ~2.5MB inline per interpreter for
|
||
something used by one verb and one prescan. Recorded rather than done, because "make it
|
||
reentrant" and "do not grow the runtime by a third for a scratch buffer" are both right
|
||
and picking between them is a decision.
|
||
|
||
### Mutation-testing gaps in the files the game work touched
|
||
|
||
Recorded the way §8's CI note records `src/audio_tables.c`: a real test gap rather than a
|
||
reason to avoid the file.
|
||
|
||
34. **`src/runtime.c` has never had a complete mutation run, and the one attempted here
|
||
was cut off at 551 of 997 mutants** after ninety minutes. What it did cover included
|
||
all three regions the value-pool, trap and skipped-block work touched, and the
|
||
survivors there are worth naming:
|
||
|
||
- `report_and_reraise()`: **closed.** Deleting the `LOG_ERROR_WITH_MESSAGE` survived,
|
||
because nothing looked at the log -- and that line *is* the visible half of item
|
||
33's second fix. `tests/trap_verbs.c` now redirects `akerr_log_method` into a buffer
|
||
and drives the path with a sink that refuses every write, which kills it.
|
||
- The skipped-block guard (`src/runtime.c`, the `BEND` test): four survivors, and
|
||
three of them are equivalent for any program that can be written -- `loopFirstLine
|
||
!= 0` mutated to `!= 1` differs only for a loop on line 1, `waitingForCommand[0]`
|
||
to `[1]` only for a one-character wait, and flipping `strcmp(...) == 0` to `!= 0`
|
||
moves the release from the `FOR` to the `NEXT` that must follow it. A test with a
|
||
loop on line 1 would kill the first. The other two are noted rather than chased.
|
||
- A NULL-argument guard in `akbasic_runtime_reserve_globals()`, which is the same
|
||
survivor class every file in this tree has: nothing calls these with NULL.
|
||
|
||
The remaining 446 mutants are unexamined. A full run is a release-workflow job, not a
|
||
per-commit one -- `.gitea/workflows/release.yaml` already mutates the whole tree -- and
|
||
what this entry is for is that the *partial* result has been read rather than filed
|
||
unopened.
|
||
|
||
Measured alongside: `src/variable.c` scores 68.2% with **every mutant on the inline-
|
||
storage lines killed**, and `src/runtime_trap.c` 82.5% with **no survivors** in
|
||
`set_error_variables()`. Both files' remaining survivors are pre-existing and are
|
||
mostly NULL guards and `MAX - 1` arithmetic in string helpers.
|
||
|
||
### Found while writing a game against the interpreter
|
||
|
||
A complete Breakout, sprites and text grid and keyboard, running for minutes at a time. Nothing
|
||
in either corpus runs for minutes, which is why the first of these had never been seen.
|
||
|
||
30. ~~**A variable created inside a scope costs pool slots that never come back, so a
|
||
long-running program has a hard budget of 4096 name creations.**~~ **Done** — a scalar now
|
||
lives in the variable rather than in the pool (`akbasic_Variable::inlinevalue`,
|
||
`src/variable.c`), so a `GOSUB` local, a `FOR` counter and a `DEF` parameter all cost
|
||
nothing at all. Twenty thousand scoped creations where four thousand used to be the whole
|
||
run. `tests/value_pool.c` is the coverage, and it asserts the mechanism as well as the
|
||
consequence — a later change that moved arrays inline too would pass every behavioural
|
||
case and break the pointer guarantee. **An array created in a scope still leaks**, which is
|
||
deliberate and is the same statement `akbasic_ValuePool` has always made: a pointer into a
|
||
record outlives the scope that DIMmed it. The record below is what was found and why.
|
||
|
||
**Also fixed with it:** `SWAP` exchanges whole variable records, so the `values` pointer
|
||
that came over named the other variable's inline slot and SWAP silently did nothing. Caught
|
||
by `tests/language/housekeeping/verbs.bas`, which is the golden corpus earning its keep.
|
||
|
||
The original report: `akbasic_ValuePool` is a bump allocator
|
||
on purpose (`include/akbasic/value.h:30`), and the reason given is that "nothing in BASIC
|
||
destroys a variable". **Scope exit destroys variables.**
|
||
`akbasic_runtime_prev_environment()` (`src/runtime.c:121`) marks a popped environment's
|
||
variables `used = false`; `akbasic_runtime_new_variable()` (`src/runtime.c:31`) `memset`s
|
||
the slot it hands back, clearing `values` and `valuecount`; and `akbasic_variable_init()`
|
||
(`src/variable.c:98`) therefore takes *fresh* slots for a variable whose old ones are still
|
||
counted against `obj->next`. Nothing decrements it, so every `GOSUB` that creates a local
|
||
leaks the local's slots for the life of the run.
|
||
|
||
Reduced, and it fails on both builds:
|
||
|
||
```basic
|
||
X# = 0
|
||
FOR T# = 1 TO 6000
|
||
GOSUB SUBA
|
||
NEXT T#
|
||
PRINT "OK " + X#
|
||
END
|
||
|
||
LABEL SUBA
|
||
LOC# = 1
|
||
X# = X# + LOC#
|
||
RETURN
|
||
```
|
||
|
||
`? 110 : RUNTIME ERROR Array of 1 elements does not fit in the 0 remaining value slots`,
|
||
at `LOC# = 1`, on the 4091st call -- 4096 less the handful of names the program itself
|
||
declared. Both builds, same line, same count. Declare `LOC#` alongside `X#` and the same
|
||
6000 calls cost nothing at all. A `FOR` counter behaves the same way: name one that does
|
||
not already exist and every entry to the loop spends a slot.
|
||
|
||
**What it costs a program:** a game loop that creates one name per tick is dead in half a
|
||
minute, and the error names the line that happened to be unlucky rather than the line that
|
||
caused it. Breakout ran twenty-five seconds before this was understood. The workaround is
|
||
real and not onerous -- declare every name, loop counters included, at the top -- but it
|
||
has to be *known*, and right now the only way to learn it is to hit it.
|
||
|
||
Three ways out, in increasing order of work. Reclaim on scope exit: give the pool a mark
|
||
and release, take the mark when an environment is pushed and rewind to it when it pops,
|
||
which is exactly the discipline the environments themselves already have and is why they
|
||
are a pool in the first place. Or keep the slice on the recycled variable: stop the
|
||
`memset` in `new_variable()` from clearing `values`/`valuecount` and let
|
||
`variable_init()`'s existing "reuse the slice when it is already big enough" branch do the
|
||
work -- much smaller, and it fixes the common case of the same-sized scalar being recreated
|
||
over and over. Or, at minimum, say so in the manual and in `value.h`'s comment, which
|
||
currently states the opposite.
|
||
|
||
Whichever is chosen, the test is a CTest program that enters and leaves a scope creating a
|
||
variable tens of thousands of times and returns zero -- and it wants to exist even for the
|
||
documentation-only outcome, pinned at whatever the real budget turns out to be. Item 33
|
||
has to be settled with it: while the pool is dry the failure cannot be trapped, so a
|
||
program cannot even report this one against itself.
|
||
|
||
31. ~~**`WINDOW` cannot be reached in the standalone AKGL build.**~~ **Done** — `tee_window()`
|
||
(`src/sink_tee.c`) forwards to whichever half offers one, offered only when one does,
|
||
with the case in `tests/sink_tee.c` beside the `moveto` one. `WINDOW 0, 0, 20, 4` now
|
||
works in the windowed build and still refuses by name without a grid.
|
||
|
||
**`akbasic_sink_init_tee()` and `akbasic_sink_init_stdio()` also now clear `moveto` and
|
||
`window` rather than leaving them alone.** Writing the test found it: both are
|
||
conditional in the tee, so a caller with a sink on the stack got whatever was in that
|
||
memory, and `CHAR` decides whether it can move a cursor by testing that pointer for
|
||
NULL. An initializer that leaves a field alone is not an initializer.
|
||
|
||
The grid-size half of this item is closed separately; see item 31a below.
|
||
|
||
The original report: `akbasic_sink_init_tee()`
|
||
(`src/sink_tee.c:143`) wires `write`, `writeln`, `readline`, `clear` and -- conditionally
|
||
-- `moveto`, and never sets `window`. The standalone frontend runs the interpreter against
|
||
the tee (`src/frontend_akgl.c:215`), so the fully implemented `sink_window()` underneath it
|
||
(`src/sink_akgl.c:413`) is unreachable: `WINDOW 0, 0, 10, 10` in the windowed build answers
|
||
`? RUNTIME ERROR WINDOW needs a text device with a character grid, and this one has none`.
|
||
The verb is only usable by a host that hands the runtime the akgl sink directly.
|
||
|
||
It looks like an oversight rather than a decision, because `moveto` right above it *is*
|
||
forwarded, and with the same reasoning that would apply here. The fix is a `tee_window()`
|
||
that forwards to whichever half offers one, offered only when one does, and a case in
|
||
`tests/sink_tee.c` beside the `moveto` one. §3's "Still missing from the sink" closes by
|
||
calling `WINDOW` "ordinary work here rather than anything blocked", which is now half true:
|
||
the work was done and then not plumbed.
|
||
|
||
~~Worth deciding at the same time: **a program still has no way to ask how big the text
|
||
grid is.**~~ **Done, and with BASIC 7.0's own function rather than more `RGR` fields.**
|
||
`RWINDOW(0)` is the current text window's rows and `RWINDOW(1)` its columns -- the C128
|
||
has exactly this function and this interpreter had never implemented it. `RWINDOW(2)` is
|
||
refused by name: it reports a 40 or 80 column screen mode and there is no such mode here,
|
||
so answering 0 would be a plausible lie.
|
||
|
||
The cell size in pixels is `RGR(3)` and `RGR(4)`, beside the surface's own dimensions,
|
||
because that is a fact about the surface -- and because `RWINDOW` reports the *window*,
|
||
which makes dividing `RGR(1)` by a column count wrong the moment a program calls
|
||
`WINDOW`. Both read a new optional `grid` entry point on `akbasic_TextSink`, implemented
|
||
by the akgl sink and forwarded by the tee, NULL everywhere else so both refuse by name.
|
||
|
||
`RGR(3)` on the standalone build now answers 16 -- the number the Breakout listing had
|
||
measured by hand and written out as a constant.
|
||
|
||
The original note: `RGR(1)` and `RGR(2)` give the window in pixels, and nothing gives columns, rows or
|
||
the cell size, so anything that wants to place a character *and* a sprite at the same spot
|
||
has to hardcode a cell size measured against the bundled font -- which is what the Breakout
|
||
in `examples/breakout/characters` does, and it is the one thing in that listing that will break
|
||
on a different font or window. Two more `RGR()` indices would answer it.
|
||
|
||
32. ~~**A character written past a short row's terminator is stored where nothing will
|
||
render it.**~~ **Done** -- `putchar_at()` (`src/sink_akgl.c`) fills the gap with spaces
|
||
before storing, so a positioned write always lands.
|
||
|
||
**Padded in `putchar_at()` rather than in `sink_moveto()`**, which is the friendlier of
|
||
the two options this entry offered and avoids its own objection: `moveto` stays
|
||
read-only, and a row is only ever padded when a character actually arrives. The pad
|
||
fills from the terminator rather than replacing it, because the buffer is not cleared
|
||
between rows -- replacing only the terminator would expose the tail of whatever longer
|
||
row used to be there, and `tests/akgl_backends.c` asserts exactly that case.
|
||
|
||
The documented truncation on the way back is unaffected and is asserted beside it.
|
||
|
||
The original report: A grid row is a NUL-terminated string and `putchar_at()` (`src/sink_akgl.c:89`)
|
||
writes the character, advances, and terminates -- so `CHAR 1, 40, 1, "#"` on an empty row
|
||
stores `#` at column 40 with `text[1][0]` still `'\0'`, and `sink_akgl_render()` draws
|
||
nothing at all. The same call on a row already 50 characters long draws the `#` and erases
|
||
columns 41 onward, which is the documented truncation and is fine.
|
||
|
||
It is the *silent nothing* that is the trap: the write succeeds, the cursor moves, stdout's
|
||
mirror shows the character, and the window stays blank. Either pad the gap with spaces on
|
||
a `moveto` past the terminator, so a positioned write always lands, or say plainly beside
|
||
`CHAR` in `docs/11-verb-reference.md` and deviation 49 that a row is written from column 0
|
||
or not at all. Padding is the friendlier of the two and costs one loop in `sink_moveto()`;
|
||
the argument against it is that it makes `moveto` write to the grid, which it currently
|
||
does not.
|
||
|
||
33. ~~**A `TRAP` handler cannot be entered once the value pool is dry, and the error is
|
||
dropped in silence.**~~ **Done**, in two halves. `akbasic_runtime_reserve_globals()`
|
||
(`src/runtime.c`) creates `ER#` and `EL#` at runtime init and again after `CLR`, so the
|
||
dispatch never allocates -- there is nothing left for it to fail at. And
|
||
`report_and_reraise()` no longer lets a failure *inside* reporting replace the error the
|
||
program actually made: the secondary one is logged where a developer will see it and the
|
||
original is re-raised.
|
||
|
||
**The reduction below no longer reproduces, and not because of this fix.** §6 item 30
|
||
made a scalar free, so the value pool can no longer be emptied by creating names and
|
||
`ER#`/`EL#` could be created after all. The defect was still there, reachable through the
|
||
*variable* table instead: 124 names and a `TRAP` armed, and the handler was silently
|
||
skipped while the program carried on. That is what `tests/trap_verbs.c` now pins, along
|
||
with the invariant itself -- both globals present before a program runs.
|
||
|
||
The original report: `akbasic_trap_set_error_variables()` (`src/runtime_trap.c:36`) sets `ER#`
|
||
and `EL#` through `akbasic_runtime_global()` (`:51` and `:53`), which *creates* them if
|
||
the program never named them. Creating them needs pool slots. So the one error most
|
||
likely to leave the pool empty -- item 30's -- is the one error whose handler cannot be
|
||
reached, and because the dispatch fails inside the error path rather than raising, **the
|
||
program keeps running with the failed statement's effect quietly missing**.
|
||
|
||
It is easy to see, and it is much worse than an abort:
|
||
|
||
```basic
|
||
X# = 0
|
||
TRAP RANOUT
|
||
FOR T# = 1 TO 6000
|
||
GOSUB SUBA
|
||
NEXT T#
|
||
PRINT "OK " + X#
|
||
END
|
||
|
||
LABEL SUBA
|
||
LOC# = 1
|
||
X# = X# + LOC#
|
||
RETURN
|
||
|
||
LABEL RANOUT
|
||
PRINT "DIED AT X " + X#
|
||
QUIT
|
||
```
|
||
|
||
prints `OK 4092`. The handler never ran, nothing was reported, and `X#` is 1908 short of
|
||
the 6000 the loop counted -- a wrong answer delivered as a right one. Add `ER# = 0` and
|
||
`EL# = 0` at the top, so the dispatch has nothing left to create, and the same program
|
||
prints `DIED AT X 4090` from its handler as it should.
|
||
|
||
Two things want fixing and they are separable. The dispatch should not allocate: create
|
||
`ER#` and `EL#` at runtime init, where there is always room, rather than at the moment the
|
||
program is in trouble -- they are reserved names and §1's other reserved globals are
|
||
already there. And a failure *inside* the trap dispatch should not be swallowed; if the
|
||
handler cannot be entered, the original error is the thing to report, and reporting it is
|
||
what the untrapped path already does correctly.
|
||
|
||
`AKBASIC_ERR_BOUNDS` from the pool is documented as "bounded and diagnosable, which is the
|
||
point" (`include/akbasic/value.h:34`). With a `TRAP` armed it is currently neither.
|
||
|
||
### Found while rewriting the game tutorials
|
||
|
||
Chapters 17 and 18 were rewritten to teach a beginner rather than to explain a finished
|
||
listing, which meant writing every rule down as a rule instead of as a comment beside the
|
||
code that dodges it. Three of them turned out to have no entry anywhere. The first two are
|
||
the same shape as item 12 above — a parser function that consumes one operator where it
|
||
should loop — and the third is why both games are built out of `LABEL` and `GOTO`.
|
||
|
||
35. **An expression mixing `-` and `+` at the same level is evaluated right to left.**
|
||
`a - b + c` computes `a - (b + c)`. A C128, and every BASIC this descends from, folds a
|
||
run of same-precedence additive operators left to right.
|
||
|
||
```basic
|
||
PRINT 10 - 3 + 2
|
||
PRINT 1 - 2 - 3
|
||
```
|
||
|
||
```output
|
||
5
|
||
-4
|
||
```
|
||
|
||
The second line is right and the first is not; 9 is the answer. **Nothing fails** — this
|
||
is item 4's failure mode over again, a program that quietly computes something else — and
|
||
the two tutorials work around it by parenthesising every mixed `+` and `-` as a rule
|
||
rather than where it happens to matter.
|
||
|
||
**The cause is the grammar's shape, not a missing loop.** `subtraction()`
|
||
(`src/parser.c:425`) sits *above* `addition()` (`:447`) as its own precedence level, and
|
||
both loop correctly — but `subtraction()`'s right operand is parsed by `addition()`, which
|
||
then consumes the `+` that should have been the *next* term of the subtraction. `1 - 2 - 3`
|
||
is right because the outer loop sees both minuses; `10 - 3 + 2` is wrong because the inner
|
||
one eats the plus first.
|
||
|
||
The fix is one additive level rather than two: a single function matching `PLUS` and
|
||
`MINUS` in one `while`, building left-associatively, with `multiplication()` beneath it.
|
||
That is what item 12 left half-done — it made each of the two levels fold left without
|
||
noticing that there should only have been one. Touches `src/parser.c` and wants cases in
|
||
`tests/parser_expressions.c` beside item 12's, asserting `10 - 3 + 2` is 9 and
|
||
`10 + 3 - 2` is 11 (which already passes, and is the asymmetry that gives it away).
|
||
|
||
36. **Only one unparenthesised `AND` or `OR` is matched per expression.** A second one is left
|
||
in the token stream, and in the place a program actually writes them — a condition — the
|
||
`THEN` check then finds the wrong token and the line is refused:
|
||
|
||
```basic
|
||
X# = 1
|
||
Y# = 2
|
||
IF X# = 1 AND Y# = 2 THEN PRINT "TWO IS FINE"
|
||
IF X# = 1 AND Y# = 2 AND X# = 1 THEN PRINT "THREE IS NOT"
|
||
```
|
||
|
||
```output
|
||
TWO IS FINE
|
||
? 4 : PARSE ERROR Incomplete IF statement
|
||
```
|
||
|
||
Parenthesising is the workaround and it works — `IF (A AND B) AND C THEN` parses — which
|
||
is what the tutorials tell a reader to do. It is a refusal rather than a wrong answer, so
|
||
it costs a puzzled minute rather than an evening; item 42 in §5 fixed the case of *one*
|
||
`AND` and stopped there.
|
||
|
||
`logicalandor()` (`src/parser.c:329`) is `if ( akbasic_parser_match(obj, OPS, 2) )` at `:339`
|
||
where `subtraction()` at `:425` is `while`. **This is item 12's defect exactly**, on the
|
||
one operator pair item 12 did not reach, and the reference has the same shape at
|
||
`basicparser.go`. The fix is the same fix: `while`, folding left, reassigning `left` from
|
||
the leaf built on the previous pass. `tests/parser_expressions.c` for the expression form
|
||
and `tests/parser_commands.c` for the `IF` that made it visible.
|
||
|
||
Worth doing together with item 35: they are one afternoon, one file, and between them they
|
||
are the two rules a tutorial currently has to spend a paragraph on.
|
||
|
||
37. **A `GOTO` that leaves a `FOR` or a `DO` leaks the loop's scope**, so a program that uses
|
||
the pattern as its main loop stops after the thirty-second time round:
|
||
|
||
```basic
|
||
N# = 0
|
||
LABEL TOP
|
||
DO
|
||
N# = N# + 1
|
||
IF N# < 100 THEN GOTO TOP
|
||
LOOP UNTIL N# > 99
|
||
PRINT "SURVIVED " + N#
|
||
```
|
||
|
||
```output
|
||
? 3 : PARSE ERROR Environment pool exhausted at line 3 (32 in use)
|
||
```
|
||
|
||
Thirty-two is `AKBASIC_MAX_ENVIRONMENTS` (`include/akbasic/types.h:31`), and the branch
|
||
that reports is the `DO` being entered for the thirty-third time rather than the `GOTO`
|
||
that did the damage — the same misattribution item 2 had. The `FOR` form behaves
|
||
identically.
|
||
|
||
**This is a real pattern rather than a contrivance.** A game's main loop is a loop the
|
||
program leaves from the middle of: losing a life, clearing a level and quitting are all
|
||
"stop what you are doing and go somewhere else". Both Breakout listings in `examples/`
|
||
are built out of `LABEL`/`GOTO` for exactly this reason, which is the shape a reader of
|
||
chapters 17 and 18 is now told to copy, and it is a workaround rather than a preference —
|
||
a `DO ... LOOP` around the frame would be the natural way to write either one.
|
||
|
||
The mechanism is the one item 2 documents from the other end: a loop's environment is
|
||
pushed when the line is parsed and popped by its `NEXT` or `LOOP`, and nothing else pops
|
||
it. `GOTO` sets the next line and returns (`akbasic_cmd_goto()`, `src/runtime_commands.c`);
|
||
it has no idea it has just left a scope.
|
||
|
||
A fix has to know which scopes the branch target is *outside* of, which the environment
|
||
stack can answer if a loop's environment records the source line that pushed it: on a
|
||
`GOTO`, pop every loop scope whose line is not on the path to the target. That is more
|
||
machinery than items 35 and 36 want between them, and it is worth pricing against simply
|
||
documenting it — chapters 13 and 17 both now say "loop with `GOTO`, never jump out of a
|
||
`DO`", which is a rule a reader can follow. Wants `tests/for_next.c` either way, because
|
||
the reduction above is four lines and currently nothing asserts it in either direction.
|
||
|
||
### Found while integrating libakgl's collision subsystem
|
||
|
||
`spr_collisions()` now answers through `akgl_collision_test()` and every sprite carries an
|
||
`akgl_CollisionShape` and a pooled proxy. Three things were deliberately left for later, and
|
||
each is here so the reasoning does not have to be reconstructed.
|
||
|
||
38. **Finish moving `COLLISION` and `BUMP` onto the subsystem.** What landed is the narrowphase
|
||
and the shapes; the seam is still `collisions(self, uint16_t *mask)` and the pairing is
|
||
still all-against-all over eight slots with a bounding-box reject in front of it.
|
||
|
||
That is the right shape *at eight sprites* and the benchmark says so: 54.9 ns a scan spread
|
||
out, 211.8 ns for breakout's layout, against a 1.20 ms frame. A uniform grid over eight
|
||
proxies costs more in per-proxy insert and move bookkeeping than an all-pairs loop over 28
|
||
pairs saves, which is why the proxies are deliberately **not** registered with any
|
||
partitioner — `akgl_collision_test()` takes two positioned proxies and needs no world.
|
||
|
||
**The threshold is what to record, not the work.** Registering the proxies and letting
|
||
`akgl_collision_query_box()` or the partitioner's `each_pair` do the pairing becomes worth
|
||
it somewhere above eight, and `AKBASIC_MAX_SPRITES` is a `#define`
|
||
(`include/akbasic/sprite.h:38`). Anybody raising it should have the number in front of them
|
||
rather than discovering the cost afterwards: re-run `tests/collision_perf.c` at the new
|
||
ceiling and compare against the frame row. Note that libakgl's own measurement puts the
|
||
naive all-pairs sweep at 0.7% of a frame at 64 actors and 11% at 256, so the crossover is
|
||
well above anything a Commodore-shaped sprite budget will reach.
|
||
|
||
Also still open at the seam: the contact is computed and discarded. §6 item 40 and the
|
||
BASIC surface built on it are what consume it.
|
||
|
||
39. **Convert the breakout listings and chapters 17 and 18 to the collision verbs.** This is the
|
||
payoff, and it is deliberately not part of the integration commit.
|
||
|
||
Neither game calls `COLLISION` or `BUMP` even once today. Of roughly 230 lines of collision
|
||
code across the two, about 19 are sprite-against-sprite; the rest is a sprite against
|
||
numbers in a BASIC array or against bare constants, because bricks are text characters in
|
||
one game and stamped pixels inside one big sprite in the other. Static collision geometry is
|
||
what changes that — a brick becomes a collidable box without spending a sprite slot.
|
||
|
||
What goes, concretely:
|
||
|
||
- `examples/breakout/sprites/breakout.bas` — `BALLBRICKS` (29 lines) and `TESTCELL` (28)
|
||
become a handler that reads which box was hit and which axis to reverse. The
|
||
hand-written minimum-translation-axis at `:661-666` is a contact field. `BUILDLIVE` and
|
||
`BUILDROW` stop maintaining a parallel `BRK#()` grid.
|
||
- `examples/breakout/characters/breakout.bas` — `HITTEST` (14 lines) and the pixel-to-cell
|
||
arithmetic go the same way, and `PADHIT`'s five-line overlap test becomes `BUMP`.
|
||
|
||
**The documentation cost is the reason this is separate.** Chapter 17 Step 10 and chapter 18
|
||
Steps 9 and 13 are built around that arithmetic, both chapters' examples are executed by
|
||
`docs_examples`, and both were rewritten and validated immediately before this work started.
|
||
Folding a listing rewrite into a library integration would make neither reviewable.
|
||
|
||
Sequence it after §9 item 9 if that is ever fixed, since a game that no longer spends two
|
||
sprites on the screen has two more to spend on collidable things, and the two rewrites would
|
||
otherwise touch the same lines twice.
|
||
|
||
40. **`akbasic_runtime_call_function()`: call a BASIC function from C with bound values.**
|
||
|
||
A verb cannot currently take a BASIC function as an argument, and it came up while designing
|
||
the collision query — a callback per overlapping shape is one obvious shape for that verb.
|
||
It was rejected for the query on the use case rather than on feasibility: the motivating
|
||
program wants one answer rather than many, a loop is the idiomatic form in this dialect, and
|
||
the language already has a callback shaped the way a BASIC programmer expects — an interrupt
|
||
handler taking a label, which `COLLISION` uses.
|
||
|
||
**The finding worth keeping is that the hard half already exists.** `src/runtime.c:1031` is
|
||
the multi-line `DEF` path:
|
||
|
||
```c
|
||
callenv->gosubReturnLine = callenv->lineno + 1;
|
||
callenv->nextline = fndef->lineno;
|
||
while ( obj->environment != targetenv && obj->mode == AKBASIC_MODE_RUN ) {
|
||
PASS(errctx, akbasic_runtime_process_line_run(obj));
|
||
}
|
||
```
|
||
|
||
The interpreter already re-enters its own line loop synchronously from inside expression
|
||
evaluation, with a scope from the pool, re-entrant since §6 item 26, and with recursion
|
||
depth answering to `AKBASIC_MAX_ENVIRONMENTS` as a diagnosis rather than a hang.
|
||
|
||
What is missing is narrow. `akbasic_runtime_user_function()` takes an `akbasic_ASTLeaf *`
|
||
call site and evaluates argument *leaves* at `:993-1006`; split that loop so the AST path
|
||
evaluates-then-binds and a new entry point binds values it is handed. Resolving a bare word
|
||
to a function rather than a label is `akbasic_environment_get_function()` at `:960`, and
|
||
`COLLISION 1, BUMPED` is the precedent for a verb taking a bare word by name
|
||
(`src/runtime_sprite.c:543-555`).
|
||
|
||
**One hazard, if it is ever wired to a collision query.** Do not pass the BASIC function
|
||
through as an `akgl_CollisionVisitFunc`: it would run inside the partitioner's cell-chain
|
||
walk, and the callback a brick loop wants removes the brick it was just told about, which is
|
||
`partitioner.remove()` rewriting the chain the walk holds a cursor into. Collect the
|
||
candidates during the walk, close the walk, then dispatch.
|
||
|
||
## 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; two cases have diverged on purpose since, and
|
||
`tests/reference/README.md` lists them.
|
||
|
||
| Gate | Result |
|
||
|---|---|
|
||
| `ctest` | 109/109 — 41 reference golden cases, 23 local ones, 40 unit tests, 1 known-failing, 3 embedding examples, and `docs_examples` |
|
||
| `ctest` with `-DAKBASIC_WITH_AKGL=ON` | 109/109 headless, with `akgl_typing` skipping itself. The same set minus the three `no_device` cases the SDL driver contradicts, plus `akgl_backends`, `akgl_frontend`, `docs_screenshots` 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: 55 programs, 9 transcripts, 63 output comparisons, 2 excerpts, 2 shell blocks and 8 figures in the default build. The C-snippet count reads 0 when the harness is run by hand without `--cflags-file`; CTest passes it. `MAINTENANCE.md` documents the fence-tag convention |
|
||
| `docs_screenshots` | 8/8 figures re-rendered and byte-identical to the checked-in PNGs. AKGL build only — rendering a picture needs the SDL half |
|
||
| 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 | 109/109 |
|
||
| Line coverage | 94.9% (6767/7130) — above the 90% gate |
|
||
| Function coverage | 98.5% (447/454) |
|
||
| 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`.
|
||
3. **An armed `TRAP` made a parse error vanish outright.** Found the same way, writing §8
|
||
item 11's error-code appendix: a program with `TRAP` set and a line that would not parse
|
||
printed *nothing at all* and exited zero. No error line, because
|
||
`akbasic_runtime_error()` intercepts for the trap and deliberately prints nothing; and no
|
||
handler, because the parse branch of `process_line_run()` then set `run_finished_mode`
|
||
regardless — QUIT for a file run — so the next line boundary the handler would have been
|
||
entered at was never reached. **Arming an error handler made errors disappear**, which is
|
||
the exact opposite of the verb's purpose, and it was silent in both directions.
|
||
|
||
The guard is one `if`: set the finished mode only when the error was actually reported.
|
||
The runtime path was always right, because `report_and_reraise()` never touched the mode.
|
||
|
||
**And the same branch never recorded the status**, so the handler that now runs was
|
||
handed `ER# 0`. It sets `obj->lasterrorstatus` from the context the way
|
||
`report_and_reraise()` does, and a trapped `SCALE` with no arguments now reports 512.
|
||
Both halves in `tests/trap_verbs.c`.
|
||
|
||
None of the three was on any list. All 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.
|
||
|
||
**A fourth is open, and it is the same defect as item 2 on a path that fix did not cover.**
|
||
Writing `docs/14-architecture.md` meant describing where the boundary between a script's
|
||
error and the host's error actually sits, and the answer is: around parsing and around
|
||
`interpret()`, but **not around scanning**. `akbasic_runtime_process_line_repl()` and
|
||
`akbasic_runtime_process_line_run()` both call `akbasic_scanner_scan()` under a bare `PASS`
|
||
(`src/runtime.c:825` and `:921`), so any error the scanner raises leaves `step()` as an
|
||
interpreter error. The reachable one is the token ceiling:
|
||
|
||
```sh norun
|
||
$ printf 'PRINT 1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1\n' | ./build/basic
|
||
src/scanner.c:add_token:87: 515 (Out Of Bounds) : Line 0 has more than 32 tokens
|
||
... eight more frames ...
|
||
akbasic terminated on an unhandled error 515 (Out Of Bounds)
|
||
```
|
||
|
||
Exit status 1, a stack trace on stderr, and the prompt gone — for a line a person typed. An
|
||
embedding host is handed a context for what is plainly the script's mistake, which is what
|
||
goal 3 exists to prevent. It should print `? N : PARSE ERROR Line N has more than 32 tokens`
|
||
and stop the run, exactly as a parse error does two lines further down.
|
||
|
||
The fix is the same `ATTEMPT`/`HANDLE_DEFAULT`/`akbasic_runtime_error()` wrapper already
|
||
sitting around `akbasic_parser_parse()` in both functions, moved up to cover the scan.
|
||
Three call sites to check, because `process_line_runstream()` has the same bare `PASS` and
|
||
its answer may want to differ: a bad line arriving from a file mid-load is not the same
|
||
situation as one typed at a prompt. Wants a test in `tests/scanner_tokens.c` asserting the
|
||
error line rather than the raised status, and one in `tests/housekeeping_verbs.c` asserting
|
||
the REPL survives it — the shape item 2's test already has.
|
||
|
||
**A separate one was found while designing optional line numbers, and is fixed.**
|
||
`akbasic_runtime_process_line_runstream()` filed the *raw* buffer, line number and all, while
|
||
`akbasic_runtime_load()` and `DLOAD` filed what the scanner had stripped. The `if` that chose
|
||
between them tested `obj->mode == AKBASIC_MODE_REPL`, and nothing reaches that function except
|
||
the `RUNSTREAM` arm of `step()` — so the stripped branch was dead and every program the driver
|
||
ran from a file was stored with its own number inside `source[]`. `LIST` printed
|
||
`10 10 PRINT "ONE"`; `DSAVE` would have written it that way and `HELP` echoed it that way.
|
||
|
||
It went unseen because no `.bas` in either corpus calls `LIST`, and because the one test helper
|
||
that claimed to load "the way RUNSTREAM would" (`tests/runtime_verbs.c:30`) stored the scanned
|
||
line — that is, it encoded the *correct* contract and so could never catch the wrong one.
|
||
`src/runtime.c` now files `scanned`. `tests/runtime_verbs.c` drives the real sink path and
|
||
fails with `10 10 PRINT "ONE"` when the fix is reverted.
|
||
|
||
The step-over-a-leading-number allowance in `scan_line_labels()` (`src/runtime.c:1235`) and
|
||
`skip_lineno()` (`src/structtype.c:42`) is kept, and both comments now say why: no path inside
|
||
the library stores a raw line any more, but `akbasic_runtime_store_line()` is public and a host
|
||
may hand it one.
|
||
|
||
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.
|
||
|
||
**The denominator moved a long way and this is not an explanation of why.** The line total
|
||
went from 4076 to 6222 between the previous entry and this one, which is far more than the
|
||
work since added — so one of the two measurements is counting something the other is not,
|
||
and which one is right has not been established. Both figures were produced with
|
||
`gcovr --filter 'src/.*'` over a clean tree. Recorded rather than quietly overwritten,
|
||
because a coverage percentage whose denominator nobody can account for is worth exactly as
|
||
much as the accounting. The *ratio* is above the gate on either reading.
|
||
|
||
**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` | 2.0.1 | 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`. **2.0.0 is thread safe and an ABI break** (`libakerror.so.2`): `__akerr_last_ignored` is thread-local and `akerr_next_error()` returns an owned reference, neither of which fails to link when mismatched. **2.0.1 fixes an exit status that mattered more to this band than to any other** — see below. |
|
||
| `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.7.0 | soname `libakgl.so.0.7`. 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 was a leak-and-overread release that changed no public struct. **0.5.0 is the first that broke our source as well as our ABI**: it namespaced every exported symbol, so `akgl_render_bind2d` is `akgl_render_2d_bind`, `akgl_sprite_sheet_coords_for_frame` is `akgl_spritesheet_coords_for_frame`, the `renderer`/`camera`/`window` globals carry the prefix, and `_akgl_renderer`/`_akgl_camera` are `akgl_default_renderer`/`akgl_default_camera`. `include/akbasic/akgl.h` asserts the floor. **0.6.0 and 0.7.0 broke nothing here** — 0.6.0 is three arcade-physics fixes and a `physics.max_timestep` property this port does not use, and 0.7.0 reports failures `libakstdlib`'s wrappers were already catching and takes `libakerror` 2.0.1. The floor moved anyway, because deciding for ourselves which of libakgl's minor releases were really compatible is the judgement the soname exists to take away. |
|
||
|
||
**An unhandled error in this band used to exit zero, and 512 is the worst possible base for
|
||
that.** `libakerror`'s default unhandled-error handler ended in `exit(errctx->status)`, and a
|
||
process exit status is one byte — the kernel keeps the low 8 bits and discards the rest.
|
||
`AKBASIC_ERR_BASE` is 512, and **512 truncates to 0**, so an unhandled `AKBASIC_ERR_SYNTAX`
|
||
reported *success* to a shell, a CI job or a supervisor watching `$?`. Every other code in the
|
||
band came out as some unrelated error's number: 515 as 3, 519 as 7.
|
||
|
||
libakerror 2.0.1 routes the handler through `akerr_exit()`, which substitutes
|
||
`AKERR_EXIT_STATUS_UNREPRESENTABLE` (125) for anything a byte cannot carry. Verified here
|
||
rather than taken on trust: a probe raising `AKBASIC_ERR_DEVICE` through `FINISH_NORETURN`
|
||
exits 125.
|
||
|
||
**It was latent here, not live**, and that is worth stating precisely rather than claiming a
|
||
narrow escape. `src/main.c` handles the context itself and returns `EXIT_FAILURE`, and every
|
||
test with a top-level `ATTEMPT` carries a `HANDLE_DEFAULT` — so nothing in this repository ever
|
||
reached the defaulted handler. `libakgl` was not so lucky and found it the hard way: its
|
||
`tests/character.c` had been passing while running one of its four tests, because `AKGL_ERR_SDL`
|
||
is exactly 256 and exited 0.
|
||
|
||
The guard is now two `#error`s in `include/akbasic/error.h` and two assertions in
|
||
`tests/version_check.c`, including one that fails if `AKBASIC_ERR_BASE` stops truncating to
|
||
zero — because the day the band moves is the day this note stops being about *our* base, and a
|
||
silent change of subject is how a comment becomes a lie.
|
||
|
||
**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 operations.**~~ **Done**, and the premise
|
||
turned out to be half wrong in a way worth keeping. The documentation did say coordinates
|
||
are 320x200 whatever the window is — but nothing ever stretched them: with `SCALE` off a
|
||
coordinate went straight to `akgl_draw_*` as a pixel address, so an 800x600 window drew a
|
||
listing into its top-left corner and left the rest unused. The described transform did not
|
||
exist.
|
||
|
||
`akbasic_GraphicsBackend` grew an optional `size` entry point, `require_graphics()` asks it
|
||
before every verb that draws so a resized window is honoured between statements, `SCALE`
|
||
maps onto the answer instead of onto the constants, and `RGR(1)` / `RGR(2)` let a program
|
||
read the size. 320x200 is now the fallback for a backend that will not answer. Deviation 18
|
||
in §5 has the whole of it, including the off-by-one it fixed on the way.
|
||
|
||
11. ~~**Error codes are undefined for the user.**~~ **Done** — `docs/15-error-codes.md`, an
|
||
appendix covering the four error classes the interpreter prints, the eight codes it owns
|
||
with what raises each, and the codes that reach `ER#` from `errno`, `libakerror` and
|
||
`libakgl` underneath it. The table is not asserted: a program in the chapter trips seven
|
||
of the eight and prints what it got, and `docs_examples` byte-compares the result.
|
||
|
||
**It found three things rather than documenting eight.** A parse error under an armed
|
||
`TRAP` vanished entirely and a trapped parse error reported `ER# 0` — both fixed, both
|
||
recorded above. And `VAL` reports a dependency's status code where the interpreter has
|
||
one of its own, which is §6 item 21 and is filed rather than fixed.
|
||
|
||
12. ~~**Screenshots for graphics operations.**~~ **Done** — eight figures, six in
|
||
`docs/06-graphics.md` and two in `docs/08-sprites.md`. The item said chapter 8; chapter 8
|
||
is sprites and chapter 6 is the drawing verbs, so both got them.
|
||
|
||
**A figure here is output, not an asset**, which is the part worth keeping. Each one is
|
||
generated by running the listing printed immediately above it:
|
||
|
||
- `tools/screenshot.c` is a second SDL host, and a much smaller one than
|
||
`src/frontend_akgl.c` — dummy video driver, software renderer, run to completion,
|
||
`SDL_RenderReadPixels`, `IMG_SavePNG`. The pattern is `deps/libakgl/tests/draw.c`'s and
|
||
this is its third user. It draws no text layer on purpose: a `READY` in the corner is
|
||
noise in a figure about `BOX`, and skipping it means no font has to be resolved.
|
||
- `tools/docs_screenshots.sh` reads the new `screenshot=NAME` fence tag straight out of
|
||
the markdown, so the picture cannot drift from the code beside it without somebody
|
||
editing one of the two. `size=WxH` is the second tag, and `SCALE`'s figure uses it —
|
||
640x400, because the point being illustrated is a 320x200 listing filling a larger
|
||
window, and that point cannot be made on a 320x200 surface.
|
||
- **Two gates, and they answer different questions.** `docs_examples` checks that a
|
||
tagged block *has* an image, in both configurations, so a figure cannot be added and
|
||
forgotten. `docs_screenshots` — a CTest, AKGL build only — re-renders every figure and
|
||
compares byte for byte, so a listing edited without regenerating fails. The first
|
||
catches a missing picture; only the second catches a *stale* one, which is the failure
|
||
this whole arrangement exists against.
|
||
|
||
The byte comparison is a deliberate bet that the dummy driver and the software renderer
|
||
are reproducible, which is the same bet `tests/reference/` already makes about golden
|
||
output. Verified run-to-run and build-to-build here; what is untested is an SDL upgrade
|
||
that moves one pixel of a diagonal. **If that happens, regenerate the figures in the same
|
||
commit as the bump** — do not weaken the test to a size check, which would pass for every
|
||
wrong picture that happened to be 320x200.
|
||
|
||
The PNGs are checked in, because a reader on the forge has no build tree, and
|
||
`docs/images/README.md` says loudly that they are generated so nobody hand-edits one.
|
||
Regenerating is never part of a build: `cmake --build build-akgl --target
|
||
docs_screenshots` is a deliberate act, so a `make` cannot quietly put eight binary diffs
|
||
in front of whoever ran it.
|
||
|
||
**Writing them turned up a documentation defect of this file's own**, recorded at §5
|
||
deviation 16: that entry claimed in bold that `BOX` fills on a negative angle, while its
|
||
own body said the fill was filed rather than implemented. Drawing the figure and getting
|
||
an outline back is what caught it. `akbasic_GraphicsBackend::filled_rect` is implemented,
|
||
recorded by the mock, and reached by no verb at all as a result.
|
||
|
||
---
|
||
|
||
## 9. Defects found by writing a game against it
|
||
|
||
`examples/breakout/sprites/breakout.bas` is a complete Breakout — sprite artwork,
|
||
powerups, a stroke-font HUD — written in this dialect, for this interpreter, by somebody who
|
||
had not written a line of it before. Eight things came out of that, and they are here for the
|
||
same reason §8's three are: **none of them was on any list, and all of them were found by
|
||
using the thing rather than by testing it.** The corpus reaches none of them.
|
||
|
||
Ordered by blast radius. Each has a minimal reproduction that fits on a screen; every one was
|
||
reduced against `build/basic`, the stdio build, unless it says otherwise.
|
||
|
||
1. ~~**Every call to a user-defined function leaks value-pool slots, so a program may call one
|
||
about four thousand times and then die.**~~ **Done** with §6 item 30, and by the same
|
||
change: the leaking slot was the call scope's parameter, which is a scalar and no longer
|
||
comes from the pool. Both `DEF` forms run eight thousand calls in
|
||
`tests/user_functions.c`. The original report:
|
||
|
||
```basic
|
||
DEF ADDIT(N#)
|
||
RETURN N# + 1
|
||
R# = 0
|
||
FOR I# = 1 TO 8000
|
||
R# = ADDIT(I#)
|
||
NEXT I#
|
||
```
|
||
|
||
```output
|
||
? 5 : RUNTIME ERROR Array of 1 elements does not fit in the 0 remaining value slots
|
||
```
|
||
|
||
It fails between 4000 and 5000 calls, which is `AKBASIC_MAX_ARRAY_VALUES` (4096,
|
||
`include/akbasic/types.h:29`) at one slot per call — the call scope's parameter. **Both
|
||
`DEF` forms leak**; the single-expression `DEF SQR1(X#) = X# * X#` fails at the same point.
|
||
A `GOSUB` doing the same work runs eight thousand times without complaint, which is the
|
||
comparison that isolates it.
|
||
|
||
`akbasic_valuepool_take()` (`src/value.c:142`) is a bump allocator — `obj->next += count` at
|
||
`:160` — and **nothing anywhere returns a slot**. The only reset is
|
||
`akbasic_valuepool_init()`, from `src/runtime.c:209` at startup and
|
||
`src/runtime_housekeeping.c:53` for `CLR`/`NEW`. Environments *are* released back to their
|
||
own pool (§1.4, and §6 item 11 says so), but the value slots the variables in them held are
|
||
not: `src/environment.c:321` takes them through `akbasic_variable_init()` and the release
|
||
path has no counterpart.
|
||
|
||
The consequence is a hard ceiling on a whole language feature. A game calling one function
|
||
per frame at 30fps gets a little over two minutes. It is why the game in question spells its
|
||
pseudo-random generator as a `GOSUB` over globals rather than the `DEF` it wants to be.
|
||
|
||
Fixing it means giving the pool a free list, or giving each environment a value arena
|
||
released with it. The second is closer to the house style and to what §1.4 already does for
|
||
environments; it touches `src/value.c`, `src/variable.c`, `src/environment.c` and
|
||
`include/akbasic/value.h`. A test belongs in `tests/user_functions.c`: call a `DEF` ten
|
||
thousand times and then `DIM` an array.
|
||
|
||
2. ~~**A `BEGIN` block that is skipped leaks a scope for every loop inside it**, because
|
||
`FOR` and `DO` create their environment at *parse* time and the skip is decided at
|
||
*evaluate* time.~~ **Done** — the skip now releases what parsing pushed
|
||
(`akbasic_runtime_interpret()`, `src/runtime.c`), taking the second of the two routes
|
||
below. `tests/structure_verbs.c` covers both loop forms inside a routine and the
|
||
forty-skip top-level case.
|
||
|
||
**It is narrower than it looks, and the golden corpus is why.** The first attempt
|
||
released the scope on *any* skip, which broke
|
||
`tests/reference/language/flowcontrol/nestedforloopwaitingforcommand.bas`: a zero-
|
||
iteration `FOR` skips its body by the same mechanism, and there the orphan is
|
||
load-bearing -- it is what absorbs the inner `NEXT` so the outer one still finds its
|
||
`FOR`. Releasing it turns that case into "NEXT outside the context of FOR". So the
|
||
release is conditional on the skip being a *block* skip, which is testable because a
|
||
skipped block can never contain a live `NEXT` wait. Both halves are now asserted side by
|
||
side in `tests/structure_verbs.c`.
|
||
|
||
The original report:
|
||
|
||
```basic
|
||
N# = 0
|
||
LABEL AGAIN
|
||
N# = N# + 1
|
||
IF 1 = 0 THEN BEGIN
|
||
FOR I# = 0 TO 2
|
||
N# = N# + 1000
|
||
NEXT I#
|
||
BEND
|
||
IF N# < 40 THEN GOTO AGAIN
|
||
```
|
||
|
||
```output
|
||
? 5 : PARSE ERROR Environment pool exhausted at line 5 (32 in use)
|
||
```
|
||
|
||
Thirty-two skips is `AKBASIC_MAX_ENVIRONMENTS` (`include/akbasic/types.h:31`) at one per
|
||
skip. Inside a subroutine it announces itself on the very first skip and much more
|
||
confusingly — the orphaned scope sits between the routine and its caller, so the `RETURN`
|
||
after the block reports from a routine that plainly *was* entered by a `GOSUB`:
|
||
|
||
```basic
|
||
T# = 0
|
||
GOSUB DOIT
|
||
PRINT "came back"
|
||
END
|
||
LABEL DOIT
|
||
IF 1 = 0 THEN BEGIN
|
||
FOR I# = 0 TO 2
|
||
T# = T# + 1
|
||
NEXT I#
|
||
BEND
|
||
RETURN
|
||
```
|
||
|
||
```output
|
||
? 11 : RUNTIME ERROR RETURN outside the context of GOSUB
|
||
```
|
||
|
||
That is the form it was found in, and it costs an evening because the error names the one
|
||
construct that is not at fault.
|
||
|
||
**The cause is an ordering mismatch and it is exact.** `akbasic_parse_do()`
|
||
(`src/parser_commands.c:266`) and `akbasic_parse_for()` (`:849`) both call
|
||
`akbasic_runtime_new_environment()` while parsing the line. The block skip is tested in
|
||
`akbasic_runtime_interpret_line()` (`src/runtime.c:767`), which runs *after* the line has
|
||
been parsed — so a skipped `FOR` pushes a scope, its execution is skipped, and the `NEXT`
|
||
that would pop it is skipped too. `BEGIN` itself pushes nothing, which is why a nested
|
||
`BEGIN`/`BEND` inside a skipped block is harmless, and a `GOSUB` inside one is harmless for
|
||
the same reason.
|
||
|
||
Confirmed for both loop forms: `DO`/`LOOP` in a skipped block fails identically. Confirmed
|
||
for both kinds of enclosing routine: a multi-line `DEF` reports the same thing.
|
||
|
||
A fix has to either push the loop's scope at execution rather than at parse, or have the
|
||
skip release what parsing pushed. Touches `src/parser_commands.c`, `src/runtime.c` and
|
||
`tests/structure_verbs.c`, which today only exercises blocks whose bodies are plain
|
||
statements.
|
||
|
||
3. **In the standalone SDL build, nothing the graphics verbs draw can be seen unless the
|
||
program shrinks the text area first.** **Half done.** The tee sink now forwards
|
||
`WINDOW` (§6 item 31), so `sink_window()` is reachable and a program *can* take rows
|
||
back from the text layer -- which is the half that made this unanswerable. What stands
|
||
is the default: the text area is the whole window, so a program that does not call
|
||
`WINDOW` still cannot see a drawing.
|
||
|
||
Whether that default is right is a real question rather than a defect, and it is
|
||
entangled with what no `WINDOW` call fixes -- the frontend never clears and SDL
|
||
double-buffers, so a drawing has to be re-issued every frame, and it has to fit inside
|
||
one batch to survive a capture (item 5). Both are now documented in chapters 6, 13 and
|
||
14. **Sprites remain the only thing visible for free**, so the trick the game uses is
|
||
still the right one for a game; it should not be the *only* way, and it no longer is.
|
||
|
||
The original report, whose second bullet is fixed and whose first stands:
|
||
|
||
```basic
|
||
GRAPHIC 1, 1
|
||
COLOR 1, 3
|
||
BOX 1, 100, 300, 700, 500
|
||
PRINT "THE TEXT SHOWS AND THE BOX DOES NOT"
|
||
LABEL SPIN
|
||
GOTO SPIN
|
||
```
|
||
|
||
The text appears; the box never does. Two mechanisms combine, and each alone would be
|
||
survivable:
|
||
|
||
- `akbasic_sink_akgl_render()` fills **every row of the text area, opaque, every frame**
|
||
(`src/sink_akgl.c:564`), and the area is the whole window — `state->rows = h / cellh` at
|
||
`:505`. `akbasic_frontend_akgl_pump()` calls it after `akbasic_runtime_run()` and before
|
||
`SDL_RenderPresent`, so it paints over everything the program drew during those steps.
|
||
The comment at `:564` explains why it must repaint rather than track dirty rows, and that
|
||
reasoning is sound; what it does not account for is that the rows it owns are all of them.
|
||
- `WINDOW` would shrink the text area out of the way, and the akgl sink implements it
|
||
(`sink_window`, `src/sink_akgl.c:413`) — but **the tee sink in front of it never assigns
|
||
`obj->window`** (`src/sink_tee.c:143-151` assigns `clear` and conditionally `moveto`, and
|
||
stops). So `WINDOW` refuses with "needs a text device with a character grid"
|
||
(`src/runtime_console.c:179`) in the one build that has a character grid.
|
||
|
||
The result is that **chapter 6 cannot be run in the interpreter it documents**. Its figures
|
||
are right because `tools/screenshot.c` deliberately omits the text layer — the comment in
|
||
that file says so — so the harness that proves the chapter is exactly the harness that hides
|
||
this. `docs_screenshots` passes and always would.
|
||
|
||
Sprites are unaffected: `akbasic_sprite_akgl_render()` runs after the sink, which is why the
|
||
game draws its entire screen by `SSHAPE`-ing what it drew and installing it with `SPRSAV`.
|
||
That is a fine trick and it should not be the only way.
|
||
|
||
The tee half is three lines and is worth doing whatever is decided about the rest.
|
||
|
||
4. ~~**Mixed-type arithmetic is decided by the left operand alone, and nothing says so.**~~
|
||
**Done, as documentation, by decision.** The behaviour is unchanged: a dialect is
|
||
allowed to say the left operand wins and this one is consistent about it, and promotion
|
||
would change the result of every mixed expression in every existing program. What was
|
||
missing was anybody saying so. Now: a section in `docs/03-the-language.md` with the
|
||
two-line demonstration and the two rules that keep a program out of it, a row in
|
||
`docs/13-differences.md` naming it as the difference from 7.0 most likely to turn a
|
||
working listing into a quietly wrong one, and the reasoning on `include/akbasic/value.h`
|
||
where the operators are declared.
|
||
|
||
`tests/value_arithmetic.c` pins it in both directions, so **promotion is now a decision
|
||
somebody has to take deliberately** rather than a change that could slip in. The
|
||
original report:
|
||
|
||
```basic
|
||
A# = 3
|
||
PRINT A# * 0.45
|
||
PRINT 0.45 * A#
|
||
```
|
||
|
||
```output
|
||
0
|
||
1.350000
|
||
```
|
||
|
||
`akbasic_value_math_plus()` and its siblings branch on `self->valuetype`
|
||
(`src/value.c:510`, `:512`, `:539`) and convert the right operand to match, so an integer on
|
||
the left silently truncates a float on the right. **This is inherited, not introduced** —
|
||
`deps/basicinterpret/basicvalue.go:190-193` has the same shape — and §6 item 5 already fixed
|
||
the other half of that expression without changing the branch.
|
||
|
||
It may well be intended; a dialect is allowed to say the left operand wins, and this one is
|
||
consistent about it. The defect is that **it is documented nowhere**. Chapter 3's "Numbers"
|
||
does not mention it, chapter 13 does not list it among the differences, and a C128 promotes
|
||
to float instead. Nothing fails when you get it wrong — the program computes something else
|
||
and carries on.
|
||
|
||
It cost the game two live bugs before either was noticed. `SPD% = 5.6 + LEVEL# * 0.45`
|
||
evaluated to a flat 5.6, so no level ever ran faster than level one; and `0 - BLVX%(B#)`,
|
||
the obvious way to reverse a ball, quantised its velocity to whole pixels on every bounce
|
||
and bled speed out of it. Both read correctly. Neither produced a diagnostic.
|
||
|
||
The cheap fix is documentation: a paragraph in chapter 3 and a row in chapter 13. The other
|
||
fix is promotion, which would be a real change of semantics and wants its own decision.
|
||
|
||
5. ~~**A drawing that spans a 256-line batch boundary is torn, not merely transient.**~~
|
||
**Done, as documentation.** No fix is possible without changing what a host owns -- the
|
||
budget is the host's and the present is the host's -- so what was needed was for the
|
||
consequence to be written down beside the advice it qualifies. `docs/13-differences.md`
|
||
now says that "redraw it every frame" is not sufficient on its own and carries the
|
||
measured numbers, `docs/06-graphics.md` says it where `SSHAPE` is introduced, and
|
||
`docs/14-architecture.md` explains the mechanism from the step loop's side and points at
|
||
the `TI#` synchronisation a program has to build.
|
||
|
||
**Untested, and deliberately:** it needs the real frontend, a clock and a timing
|
||
window, and a test that reproduced it would be reproducing a race. Said here rather than
|
||
left looking like an oversight. The original report: §5 and
|
||
chapter 13 record that drawing does not persist across frames. What neither says is that a
|
||
single uninterrupted sequence of drawing verbs longer than one `akbasic_runtime_run()` budget
|
||
comes back **half drawn**, because the present in the middle of it discards the buffer — so
|
||
an `SSHAPE` at the end captures only the statements issued since that present, over whatever
|
||
the frame before left behind.
|
||
|
||
Measured against `AKBASIC_FRONTEND_STEPS_PER_FRAME` of 256, in the SDL build: after
|
||
synchronising to a jiffy edge, 110 `DRAW` statements in a `FOR` loop (220 executed lines)
|
||
are captured whole; 125 (250 lines) capture nothing at all; 140 (280 lines) capture the last
|
||
fifteen. A program cannot see the boundary except by watching `TI#` change, which is what
|
||
the game's `PACE` routine does and what makes the whole thing workable.
|
||
|
||
This is worth a paragraph in chapter 13 beside the existing note, because "redraw it every
|
||
frame" is the advice there and it is not sufficient advice — the redraw also has to fit.
|
||
|
||
6. ~~**`SSHAPE` and `GSHAPE` ignore the subscript on a string array element.**~~ **Done** --
|
||
`shape_variable()` (`src/runtime_graphics.c`) now resolves the subscript the way
|
||
`SPRSAV` does, through a shared `akbasic_environment_collect_subscripts()` lifted out of
|
||
`src/environment.c` so that a verb taking a variable by name resolves one exactly as
|
||
assignment does. `tests/graphics_verbs.c` covers the reduction and the case a program
|
||
actually wants: two shapes captured into two elements and each stamped back by its own.
|
||
|
||
The original report, which needed a graphics device and was reduced against
|
||
`build-akgl/basic`:
|
||
|
||
```basic
|
||
DIM SH$(4)
|
||
SSHAPE SH$(2), 0, 0, 61, 4
|
||
PRINT "[" + SH$(0) + "] [" + SH$(2) + "]"
|
||
```
|
||
|
||
```output
|
||
[SHAPE:0] []
|
||
```
|
||
|
||
The handle lands in `SH$(0)`; `SH$(2)` stays empty. `GSHAPE SH$(2)` then stamps whatever is
|
||
in `SH$(0)`. Ordinary assignment and `PRINT` honour the subscript, so a program that keeps
|
||
several saved shapes in an array gets every one of them resolving to element zero, silently.
|
||
|
||
`shape_variable()` (`src/runtime_graphics.c:625`) takes `arg->identifier` and calls
|
||
`akbasic_environment_get()` — it never evaluates the subscript — and both verbs then use a
|
||
literal zero: `src/runtime_graphics.c:688` writes with `zerosubscript`, `:724` reads with it.
|
||
**`SPRSAV` is the counter-example and the model for the fix**: it calls
|
||
`akbasic_runtime_evaluate()` on its argument (`src/runtime_sprite.c:480`) and handles an
|
||
array element correctly.
|
||
|
||
The game keeps six brick stamps in six separate scalars because of this, and the workaround
|
||
is not obvious from the failure — every brick simply comes out the colour of the last one
|
||
captured. `tests/graphics_verbs.c` is where the regression goes.
|
||
|
||
7. ~~**`GRAPHIC CLR` is documented and refused.**~~ **Done** -- `akbasic_parse_graphic()`
|
||
(`src/parser_commands.c`) takes `CLR` as this verb's keyword argument and emits mode 5,
|
||
which `akbasic_cmd_graphic()` already treats as exactly this, so both spellings are one
|
||
statement and the exec handler is unchanged. The documentation was right and the parser
|
||
was not; `tests/parser_commands.c` now pins the form the two chapters give.
|
||
|
||
The original report: `docs/06-graphics.md:65` and
|
||
`docs/11-verb-reference.md:54` both give the form as `GRAPHIC mode | CLR`.
|
||
|
||
```basic
|
||
GRAPHIC CLR
|
||
```
|
||
|
||
```output
|
||
? 1 : PARSE ERROR 1 at 'CLR', Expected expression or literal
|
||
```
|
||
|
||
`GRAPHIC` parses with `akbasic_parse_arglist` (`src/verbs.c:86`), so `CLR` scans as the `CLR`
|
||
verb rather than as a keyword argument. `GRAPHIC 5` does the job and is what the game calls.
|
||
Either the parse handler learns the word or both documents lose it; the second is smaller and
|
||
the first is what a C128 programmer will type.
|
||
|
||
Worth noting because `docs_examples` cannot catch it: every fenced `GRAPHIC CLR` in the
|
||
documentation is prose, not a tagged runnable block.
|
||
|
||
8. ~~**A `DATA` line holding a negative number cannot be executed.**~~ **Done** --
|
||
`akbasic_parse_data()` accepts a unary minus over a numeric literal, which is the only
|
||
shape that was refused. Deliberately narrow: `DATA -A#` is still a mistake worth naming,
|
||
and `akbasic_leaf_is_literal()` keeps meaning what it says because it is used elsewhere.
|
||
`tests/read_data.c` covers reading one *and* walking past the line, which were two
|
||
different mechanisms and only one of them was ever wrong.
|
||
|
||
The original report: §4 settled that "`DATA` at
|
||
run time is now a no-op: it is a declaration, and reaching the statement means walking past
|
||
it." Walking past a negative one raises instead:
|
||
|
||
```basic
|
||
READ A#
|
||
PRINT "READ GAVE " + A#
|
||
DATA -5
|
||
PRINT "REACHED THE LINE AFTER THE DATA"
|
||
```
|
||
|
||
```output
|
||
READ GAVE -5
|
||
? 3 : PARSE ERROR Expected literal
|
||
```
|
||
|
||
`READ` handles the value correctly — the negative comes back intact — so this is only the
|
||
statement's own argument parse, in `akbasic_parse_data()`
|
||
(`src/parser_commands.c:934`), refusing a leading `-` where the prescan accepted it. The
|
||
positive form falls through cleanly.
|
||
|
||
Reachable in ordinary code: a table of `DATA` at the foot of a program is walked into by any
|
||
routine above it that ends in a fall-through rather than an `END`, and a table of angles or
|
||
offsets is exactly where negative numbers live. `tests/read_data.c`.
|
||
|
||
9. **A program that wants a picture on the screen has to spend a sprite slot on it, and
|
||
`examples/breakout/sprites/breakout.bas` spends two of its eight on the screen itself.**
|
||
|
||
That listing draws its play field -- walls, sixty stamped bricks, the lettering -- captures
|
||
the whole 800 by 540 region with `SSHAPE`, and installs the capture as sprite 2. It does the
|
||
same for the HUD strip as sprite 1. Its own header calls them "the screen". It reads as a
|
||
silly thing to do and it is not: it is the only thing that works, and it is working around
|
||
the combination of item 3 and item 5 rather than choosing anything.
|
||
|
||
Neither defect alone would force it:
|
||
|
||
- **Item 3** says the text layer owns every row of the window by default, so a drawing is
|
||
painted over before it is presented. That half is now answerable -- `WINDOW` is reachable
|
||
since §6 item 31, so a program *can* take rows back.
|
||
- **Item 5** is what `WINDOW` does not fix. The frontend never clears and SDL double-buffers,
|
||
so a drawing has to be **re-issued every frame**, and a re-issue has to fit inside one
|
||
256-line batch to survive a present. Breakout's field is sixty `GSHAPE` stamps plus two
|
||
`BOX`es plus a stroke-drawn banner. It does not fit in one batch and never will.
|
||
|
||
So the program cannot draw its field every frame, and a drawing it makes once does not
|
||
survive. A sprite is drawn from state the interpreter keeps, every frame, for nothing --
|
||
which is exactly the property the field needs and the only place in the interpreter that
|
||
has it.
|
||
|
||
**What it costs, now measured rather than asserted:**
|
||
|
||
- **Two of eight sprite slots.** That is the whole reason the bricks are drawn rather than
|
||
made of artwork, why only one gem can fall at a time, and why chapter 18 opens with a
|
||
sprite budget table. Every design compromise in that game traces back here.
|
||
- **4.5% of a frame in collision alone.** Sprite 2's bounding box covers the entire play
|
||
field, so every moving sprite overlaps it permanently and the broad-phase reject can never
|
||
throw those pairs out. `tests/collision_perf.c` has the row: 211.8 ns a scan for that
|
||
layout against 54.9 ns for eight sprites that do not overlap. The game pays it on every
|
||
one of 256 scans a frame to collide with *the backdrop*.
|
||
- **A whole chapter of documentation.** Chapter 18 Step 3 is called "Turn a drawing into a
|
||
sprite" and exists solely to teach the workaround.
|
||
|
||
**What would fix it: somewhere to draw that persists and is not a sprite.** A drawing layer
|
||
the sink composites beneath the text layer and the sprites, that a program writes once and
|
||
the frontend does not discard. `GRAPHIC` already selects a mode; the natural shape is for a
|
||
drawing mode to mean "this layer is yours and it stays" rather than "this is thrown away at
|
||
the next present". That is `src/sink_akgl.c` and `src/frontend_akgl.c`, and it is a real
|
||
design decision about what a frame owns rather than a patch -- which is why this is a filed
|
||
item and not a fix.
|
||
|
||
Nothing in libakgl 0.8.0 supplies it. There is no render-to-texture layer and no persistent
|
||
surface in `include/akgl/renderer.h`; `frame_start` clears the target, which is the opposite.
|
||
If the answer turns out to want one, it is a §7 filing against `libakgl` rather than
|
||
something to build here.
|
||
|
||
**Do not "fix" this by rewriting the game.** The listing is correct for the interpreter it
|
||
was written against, and item 3's own entry says the trick "is still the right one for a
|
||
game; it should not be the *only* way". The game changes when the interpreter gives it
|
||
something better, and chapters 17 and 18 change with it -- see §6 item 39.
|
||
|
||
**A note on what this list is.** Items 1, 2, 6, 7 and 8 are defects by any reading. Item 3 is a
|
||
design consequence nobody chose, and item 9 is what that consequence costs a real program.
|
||
Item 4 may be intended and is a documentation gap either way. Item 5 sharpens something already
|
||
recorded. None of them is a complaint about the interpreter,
|
||
which was a pleasure to write a real program against — they are the kind of thing one real
|
||
program finds and a test suite written alongside the implementation structurally cannot,
|
||
because the suite tests the constructs the implementer had in mind and a game reaches for
|
||
whatever it needs.
|