Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
# 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
2026-07-31 12:52:00 -04:00
### 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.
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
**Read these before touching anything**, in this order:
Split the documentation by who reads it
README.md was 577 lines and answered four different questions at once: what
the project is, how to build it, every verb and function in the language, and
how to maintain the test harness. The verb and function lists had already been
written a second time in docs/11 and docs/12, which is how a list of that size
goes stale -- there is no way to notice the two have drifted apart.
README.md is now 150 lines and holds only what somebody evaluating the project
needs: what it is, the quickstart, why it was rewritten in C, the five rules
embedding imposes on the design, the two ways to use it, and where everything
else lives. Technical detail goes to docs/, maintenance to MAINTENANCE.md.
The akbasic_TextSink struct moved to docs/10-embedding.md rather than being
deleted. It was the corpus's only `c excerpt=` block -- the check that caught
the stale struct two commits ago -- so dropping it with the README would have
quietly retired a test. docs/10 also stopped claiming README.md carries the
full API surface and the pool limits, which the trim made false.
CLAUDE.md went from 458 lines to 62, because almost none of it was
agent-specific. The project goals, the Go reference and its architecture, the
dependency version and ABI rules, the four ways an embedded build collides,
the libakerror convention, the error-code range map and the style rules are
all things a maintainer needs, and they are now in MAINTENANCE.md with one
copy to keep true. CLAUDE.md points there and keeps only the rules no test
enforces: tests in the same commit asserting the correct contract, file a
missing dependency capability upstream, do not edit generated output or
tests/reference/, co-author your commits.
Four claims did not survive the move, having gone stale where nothing could
notice:
- "The repository is currently empty apart from its submodules -- no
commits, no source tree, no build files." There are 43 commits.
- libakgl's target_compile_definitions(akerror PUBLIC AKERR_MAX_ERR_VALUE)
at deps/libakgl/CMakeLists.txt:44, described as inert but present. It is
gone; only a historical mention in a comment remains.
- "akbasic_init() claims 512-767." There is no akbasic_init. It is
akbasic_error_register(), called from akbasic_runtime_init().
- Time-relative phrasing ("libakgl hit two of them in the last week").
Five places pointed at CLAUDE.md for the range map or the file-it-upstream
rule and now point at MAINTENANCE.md: include/akbasic/error.h,
src/runtime_disk.c and three entries in TODO.md. Both source changes are
comments. deps/libakgl/TODO.md cites it too and is left alone; it is a
submodule, and the rule it quotes is still reachable from CLAUDE.md.
ctest is green at 95 of 95, docs_examples included: 36 programs, 9
transcripts, 44 output comparisons, 3 C snippets, 1 excerpt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:59:43 -04:00
1. `MAINTENANCE.md` in this repository — project goals, the `libakerror` convention, the
error-code range map, and the dependency versions.
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
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.
2026-07-31 12:52:00 -04:00
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.
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
**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;
```
2026-07-31 10:57:02 -04:00
`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.
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
### 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
Split the documentation by who reads it
README.md was 577 lines and answered four different questions at once: what
the project is, how to build it, every verb and function in the language, and
how to maintain the test harness. The verb and function lists had already been
written a second time in docs/11 and docs/12, which is how a list of that size
goes stale -- there is no way to notice the two have drifted apart.
README.md is now 150 lines and holds only what somebody evaluating the project
needs: what it is, the quickstart, why it was rewritten in C, the five rules
embedding imposes on the design, the two ways to use it, and where everything
else lives. Technical detail goes to docs/, maintenance to MAINTENANCE.md.
The akbasic_TextSink struct moved to docs/10-embedding.md rather than being
deleted. It was the corpus's only `c excerpt=` block -- the check that caught
the stale struct two commits ago -- so dropping it with the README would have
quietly retired a test. docs/10 also stopped claiming README.md carries the
full API surface and the pool limits, which the trim made false.
CLAUDE.md went from 458 lines to 62, because almost none of it was
agent-specific. The project goals, the Go reference and its architecture, the
dependency version and ABI rules, the four ways an embedded build collides,
the libakerror convention, the error-code range map and the style rules are
all things a maintainer needs, and they are now in MAINTENANCE.md with one
copy to keep true. CLAUDE.md points there and keeps only the rules no test
enforces: tests in the same commit asserting the correct contract, file a
missing dependency capability upstream, do not edit generated output or
tests/reference/, co-author your commits.
Four claims did not survive the move, having gone stale where nothing could
notice:
- "The repository is currently empty apart from its submodules -- no
commits, no source tree, no build files." There are 43 commits.
- libakgl's target_compile_definitions(akerror PUBLIC AKERR_MAX_ERR_VALUE)
at deps/libakgl/CMakeLists.txt:44, described as inert but present. It is
gone; only a historical mention in a comment remains.
- "akbasic_init() claims 512-767." There is no akbasic_init. It is
akbasic_error_register(), called from akbasic_runtime_init().
- Time-relative phrasing ("libakgl hit two of them in the last week").
Five places pointed at CLAUDE.md for the range map or the file-it-upstream
rule and now point at MAINTENANCE.md: include/akbasic/error.h,
src/runtime_disk.c and three entries in TODO.md. Both source changes are
comments. deps/libakgl/TODO.md cites it too and is left alone; it is a
submodule, and the rule it quotes is still reachable from CLAUDE.md.
ctest is green at 95 of 95, docs_examples included: 36 programs, 9
transcripts, 44 output comparisons, 3 C snippets, 1 excerpt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:59:43 -04:00
code; `MAINTENANCE.md` 's error-code section is the condensed version. Three things this changes
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
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` .
Split the documentation by who reads it
README.md was 577 lines and answered four different questions at once: what
the project is, how to build it, every verb and function in the language, and
how to maintain the test harness. The verb and function lists had already been
written a second time in docs/11 and docs/12, which is how a list of that size
goes stale -- there is no way to notice the two have drifted apart.
README.md is now 150 lines and holds only what somebody evaluating the project
needs: what it is, the quickstart, why it was rewritten in C, the five rules
embedding imposes on the design, the two ways to use it, and where everything
else lives. Technical detail goes to docs/, maintenance to MAINTENANCE.md.
The akbasic_TextSink struct moved to docs/10-embedding.md rather than being
deleted. It was the corpus's only `c excerpt=` block -- the check that caught
the stale struct two commits ago -- so dropping it with the README would have
quietly retired a test. docs/10 also stopped claiming README.md carries the
full API surface and the pool limits, which the trim made false.
CLAUDE.md went from 458 lines to 62, because almost none of it was
agent-specific. The project goals, the Go reference and its architecture, the
dependency version and ABI rules, the four ways an embedded build collides,
the libakerror convention, the error-code range map and the style rules are
all things a maintainer needs, and they are now in MAINTENANCE.md with one
copy to keep true. CLAUDE.md points there and keeps only the rules no test
enforces: tests in the same commit asserting the correct contract, file a
missing dependency capability upstream, do not edit generated output or
tests/reference/, co-author your commits.
Four claims did not survive the move, having gone stale where nothing could
notice:
- "The repository is currently empty apart from its submodules -- no
commits, no source tree, no build files." There are 43 commits.
- libakgl's target_compile_definitions(akerror PUBLIC AKERR_MAX_ERR_VALUE)
at deps/libakgl/CMakeLists.txt:44, described as inert but present. It is
gone; only a historical mention in a comment remains.
- "akbasic_init() claims 512-767." There is no akbasic_init. It is
akbasic_error_register(), called from akbasic_runtime_init().
- Time-relative phrasing ("libakgl hit two of them in the last week").
Five places pointed at CLAUDE.md for the range map or the file-it-upstream
rule and now point at MAINTENANCE.md: include/akbasic/error.h,
src/runtime_disk.c and three entries in TODO.md. Both source changes are
comments. deps/libakgl/TODO.md cites it too and is left alone; it is a
submodule, and the rule it quotes is still reachable from CLAUDE.md.
ctest is green at 95 of 95, docs_examples included: 36 programs, 9
transcripts, 44 output comparisons, 3 C snippets, 1 excerpt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:59:43 -04:00
The offset scheme is what produced the live `libakgl` collision documented in
`MAINTENANCE.md` .
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
Split the documentation by who reads it
README.md was 577 lines and answered four different questions at once: what
the project is, how to build it, every verb and function in the language, and
how to maintain the test harness. The verb and function lists had already been
written a second time in docs/11 and docs/12, which is how a list of that size
goes stale -- there is no way to notice the two have drifted apart.
README.md is now 150 lines and holds only what somebody evaluating the project
needs: what it is, the quickstart, why it was rewritten in C, the five rules
embedding imposes on the design, the two ways to use it, and where everything
else lives. Technical detail goes to docs/, maintenance to MAINTENANCE.md.
The akbasic_TextSink struct moved to docs/10-embedding.md rather than being
deleted. It was the corpus's only `c excerpt=` block -- the check that caught
the stale struct two commits ago -- so dropping it with the README would have
quietly retired a test. docs/10 also stopped claiming README.md carries the
full API surface and the pool limits, which the trim made false.
CLAUDE.md went from 458 lines to 62, because almost none of it was
agent-specific. The project goals, the Go reference and its architecture, the
dependency version and ABI rules, the four ways an embedded build collides,
the libakerror convention, the error-code range map and the style rules are
all things a maintainer needs, and they are now in MAINTENANCE.md with one
copy to keep true. CLAUDE.md points there and keeps only the rules no test
enforces: tests in the same commit asserting the correct contract, file a
missing dependency capability upstream, do not edit generated output or
tests/reference/, co-author your commits.
Four claims did not survive the move, having gone stale where nothing could
notice:
- "The repository is currently empty apart from its submodules -- no
commits, no source tree, no build files." There are 43 commits.
- libakgl's target_compile_definitions(akerror PUBLIC AKERR_MAX_ERR_VALUE)
at deps/libakgl/CMakeLists.txt:44, described as inert but present. It is
gone; only a historical mention in a comment remains.
- "akbasic_init() claims 512-767." There is no akbasic_init. It is
akbasic_error_register(), called from akbasic_runtime_init().
- Time-relative phrasing ("libakgl hit two of them in the last week").
Five places pointed at CLAUDE.md for the range map or the file-it-upstream
rule and now point at MAINTENANCE.md: include/akbasic/error.h,
src/runtime_disk.c and three entries in TODO.md. Both source changes are
comments. deps/libakgl/TODO.md cites it too and is left alone; it is a
submodule, and the rule it quotes is still reachable from CLAUDE.md.
ctest is green at 95 of 95, docs_examples included: 36 programs, 9
transcripts, 44 output comparisons, 3 C snippets, 1 excerpt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:59:43 -04:00
`akbasic` owns **512– 767 ** per the range map in `MAINTENANCE.md` . Declare it as an `enum` , so the
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
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.
2026-07-31 12:52:00 -04:00
### 1.8 Error message text is a convention, no longer a contract
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
`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
2026-07-31 12:52:00 -04:00
to `Println` , which adds another.
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
2026-07-31 12:52:00 -04:00
**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.
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
Consume libakgl 0.2.0 and libakstdlib 0.2.0
Rebasing the defect filing onto libakgl's origin/main brought two upstream
commits with it -- "Consume libakstdlib 0.2.0" and "Bump libakgl to 0.2.0" --
and libakgl's sources now use the 0.2.0 akstdlib API. Our CMakeLists declares
akstdlib::akstdlib from our own tree first, so libakgl was compiling 0.2.0-era
code against 0.1.0 headers and failing on aksl_fwrite, aksl_fread and
aksl_realpath. The two have to move together.
The code change is one call site. aksl_fwrite now takes a required size_t
*nmemb_out and reports a short transfer as AKERR_IO rather than a silent
success, so DSAVE onto a full disk is an error a program sees where before it
reported nothing. The count is passed and discarded on purpose: the library does
the noticing now.
The documentation change is larger, because libakstdlib 0.2.0 fixed all six of
the confirmed defects TODO.md section 1.9 was built around. That section was a
table of bans; leaving it would send an agent around a workaround for functions
that now work. The aksl_ato* family raises AKERR_VALUE on no digits or trailing
junk and ERANGE on overflow -- exactly the contract that section demanded --
aksl_list_append no longer truncates, aksl_list_iterate no longer skips the
first half, AKERR_ITERATOR_BREAK stops a tree traversal, and aksl_realpath no
longer reads uninitialised memory. One caveat survives: aksl_strhash_djb2 still
sign-extends char, which section 1.3 already covers and the symbol tables still
cannot reach.
src/convert.c has therefore outlived its reason, and is deliberately left in
place. Its own note said to delete it when libakstdlib grew the contract, and
that condition is now met -- but doing it touches four call sites, changes the
raised status from AKBASIC_ERR_VALUE to AKERR_VALUE where section 1.8 says
message text is part of the acceptance contract, and would silently gut the CI
mutation job, which is bounded to src/convert.c and src/symtab.c. Section 1.9
now lists all of that. Worth doing on purpose rather than as a side effect of a
version bump.
libakgl defect #14 is closed by its own 1066ac7, which bumped it to 0.2.0 while
this was being written. The requirement is no longer pinned by submodule commit
in the README, because AKGL_VERSION_AT_LEAST(0, 2, 0) finally means something.
70/70 core, 71/71 with libakgl, 70/70 under ASan+UBSan, clean under
-Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:53:41 -04:00
### 1.9 Which `libakstdlib` calls are cleared for use — **the bans are lifted**
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
Consume libakgl 0.2.0 and libakstdlib 0.2.0
Rebasing the defect filing onto libakgl's origin/main brought two upstream
commits with it -- "Consume libakstdlib 0.2.0" and "Bump libakgl to 0.2.0" --
and libakgl's sources now use the 0.2.0 akstdlib API. Our CMakeLists declares
akstdlib::akstdlib from our own tree first, so libakgl was compiling 0.2.0-era
code against 0.1.0 headers and failing on aksl_fwrite, aksl_fread and
aksl_realpath. The two have to move together.
The code change is one call site. aksl_fwrite now takes a required size_t
*nmemb_out and reports a short transfer as AKERR_IO rather than a silent
success, so DSAVE onto a full disk is an error a program sees where before it
reported nothing. The count is passed and discarded on purpose: the library does
the noticing now.
The documentation change is larger, because libakstdlib 0.2.0 fixed all six of
the confirmed defects TODO.md section 1.9 was built around. That section was a
table of bans; leaving it would send an agent around a workaround for functions
that now work. The aksl_ato* family raises AKERR_VALUE on no digits or trailing
junk and ERANGE on overflow -- exactly the contract that section demanded --
aksl_list_append no longer truncates, aksl_list_iterate no longer skips the
first half, AKERR_ITERATOR_BREAK stops a tree traversal, and aksl_realpath no
longer reads uninitialised memory. One caveat survives: aksl_strhash_djb2 still
sign-extends char, which section 1.3 already covers and the symbol tables still
cannot reach.
src/convert.c has therefore outlived its reason, and is deliberately left in
place. Its own note said to delete it when libakstdlib grew the contract, and
that condition is now met -- but doing it touches four call sites, changes the
raised status from AKBASIC_ERR_VALUE to AKERR_VALUE where section 1.8 says
message text is part of the acceptance contract, and would silently gut the CI
mutation job, which is bounded to src/convert.c and src/symtab.c. Section 1.9
now lists all of that. Worth doing on purpose rather than as a side effect of a
version bump.
libakgl defect #14 is closed by its own 1066ac7, which bumped it to 0.2.0 while
this was being written. The requirement is no longer pinned by submodule commit
in the README, because AKGL_VERSION_AT_LEAST(0, 2, 0) finally means something.
70/70 core, 71/71 with libakgl, 70/70 under ASan+UBSan, clean under
-Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:53:41 -04:00
`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.
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
Consume libakgl 0.2.0 and libakstdlib 0.2.0
Rebasing the defect filing onto libakgl's origin/main brought two upstream
commits with it -- "Consume libakstdlib 0.2.0" and "Bump libakgl to 0.2.0" --
and libakgl's sources now use the 0.2.0 akstdlib API. Our CMakeLists declares
akstdlib::akstdlib from our own tree first, so libakgl was compiling 0.2.0-era
code against 0.1.0 headers and failing on aksl_fwrite, aksl_fread and
aksl_realpath. The two have to move together.
The code change is one call site. aksl_fwrite now takes a required size_t
*nmemb_out and reports a short transfer as AKERR_IO rather than a silent
success, so DSAVE onto a full disk is an error a program sees where before it
reported nothing. The count is passed and discarded on purpose: the library does
the noticing now.
The documentation change is larger, because libakstdlib 0.2.0 fixed all six of
the confirmed defects TODO.md section 1.9 was built around. That section was a
table of bans; leaving it would send an agent around a workaround for functions
that now work. The aksl_ato* family raises AKERR_VALUE on no digits or trailing
junk and ERANGE on overflow -- exactly the contract that section demanded --
aksl_list_append no longer truncates, aksl_list_iterate no longer skips the
first half, AKERR_ITERATOR_BREAK stops a tree traversal, and aksl_realpath no
longer reads uninitialised memory. One caveat survives: aksl_strhash_djb2 still
sign-extends char, which section 1.3 already covers and the symbol tables still
cannot reach.
src/convert.c has therefore outlived its reason, and is deliberately left in
place. Its own note said to delete it when libakstdlib grew the contract, and
that condition is now met -- but doing it touches four call sites, changes the
raised status from AKBASIC_ERR_VALUE to AKERR_VALUE where section 1.8 says
message text is part of the acceptance contract, and would silently gut the CI
mutation job, which is bounded to src/convert.c and src/symtab.c. Section 1.9
now lists all of that. Worth doing on purpose rather than as a side effect of a
version bump.
libakgl defect #14 is closed by its own 1066ac7, which bumped it to 0.2.0 while
this was being written. The requirement is no longer pinned by submodule commit
in the README, because AKGL_VERSION_AT_LEAST(0, 2, 0) finally means something.
70/70 core, 71/71 with libakgl, 70/70 under ASan+UBSan, clean under
-Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:53:41 -04:00
What changed, in the four that mattered to this port:
| Was banned | Now |
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
|---|---|
Consume libakgl 0.2.0 and libakstdlib 0.2.0
Rebasing the defect filing onto libakgl's origin/main brought two upstream
commits with it -- "Consume libakstdlib 0.2.0" and "Bump libakgl to 0.2.0" --
and libakgl's sources now use the 0.2.0 akstdlib API. Our CMakeLists declares
akstdlib::akstdlib from our own tree first, so libakgl was compiling 0.2.0-era
code against 0.1.0 headers and failing on aksl_fwrite, aksl_fread and
aksl_realpath. The two have to move together.
The code change is one call site. aksl_fwrite now takes a required size_t
*nmemb_out and reports a short transfer as AKERR_IO rather than a silent
success, so DSAVE onto a full disk is an error a program sees where before it
reported nothing. The count is passed and discarded on purpose: the library does
the noticing now.
The documentation change is larger, because libakstdlib 0.2.0 fixed all six of
the confirmed defects TODO.md section 1.9 was built around. That section was a
table of bans; leaving it would send an agent around a workaround for functions
that now work. The aksl_ato* family raises AKERR_VALUE on no digits or trailing
junk and ERANGE on overflow -- exactly the contract that section demanded --
aksl_list_append no longer truncates, aksl_list_iterate no longer skips the
first half, AKERR_ITERATOR_BREAK stops a tree traversal, and aksl_realpath no
longer reads uninitialised memory. One caveat survives: aksl_strhash_djb2 still
sign-extends char, which section 1.3 already covers and the symbol tables still
cannot reach.
src/convert.c has therefore outlived its reason, and is deliberately left in
place. Its own note said to delete it when libakstdlib grew the contract, and
that condition is now met -- but doing it touches four call sites, changes the
raised status from AKBASIC_ERR_VALUE to AKERR_VALUE where section 1.8 says
message text is part of the acceptance contract, and would silently gut the CI
mutation job, which is bounded to src/convert.c and src/symtab.c. Section 1.9
now lists all of that. Worth doing on purpose rather than as a side effect of a
version bump.
libakgl defect #14 is closed by its own 1066ac7, which bumped it to 0.2.0 while
this was being written. The requirement is no longer pinned by submodule commit
in the README, because AKGL_VERSION_AT_LEAST(0, 2, 0) finally means something.
70/70 core, 71/71 with libakgl, 70/70 under ASan+UBSan, clean under
-Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:53:41 -04:00
| `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.
Delete src/convert.c: libakstdlib 0.2.0 grew the contract it existed for
That file wrapped strtoll and strtod because libakstdlib's aksl_ato* family
could not report a conversion failure at all -- atoi("not a number") returned
success with 0, turning four diagnosable errors into wrong answers. Its own note
said what to do when that changed: "When it grows it, delete src/convert.c and
switch the call sites over." 0.2.0 grew it, so it is gone.
Six call sites go straight to the library now: both literal constructors 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. aksl_strtoll(str, NULL, base, &dest) rather than
aksl_atoll wherever a base is involved, because the ato* forms are base 10 and
grammar.c picks base 8 or 16 off the lexeme's prefix; the NULL endptr is what
makes trailing junk an error rather than a stopping point.
The raised status changed from AKBASIC_ERR_VALUE to AKERR_VALUE, and the message
with it -- VAL("garbage") now says `no digits in "garbage"`. Section 1.8 makes
message text part of the acceptance contract, so that was checked against the
corpus before touching anything: no golden file contained a conversion message,
which is also why section 1.9 had been asking for one. Two exist now, so the next
change to that text has to move a golden file, and one of them pins the
reference's octal-literal defect (section 6 item 10) while it is still
deliberately reproduced.
tests/convert.c became tests/numeric_contract.c rather than being deleted with
the code. The assertions did not stop being worth making when the wrapper went
away -- they became assertions about a contract this port depends on and no
longer owns, and a regression in it would make four things quietly return zero.
Repoints the CI mutation job, which was bounded to src/convert.c and
src/symtab.c. src/symtab.c alone measures 74.1% against the gate of 65.
src/audio_tables.c was measured as a replacement second file and scored 64.7%:
that is a real gap rather than a reason to skip it, since almost every survivor
is in akbasic_audio_state_init, where nothing asserts a freshly initialised audio
state is actually zeroed -- the same gap this job's history records closing for
symtab. Recorded in TODO.md so the file can earn its place back.
One thing worth knowing before reading any score here, and now written down: the
harness's ICR operator only rewrites the constants 0 and 1, so a lookup table of
other values produces no mutants and a high score over one says nothing about
whether its entries are right. tests/audio_verbs.c now asserts all 32 ADSR
entries against the datasheet anyway, because a hand-edit typo is the real
failure mode there -- but that could not and did not move the mutation number,
and claiming otherwise would be the kind of thing this file exists to stop.
72/72 core, 73/73 with libakgl, 72/72 under ASan+UBSan, coverage 93.6% line and
97.8% function, clean under -Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:28:04 -04:00
**`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.
Consume libakgl 0.2.0 and libakstdlib 0.2.0
Rebasing the defect filing onto libakgl's origin/main brought two upstream
commits with it -- "Consume libakstdlib 0.2.0" and "Bump libakgl to 0.2.0" --
and libakgl's sources now use the 0.2.0 akstdlib API. Our CMakeLists declares
akstdlib::akstdlib from our own tree first, so libakgl was compiling 0.2.0-era
code against 0.1.0 headers and failing on aksl_fwrite, aksl_fread and
aksl_realpath. The two have to move together.
The code change is one call site. aksl_fwrite now takes a required size_t
*nmemb_out and reports a short transfer as AKERR_IO rather than a silent
success, so DSAVE onto a full disk is an error a program sees where before it
reported nothing. The count is passed and discarded on purpose: the library does
the noticing now.
The documentation change is larger, because libakstdlib 0.2.0 fixed all six of
the confirmed defects TODO.md section 1.9 was built around. That section was a
table of bans; leaving it would send an agent around a workaround for functions
that now work. The aksl_ato* family raises AKERR_VALUE on no digits or trailing
junk and ERANGE on overflow -- exactly the contract that section demanded --
aksl_list_append no longer truncates, aksl_list_iterate no longer skips the
first half, AKERR_ITERATOR_BREAK stops a tree traversal, and aksl_realpath no
longer reads uninitialised memory. One caveat survives: aksl_strhash_djb2 still
sign-extends char, which section 1.3 already covers and the symbol tables still
cannot reach.
src/convert.c has therefore outlived its reason, and is deliberately left in
place. Its own note said to delete it when libakstdlib grew the contract, and
that condition is now met -- but doing it touches four call sites, changes the
raised status from AKBASIC_ERR_VALUE to AKERR_VALUE where section 1.8 says
message text is part of the acceptance contract, and would silently gut the CI
mutation job, which is bounded to src/convert.c and src/symtab.c. Section 1.9
now lists all of that. Worth doing on purpose rather than as a side effect of a
version bump.
libakgl defect #14 is closed by its own 1066ac7, which bumped it to 0.2.0 while
this was being written. The requirement is no longer pinned by submodule commit
in the README, because AKGL_VERSION_AT_LEAST(0, 2, 0) finally means something.
70/70 core, 71/71 with libakgl, 70/70 under ASan+UBSan, clean under
-Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:53:41 -04:00
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.
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
---
2026-07-31 10:57:02 -04:00
## 2. What exists — **the core port is complete and green**
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
Phases 0 through 6 of the original plan are done. The interpreter builds clean under
2026-07-31 12:52:00 -04:00
`-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.
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
```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 |
2026-07-31 10:57:02 -04:00
| Stdio driver | `src/main.c` | `main.go` minus its SDL frontend |
Document host/script variable exchange, and file the scope hazard it exposes
The host can expose variables to a BASIC program, in both directions and for
every type, with no marshalling layer -- akbasic_environment_get() finds or
creates by name, the type comes from the suffix as it does for BASIC code, and
host and script share the same akbasic_Value. Nothing new was needed to make
that work.
Testing it before writing it up turned up a hazard worth stating plainly, so the
README documents the pattern and section 6 item 17 records the gap.
Seeding before akbasic_runtime_start() and reading after the script stops is
safe, and so is updating an existing global at any time, because the parent
chain is searched. Creating one mid-run is not, in two ways, and both are
silent. A script suspended part-way through a bounded run() is usually inside a
FOR or GOSUB scope, so a variable created through obj->environment lands in that
scope and dies when it pops -- the script reads it correctly inside the loop and
gets 0 immediately after. And reaching for the root explicitly does not help:
akbasic_environment_get only auto-creates when the environment it is given is
the active one, so with a child active it returns NULL through dest without
raising, and an unchecked host dereferences it.
This one is ours rather than inherited. It falls out of the environment pool
meeting the bounded run(), a combination the reference never had because its
run() never returned.
examples/hostvars.c demonstrates both hazards rather than describing them, and
prints what it observes, so the day this is fixed that output changes and the
example needs revisiting. Like examples/embed.c it is built and run by every
build. Both README snippets were extracted and compiled as a check.
Not fixed here: akbasic_runtime_global() would close it in about fifteen lines,
but what it should do when the script is suspended inside a user function's
scope is a design question worth settling deliberately rather than discovering.
Filed with the proposed signature and the tests it wants.
ctest 61/61; ASan+UBSan 61/61; no warnings under -Wall -Wextra.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:50:55 -04:00
| Embedding examples | `examples/embed.c` , `examples/hostvars.c` | * (new) * |
Port the README from the Go version and document embedding
Carries the reference README across, adjusted where C changes the answer: cmake
instead of make, the ak* libraries instead of the go-sdl2 bindings, and the
limits table now includes the three ceilings the Go version did not need because
it called make().
The new "Embedding the interpreter" section is the point of the rewrite, so it
states the four rules the library holds to -- nothing terminates the process,
nothing calls malloc, no file-scope mutable state, the host owns the loop -- and
each was checked against the tree rather than asserted.
Adds akbasic_runtime_load(). Writing the section turned up a real gap: a host
usually already holds its script as a string and wants the sink reserved for
output, and the only path that existed was AKBASIC_MODE_RUNSTREAM reading the
program through the sink's readline, which forces a game to point its output
device at its source text. The alternative was reaching into the header's
"internal API" block for store_line. Neither is something to put in a README.
Adds examples/embed.c, which is the code the README quotes -- a custom sink, a
bounded per-frame run, and the PASS-not-CATCH rule for a loop inside an ATTEMPT.
It is built by every build and registered as a CTest case, so a signature change
breaks the build instead of rotting the document. The README's own snippet is
compiled separately as a check; both were run before committing.
The "What Isn't Implemented / Isn't Working" section leads with the eleven
inherited defects rather than burying them, because five of them were found by
this port and a reader deserves to know that 1 - 2 - 3 computes 1 - 2 before
they hit it. Corrected two claims while verifying: the runtime is 10.1MB rather
than the ~8MB first written, and its largest single cost is the environment pool
at 4.1MB, not the source table.
ctest 60/60; ASan+UBSan 60/60; no warnings under -Wall -Wextra.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:42:08 -04:00
`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.
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
2026-07-31 12:35:40 -04:00
**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.
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
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()` .
---
2026-07-31 11:21:39 -04:00
## 3. The akgl-backed sink, devices and standalone frontend — **done**
Wire the sink and the three devices to libakgl
src/sink_akgl.c, src/graphics_akgl.c, src/audio_akgl.c and src/input_akgl.c, in
the akbasic_akgl target, which is the only thing here that links SDL.
-DAKBASIC_WITH_AKGL=ON had never been configured in this repository before, and
it now builds and passes.
The sink is what section 3 has been waiting on. Its character grid comes from
akgl_text_measure(font, "A", &w, &h), the direct equivalent of the reference's
font.SizeUTF8("A") and the call that did not exist 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 PRINTs a long string
and then PRINTs 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.
tests/akgl_backends.c draws into a 128x128 software renderer under the dummy
video driver and reads the pixels back -- the pattern deps/libakgl/tests/draw.c
established, which needs no display and no offscreen harness. It asserts the
seam rather than libakgl's own behaviour: a BASIC line in, a lit pixel of the
right colour out.
Four things in libakgl had to be worked around to get here. All four are
commented at their site with "filed upstream" and recorded in TODO.md section 3:
- An embedded libakgl requires SDL, SDL_image, SDL_mixer, SDL_ttf and jansson to
be *installed*. It builds its own vendored copies only when it is top-level,
and they are sitting right there in deps/libakgl/deps. Every lookup is guarded
with if(NOT TARGET ...), so this adds those five subdirectories before
add_subdirectory(deps/libakgl) -- the same trick and the same ordering
requirement akerror::akerror and akstdlib::akstdlib already need.
- akgl/controller.h does not compile on its own: it declares handlers taking an
akgl_Actor * and includes nothing that declares the type.
- There is no way to attach a 2D backend to a renderer you already have.
akgl_render_init2d() installs the vtable but also creates its own window and
writes the camera global, so it belongs to the akgl_game_init() path -- which
is exactly the path an embedding host is not on. The test assigns the six
pointers by hand.
- akgl_text_rendertextat() segfaults on a backend whose vtable is empty; it
reaches through renderer->draw_texture without checking it. Same class of
defect 42b60f7's own commit added a draw test for.
The sink's readline reports end of input rather than reading: a drawn text layer
is not a source of lines, and INPUT through one wants a line editor built on the
keystroke ring. EOF rather than an error is the contract sink.h states, so INPUT
already handles it. That editor is the next piece of work there.
70/70 core ctest with no SDL on the include path, 71/71 with the akgl suite,
clean under -Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:46:53 -04:00
2026-07-31 11:21:39 -04:00
`-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:
Wire the sink and the three devices to libakgl
src/sink_akgl.c, src/graphics_akgl.c, src/audio_akgl.c and src/input_akgl.c, in
the akbasic_akgl target, which is the only thing here that links SDL.
-DAKBASIC_WITH_AKGL=ON had never been configured in this repository before, and
it now builds and passes.
The sink is what section 3 has been waiting on. Its character grid comes from
akgl_text_measure(font, "A", &w, &h), the direct equivalent of the reference's
font.SizeUTF8("A") and the call that did not exist 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 PRINTs a long string
and then PRINTs 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.
tests/akgl_backends.c draws into a 128x128 software renderer under the dummy
video driver and reads the pixels back -- the pattern deps/libakgl/tests/draw.c
established, which needs no display and no offscreen harness. It asserts the
seam rather than libakgl's own behaviour: a BASIC line in, a lit pixel of the
right colour out.
Four things in libakgl had to be worked around to get here. All four are
commented at their site with "filed upstream" and recorded in TODO.md section 3:
- An embedded libakgl requires SDL, SDL_image, SDL_mixer, SDL_ttf and jansson to
be *installed*. It builds its own vendored copies only when it is top-level,
and they are sitting right there in deps/libakgl/deps. Every lookup is guarded
with if(NOT TARGET ...), so this adds those five subdirectories before
add_subdirectory(deps/libakgl) -- the same trick and the same ordering
requirement akerror::akerror and akstdlib::akstdlib already need.
- akgl/controller.h does not compile on its own: it declares handlers taking an
akgl_Actor * and includes nothing that declares the type.
- There is no way to attach a 2D backend to a renderer you already have.
akgl_render_init2d() installs the vtable but also creates its own window and
writes the camera global, so it belongs to the akgl_game_init() path -- which
is exactly the path an embedding host is not on. The test assigns the six
pointers by hand.
- akgl_text_rendertextat() segfaults on a backend whose vtable is empty; it
reaches through renderer->draw_texture without checking it. Same class of
defect 42b60f7's own commit added a draw test for.
The sink's readline reports end of input rather than reading: a drawn text layer
is not a source of lines, and INPUT through one wants a line editor built on the
keystroke ring. EOF rather than an error is the contract sink.h states, so INPUT
already handles it. That editor is the next piece of work there.
70/70 core ctest with no SDL on the include path, 71/71 with the akgl suite,
clean under -Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:46:53 -04:00
| 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.
2026-07-31 11:21:39 -04:00
### 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.
Wire the sink and the three devices to libakgl
src/sink_akgl.c, src/graphics_akgl.c, src/audio_akgl.c and src/input_akgl.c, in
the akbasic_akgl target, which is the only thing here that links SDL.
-DAKBASIC_WITH_AKGL=ON had never been configured in this repository before, and
it now builds and passes.
The sink is what section 3 has been waiting on. Its character grid comes from
akgl_text_measure(font, "A", &w, &h), the direct equivalent of the reference's
font.SizeUTF8("A") and the call that did not exist 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 PRINTs a long string
and then PRINTs 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.
tests/akgl_backends.c draws into a 128x128 software renderer under the dummy
video driver and reads the pixels back -- the pattern deps/libakgl/tests/draw.c
established, which needs no display and no offscreen harness. It asserts the
seam rather than libakgl's own behaviour: a BASIC line in, a lit pixel of the
right colour out.
Four things in libakgl had to be worked around to get here. All four are
commented at their site with "filed upstream" and recorded in TODO.md section 3:
- An embedded libakgl requires SDL, SDL_image, SDL_mixer, SDL_ttf and jansson to
be *installed*. It builds its own vendored copies only when it is top-level,
and they are sitting right there in deps/libakgl/deps. Every lookup is guarded
with if(NOT TARGET ...), so this adds those five subdirectories before
add_subdirectory(deps/libakgl) -- the same trick and the same ordering
requirement akerror::akerror and akstdlib::akstdlib already need.
- akgl/controller.h does not compile on its own: it declares handlers taking an
akgl_Actor * and includes nothing that declares the type.
- There is no way to attach a 2D backend to a renderer you already have.
akgl_render_init2d() installs the vtable but also creates its own window and
writes the camera global, so it belongs to the akgl_game_init() path -- which
is exactly the path an embedding host is not on. The test assigns the six
pointers by hand.
- akgl_text_rendertextat() segfaults on a backend whose vtable is empty; it
reaches through renderer->draw_texture without checking it. Same class of
defect 42b60f7's own commit added a draw test for.
The sink's readline reports end of input rather than reading: a drawn text layer
is not a source of lines, and INPUT through one wants a line editor built on the
keystroke ring. EOF rather than an error is the contract sink.h states, so INPUT
already handles it. That editor is the next piece of work there.
70/70 core ctest with no SDL on the include path, 71/71 with the akgl suite,
clean under -Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:46:53 -04:00
2026-07-31 11:21:39 -04:00
| 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.
2026-07-31 14:35:07 -04:00
**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.
2026-07-31 11:21:39 -04:00
Consume libakgl 0.3.0: every workaround deleted, two capabilities gained
0.3.0 closed all ten API gaps this port had filed. The four workarounds go
with them: the CMake block that declared libakgl's vendored dependencies by
hand, the akgl/actor.h include in three files, and the six vtable pointers
assigned by hand in two more, now akgl_render_bind2d().
Two gaps were capabilities rather than inconveniences, and both are now real:
The line editor takes the composed UTF-8 text the ring carries in preference
to the keycode, so shifted characters, keyboard layouts, compose keys and dead
keys all work. A double quote can be typed, which means a BASIC string literal
can be typed -- the sharp end of the old limitation. Letters are no longer
folded to upper case.
SOUND's dir/min/step reach akgl_audio_sweep instead of being refused. dir 3
sweeps once rather than oscillating and TODO.md section 5 says so. A backend
with no sweep still refuses the swept note and plays the held one.
The adaptors now carry an AKGL_VERSION_AT_LEAST(0, 3, 0) floor, verified by
temporarily demanding 0.4.0 and watching it fire.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 13:25:14 -04:00
### 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.
Wire the sink and the three devices to libakgl
src/sink_akgl.c, src/graphics_akgl.c, src/audio_akgl.c and src/input_akgl.c, in
the akbasic_akgl target, which is the only thing here that links SDL.
-DAKBASIC_WITH_AKGL=ON had never been configured in this repository before, and
it now builds and passes.
The sink is what section 3 has been waiting on. Its character grid comes from
akgl_text_measure(font, "A", &w, &h), the direct equivalent of the reference's
font.SizeUTF8("A") and the call that did not exist 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 PRINTs a long string
and then PRINTs 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.
tests/akgl_backends.c draws into a 128x128 software renderer under the dummy
video driver and reads the pixels back -- the pattern deps/libakgl/tests/draw.c
established, which needs no display and no offscreen harness. It asserts the
seam rather than libakgl's own behaviour: a BASIC line in, a lit pixel of the
right colour out.
Four things in libakgl had to be worked around to get here. All four are
commented at their site with "filed upstream" and recorded in TODO.md section 3:
- An embedded libakgl requires SDL, SDL_image, SDL_mixer, SDL_ttf and jansson to
be *installed*. It builds its own vendored copies only when it is top-level,
and they are sitting right there in deps/libakgl/deps. Every lookup is guarded
with if(NOT TARGET ...), so this adds those five subdirectories before
add_subdirectory(deps/libakgl) -- the same trick and the same ordering
requirement akerror::akerror and akstdlib::akstdlib already need.
- akgl/controller.h does not compile on its own: it declares handlers taking an
akgl_Actor * and includes nothing that declares the type.
- There is no way to attach a 2D backend to a renderer you already have.
akgl_render_init2d() installs the vtable but also creates its own window and
writes the camera global, so it belongs to the akgl_game_init() path -- which
is exactly the path an embedding host is not on. The test assigns the six
pointers by hand.
- akgl_text_rendertextat() segfaults on a backend whose vtable is empty; it
reaches through renderer->draw_texture without checking it. Same class of
defect 42b60f7's own commit added a draw test for.
The sink's readline reports end of input rather than reading: a drawn text layer
is not a source of lines, and INPUT through one wants a line editor built on the
keystroke ring. EOF rather than an error is the contract sink.h states, so INPUT
already handles it. That editor is the next piece of work there.
70/70 core ctest with no SDL on the include path, 71/71 with the akgl suite,
clean under -Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:46:53 -04:00
### Still missing from the sink
Consume libakgl 0.3.0: every workaround deleted, two capabilities gained
0.3.0 closed all ten API gaps this port had filed. The four workarounds go
with them: the CMake block that declared libakgl's vendored dependencies by
hand, the akgl/actor.h include in three files, and the six vtable pointers
assigned by hand in two more, now akgl_render_bind2d().
Two gaps were capabilities rather than inconveniences, and both are now real:
The line editor takes the composed UTF-8 text the ring carries in preference
to the keycode, so shifted characters, keyboard layouts, compose keys and dead
keys all work. A double quote can be typed, which means a BASIC string literal
can be typed -- the sharp end of the old limitation. Letters are no longer
folded to upper case.
SOUND's dir/min/step reach akgl_audio_sweep instead of being refused. dir 3
sweeps once rather than oscillating and TODO.md section 5 says so. A backend
with no sweep still refuses the swept note and plays the held one.
The adaptors now carry an AKGL_VERSION_AT_LEAST(0, 3, 0) floor, verified by
temporarily demanding 0.4.0 and watching it fire.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 13:25:14 -04:00
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.
2026-07-31 14:23:07 -04:00
**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.
Consume libakgl 0.3.0: every workaround deleted, two capabilities gained
0.3.0 closed all ten API gaps this port had filed. The four workarounds go
with them: the CMake block that declared libakgl's vendored dependencies by
hand, the akgl/actor.h include in three files, and the six vtable pointers
assigned by hand in two more, now akgl_render_bind2d().
Two gaps were capabilities rather than inconveniences, and both are now real:
The line editor takes the composed UTF-8 text the ring carries in preference
to the keycode, so shifted characters, keyboard layouts, compose keys and dead
keys all work. A double quote can be typed, which means a BASIC string literal
can be typed -- the sharp end of the old limitation. Letters are no longer
folded to upper case.
SOUND's dir/min/step reach akgl_audio_sweep instead of being refused. dir 3
sweeps once rather than oscillating and TODO.md section 5 says so. A backend
with no sweep still refuses the swept note and plays the held one.
The adaptors now carry an AKGL_VERSION_AT_LEAST(0, 3, 0) floor, verified by
temporarily demanding 0.4.0 and watching it fire.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 13:25:14 -04:00
One limit remains, and it is a choice rather than a gap:
2026-07-31 11:21:39 -04:00
- **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.
Consume libakgl 0.3.0: every workaround deleted, two capabilities gained
0.3.0 closed all ten API gaps this port had filed. The four workarounds go
with them: the CMake block that declared libakgl's vendored dependencies by
hand, the akgl/actor.h include in three files, and the six vtable pointers
assigned by hand in two more, now akgl_render_bind2d().
Two gaps were capabilities rather than inconveniences, and both are now real:
The line editor takes the composed UTF-8 text the ring carries in preference
to the keycode, so shifted characters, keyboard layouts, compose keys and dead
keys all work. A double quote can be typed, which means a BASIC string literal
can be typed -- the sharp end of the old limitation. Letters are no longer
folded to upper case.
SOUND's dir/min/step reach akgl_audio_sweep instead of being refused. dir 3
sweeps once rather than oscillating and TODO.md section 5 says so. A backend
with no sweep still refuses the swept note and plays the held one.
The adaptors now carry an AKGL_VERSION_AT_LEAST(0, 3, 0) floor, verified by
temporarily demanding 0.4.0 and watching it fire.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 13:25:14 -04:00
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.
2026-07-31 11:21:39 -04:00
`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.
2026-07-31 10:57:02 -04:00
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
---
## 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 |
|---|---|---|
Finish the language: every remaining verb group, and the defects that blocked them
Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and
RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of
those turned out to have been fixed or never ported and nobody had written it
down; the audit records the evidence for each.
Two of the seventeen were real. math_plus mutated its left operand when the
operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT
coverage because NEXT relied on the mutation, so tests/for_next.c came first
and NEXT now writes the counter back itself. And the binary operators summed
both numeric fields of their right operand, which no BASIC program can reach
-- that one needed a test written against the value API.
Writing the tests turned up eight defects nobody had listed. Seven are fixed:
IF A = 2 THEN was a parse error; only == worked
IF ... AND ... was a parse error, because a condition parsed as one relation
IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN
EXIT before any NEXT restarted the program and exhausted the variable pool
READ never found a DATA line above it, and swallowed the lines between
PRINT 2 + 2 at the prompt was filed as program text instead of answering
a short read discarded its bytes, so COPY produced empty files
every verb taking an argument list said "peek() returned nil token!" on none
The eighth is not fixed and cannot be quietly: a FOR whose step overshoots
runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two
errors cancel for a step of 1, which is why neither was noticed. Correcting
them changes the expected output of a checked-in acceptance file, and
tests/reference/README.md forbids editing one to suit this interpreter. It is
tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct
contract, and TODO.md items 19 and 20.
Sprites are real libakgl actors with a renderfunc of their own, because
akgl_actor_render draws every sprite square and an actor has no per-axis
scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle
or a 63-element integer array -- a string here cannot hold a zero byte.
Verbs that need hardware that does not exist are refused by name with the
reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and
DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream.
94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen
clean. The Go acceptance corpus stayed green throughout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
| ~~A. Structure~~ | ~~`DO`, `LOOP`, `WHILE`, `UNTIL`, `ON`, `BEGIN`, `BEND`, `END`~~ | **done ** — `src/runtime_structure.c` , `tests/structure_verbs.c` |
2026-07-31 12:07:50 -04:00
| ~~B. Housekeeping~~ | ~~`NEW`, `CLR`, `CONT`, `SWAP`, `TRON`, `TROFF`, `HELP`~~ | **done ** — `src/runtime_housekeeping.c` , `tests/housekeeping_verbs.c` |
Finish the language: every remaining verb group, and the defects that blocked them
Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and
RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of
those turned out to have been fixed or never ported and nobody had written it
down; the audit records the evidence for each.
Two of the seventeen were real. math_plus mutated its left operand when the
operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT
coverage because NEXT relied on the mutation, so tests/for_next.c came first
and NEXT now writes the counter back itself. And the binary operators summed
both numeric fields of their right operand, which no BASIC program can reach
-- that one needed a test written against the value API.
Writing the tests turned up eight defects nobody had listed. Seven are fixed:
IF A = 2 THEN was a parse error; only == worked
IF ... AND ... was a parse error, because a condition parsed as one relation
IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN
EXIT before any NEXT restarted the program and exhausted the variable pool
READ never found a DATA line above it, and swallowed the lines between
PRINT 2 + 2 at the prompt was filed as program text instead of answering
a short read discarded its bytes, so COPY produced empty files
every verb taking an argument list said "peek() returned nil token!" on none
The eighth is not fixed and cannot be quietly: a FOR whose step overshoots
runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two
errors cancel for a step of 1, which is why neither was noticed. Correcting
them changes the expected output of a checked-in acceptance file, and
tests/reference/README.md forbids editing one to suit this interpreter. It is
tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct
contract, and TODO.md items 19 and 20.
Sprites are real libakgl actors with a renderfunc of their own, because
akgl_actor_render draws every sprite square and an actor has no per-axis
scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle
or a 63-element integer array -- a string here cannot hold a zero byte.
Verbs that need hardware that does not exist are refused by name with the
reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and
DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream.
94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen
clean. The Go acceptance corpus stayed green throughout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
| ~~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 |
Read the keyboard: implement GET, GETKEY and SCNCLR
The poll_key half of group E, against the akbasic_InputBackend record. The
libakgl implementation behind it is akgl_controller_poll_key(), which drains a
ring the library fills from SDL events the host pumps -- so the interpreter can
answer "is there a key waiting" without owning an event loop, which is what goal
3 requires.
GET and GETKEY differ in one way and it is the interesting one. GET takes
whatever is there including nothing, and an empty buffer is success with the
empty string rather than an error -- that is what happens on most iterations of
every GET loop ever written, and upstream is explicit that its poll reports it
the same way. GETKEY waits, and since the library may not block, waiting is
spelled as holding the step loop: the verb sets a flag and akbasic_runtime_step()
declines to advance until a key arrives. Every step still returns and a bounded
run() still comes back, so a host keeps its frame rate; the program simply does
not move past the GETKEY.
The PLAY queue is serviced before that 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 on a device that
no longer exists.
Both verbs accept an integer variable as well as a string one and give it the raw
key code, which is what a program testing for cursor or function keys needs. No
key is code zero, matching what a C128 reports. A float variable is refused: it
is neither a character nor a code.
SCNCLR goes through the text sink rather than a device, because the sink is where
PRINT already goes and is the only thing that knows what a screen means for this
host. The stdio sink treats it as a no-op; clearing a pipe means nothing.
One thing worth knowing before writing any bounded-run test, and now commented in
tests/input_verbs.c: source lines are stored indexed by line number and the
cursor starts at zero, so a step is spent on each empty slot along the way. A
program at line 10 needs eleven steps before it has run anything.
70/70 ctest, clean under -Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:35:05 -04:00
| ~~G. Graphics~~ | ~~`GRAPHIC`, `DRAW`, `BOX`, `CIRCLE`, `PAINT`, `COLOR`, `SCALE`, `SSHAPE`, `GSHAPE`, `LOCATE`~~ | **done ** — `src/runtime_graphics.c` , `tests/graphics_verbs.c` |
Finish the language: every remaining verb group, and the defects that blocked them
Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and
RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of
those turned out to have been fixed or never ported and nobody had written it
down; the audit records the evidence for each.
Two of the seventeen were real. math_plus mutated its left operand when the
operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT
coverage because NEXT relied on the mutation, so tests/for_next.c came first
and NEXT now writes the counter back itself. And the binary operators summed
both numeric fields of their right operand, which no BASIC program can reach
-- that one needed a test written against the value API.
Writing the tests turned up eight defects nobody had listed. Seven are fixed:
IF A = 2 THEN was a parse error; only == worked
IF ... AND ... was a parse error, because a condition parsed as one relation
IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN
EXIT before any NEXT restarted the program and exhausted the variable pool
READ never found a DATA line above it, and swallowed the lines between
PRINT 2 + 2 at the prompt was filed as program text instead of answering
a short read discarded its bytes, so COPY produced empty files
every verb taking an argument list said "peek() returned nil token!" on none
The eighth is not fixed and cannot be quietly: a FOR whose step overshoots
runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two
errors cancel for a step of 1, which is why neither was noticed. Correcting
them changes the expected output of a checked-in acceptance file, and
tests/reference/README.md forbids editing one to suit this interpreter. It is
tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct
contract, and TODO.md items 19 and 20.
Sprites are real libakgl actors with a renderfunc of their own, because
akgl_actor_render draws every sprite square and an actor has no per-axis
scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle
or a 63-element integer array -- a string here cannot hold a zero byte.
Verbs that need hardware that does not exist are refused by name with the
reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and
DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream.
94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen
clean. The Go acceptance corpus stayed green throughout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
| ~~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 |
Read the keyboard: implement GET, GETKEY and SCNCLR
The poll_key half of group E, against the akbasic_InputBackend record. The
libakgl implementation behind it is akgl_controller_poll_key(), which drains a
ring the library fills from SDL events the host pumps -- so the interpreter can
answer "is there a key waiting" without owning an event loop, which is what goal
3 requires.
GET and GETKEY differ in one way and it is the interesting one. GET takes
whatever is there including nothing, and an empty buffer is success with the
empty string rather than an error -- that is what happens on most iterations of
every GET loop ever written, and upstream is explicit that its poll reports it
the same way. GETKEY waits, and since the library may not block, waiting is
spelled as holding the step loop: the verb sets a flag and akbasic_runtime_step()
declines to advance until a key arrives. Every step still returns and a bounded
run() still comes back, so a host keeps its frame rate; the program simply does
not move past the GETKEY.
The PLAY queue is serviced before that 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 on a device that
no longer exists.
Both verbs accept an integer variable as well as a string one and give it the raw
key code, which is what a program testing for cursor or function keys needs. No
key is code zero, matching what a C128 reports. A float variable is refused: it
is neither a character nor a code.
SCNCLR goes through the text sink rather than a device, because the sink is where
PRINT already goes and is the only thing that knows what a screen means for this
host. The stdio sink treats it as a no-op; clearing a pipe means nothing.
One thing worth knowing before writing any bounded-run test, and now commented in
tests/input_verbs.c: source lines are stored indexed by line number and the
cursor starts at zero, so a step is spent on each empty slot along the way. A
program at line 10 needs eleven steps before it has run anything.
70/70 ctest, clean under -Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:35:05 -04:00
| ~~I. Audio~~ | ~~`PLAY`, `SOUND`, `ENVELOPE`, `VOL`, `TEMPO`~~ | **done ** — `src/runtime_audio.c` , `src/play.c` , `tests/audio_verbs.c` |
2026-07-31 15:29:06 -04:00
| 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 |
Read the keyboard: implement GET, GETKEY and SCNCLR
The poll_key half of group E, against the akbasic_InputBackend record. The
libakgl implementation behind it is akgl_controller_poll_key(), which drains a
ring the library fills from SDL events the host pumps -- so the interpreter can
answer "is there a key waiting" without owning an event loop, which is what goal
3 requires.
GET and GETKEY differ in one way and it is the interesting one. GET takes
whatever is there including nothing, and an empty buffer is success with the
empty string rather than an error -- that is what happens on most iterations of
every GET loop ever written, and upstream is explicit that its poll reports it
the same way. GETKEY waits, and since the library may not block, waiting is
spelled as holding the step loop: the verb sets a flag and akbasic_runtime_step()
declines to advance until a key arrives. Every step still returns and a bounded
run() still comes back, so a host keeps its frame rate; the program simply does
not move past the GETKEY.
The PLAY queue is serviced before that 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 on a device that
no longer exists.
Both verbs accept an integer variable as well as a string one and give it the raw
key code, which is what a program testing for cursor or function keys needs. No
key is code zero, matching what a C128 reports. A float variable is refused: it
is neither a character nor a code.
SCNCLR goes through the text sink rather than a device, because the sink is where
PRINT already goes and is the only thing that knows what a screen means for this
host. The stdio sink treats it as a no-op; clearing a pipe means nothing.
One thing worth knowing before writing any bounded-run test, and now commented in
tests/input_verbs.c: source lines are stored indexed by line number and the
cursor starts at zero, so a step is spent on each empty slot along the way. A
program at line 10 needs eleven steps before it has run anything.
70/70 ctest, clean under -Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:35:05 -04:00
| ~~E′ . Console input~~ | ~~`GET`, `GETKEY`, `SCNCLR`~~ | **done ** — `src/runtime_input.c` , `tests/input_verbs.c` |
Finish the language: every remaining verb group, and the defects that blocked them
Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and
RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of
those turned out to have been fixed or never ported and nobody had written it
down; the audit records the evidence for each.
Two of the seventeen were real. math_plus mutated its left operand when the
operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT
coverage because NEXT relied on the mutation, so tests/for_next.c came first
and NEXT now writes the counter back itself. And the binary operators summed
both numeric fields of their right operand, which no BASIC program can reach
-- that one needed a test written against the value API.
Writing the tests turned up eight defects nobody had listed. Seven are fixed:
IF A = 2 THEN was a parse error; only == worked
IF ... AND ... was a parse error, because a condition parsed as one relation
IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN
EXIT before any NEXT restarted the program and exhausted the variable pool
READ never found a DATA line above it, and swallowed the lines between
PRINT 2 + 2 at the prompt was filed as program text instead of answering
a short read discarded its bytes, so COPY produced empty files
every verb taking an argument list said "peek() returned nil token!" on none
The eighth is not fixed and cannot be quietly: a FOR whose step overshoots
runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two
errors cancel for a step of 1, which is why neither was noticed. Correcting
them changes the expected output of a checked-in acceptance file, and
tests/reference/README.md forbids editing one to suit this interpreter. It is
tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct
contract, and TODO.md items 19 and 20.
Sprites are real libakgl actors with a renderfunc of their own, because
akgl_actor_render draws every sprite square and an actor has no per-axis
scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle
or a 63-element integer array -- a string here cannot hold a zero byte.
Verbs that need hardware that does not exist are refused by name with the
reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and
DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream.
94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen
clean. The Go acceptance corpus stayed green throughout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
| ~~J. Machine~~ | ~~`SYS`, `FETCH`, `STASH`~~ | **done ** — `src/runtime_machine.c` , `tests/machine_verbs.c` . `SYS` is refused by name; see §5 |
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
2026-07-31 12:07:50 -04:00
**`RESTORE` and `RENUMBER` are deferred, and neither is a small job.**
Finish the language: every remaining verb group, and the defects that blocked them
Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and
RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of
those turned out to have been fixed or never ported and nobody had written it
down; the audit records the evidence for each.
Two of the seventeen were real. math_plus mutated its left operand when the
operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT
coverage because NEXT relied on the mutation, so tests/for_next.c came first
and NEXT now writes the counter back itself. And the binary operators summed
both numeric fields of their right operand, which no BASIC program can reach
-- that one needed a test written against the value API.
Writing the tests turned up eight defects nobody had listed. Seven are fixed:
IF A = 2 THEN was a parse error; only == worked
IF ... AND ... was a parse error, because a condition parsed as one relation
IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN
EXIT before any NEXT restarted the program and exhausted the variable pool
READ never found a DATA line above it, and swallowed the lines between
PRINT 2 + 2 at the prompt was filed as program text instead of answering
a short read discarded its bytes, so COPY produced empty files
every verb taking an argument list said "peek() returned nil token!" on none
The eighth is not fixed and cannot be quietly: a FOR whose step overshoots
runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two
errors cancel for a step of 1, which is why neither was noticed. Correcting
them changes the expected output of a checked-in acceptance file, and
tests/reference/README.md forbids editing one to suit this interpreter. It is
tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct
contract, and TODO.md items 19 and 20.
Sprites are real libakgl actors with a renderfunc of their own, because
akgl_actor_render draws every sprite square and an actor has no per-axis
scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle
or a 63-element integer array -- a string here cannot hold a zero byte.
Verbs that need hardware that does not exist are refused by name with the
reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and
DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream.
94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen
clean. The Go acceptance corpus stayed green throughout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
- ~~**`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.
2026-07-31 12:07:50 -04:00
Re-sequence the language queue now that libakgl closed the gaps
The bump in 7f16337 updated sections 3, 7 and 8 but left the section 4 work
queue still marking groups G and I "file it", which is no longer true.
Groups G and I are unblocked outright. Group E splits: GET, GETKEY and SCNCLR
need nothing further, while WINDOW, KEY, SLEEP, WAIT and TI still want the sink
or a clock. LOCATE was listed in both E and G; it belongs to G alone, because in
BASIC 7.0 it moves the graphics pixel cursor DRAW starts from and the text
cursor verb is CHAR, already in group D.
One gap is genuinely outstanding and upstream named it in the same commit that
added the audio API: FILTER has no SDL3 primitive behind it. It is recorded in
section 7 with the behaviour to apply until an akgl_audio_filter() exists --
refuse at execution rather than silently ignore, since a program that asks for a
low-pass and gets an unfiltered square wave has been lied to.
Also records why the libakgl requirement is pinned by commit rather than by
version: 42b60f7 added 22 public symbols across four headers and left both
VERSION 0.1.0 and the libakgl.so.0.1 soname alone, so AKGL_VERSION_AT_LEAST
cannot tell the two trees apart and a binary built against the new headers
resolves against an old .so without complaint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:01:16 -04:00
`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.
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
**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
Finish the language: every remaining verb group, and the defects that blocked them
Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and
RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of
those turned out to have been fixed or never ported and nobody had written it
down; the audit records the evidence for each.
Two of the seventeen were real. math_plus mutated its left operand when the
operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT
coverage because NEXT relied on the mutation, so tests/for_next.c came first
and NEXT now writes the counter back itself. And the binary operators summed
both numeric fields of their right operand, which no BASIC program can reach
-- that one needed a test written against the value API.
Writing the tests turned up eight defects nobody had listed. Seven are fixed:
IF A = 2 THEN was a parse error; only == worked
IF ... AND ... was a parse error, because a condition parsed as one relation
IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN
EXIT before any NEXT restarted the program and exhausted the variable pool
READ never found a DATA line above it, and swallowed the lines between
PRINT 2 + 2 at the prompt was filed as program text instead of answering
a short read discarded its bytes, so COPY produced empty files
every verb taking an argument list said "peek() returned nil token!" on none
The eighth is not fixed and cannot be quietly: a FOR whose step overshoots
runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two
errors cancel for a step of 1, which is why neither was noticed. Correcting
them changes the expected output of a checked-in acceptance file, and
tests/reference/README.md forbids editing one to suit this interpreter. It is
tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct
contract, and TODO.md items 19 and 20.
Sprites are real libakgl actors with a renderfunc of their own, because
akgl_actor_render draws every sprite square and an actor has no per-axis
scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle
or a 63-element integer array -- a string here cannot hold a zero byte.
Verbs that need hardware that does not exist are refused by name with the
reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and
DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream.
94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen
clean. The Go acceptance corpus stayed green throughout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
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.
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
Also on the queue, from the reference's own defect list:
2026-07-31 11:46:10 -04:00
- ~~**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
Erase what the text layer drew: three GUI bugs, one cause
The sink painted glyphs on top of whatever was already on the renderer and
never erased anything, and the host deliberately does not clear the frame so a
DRAW survives. The grid was always correct; the screen kept the previous
frame. That surfaced as three separate-looking faults:
- a backspaced character stayed on screen, including across a line wrap
- a scroll left the old rows behind, "papered over with garbage"
- the editing cursor smeared an underscore along every column it passed
through, which read as an underline under the text being typed
render() now fills each row it is about to draw, and each row that has emptied
since it last drew, with the background first. Row by row rather than one
clear over the whole area: the text layer is authoritative over the rows it
occupies and leaves every other pixel alone, so a picture behind the text
loses only the strips the text uses instead of all of it.
echo_line() also truncates rows that hold nothing but the spaces its own erase
pass wrote, which is what backspacing back across a wrap leaves behind. A row
of spaces is text as far as render() is concerned, so it would have been
repainted forever and kept erasing whatever was under it.
The cursor glyph is removed outright, as asked. The smearing was the erase bug
rather than the cursor, so one could come back and behave -- a block would
read better than an underscore.
Four pixel-level tests, because grid assertions could never have caught any of
this. Each was checked by reverting the fix and watching it fail; two of them
were vacuous when first written and are noted as such where they are fixed.
The real-keyboard test also now rubs out a character and scrolls forty lines.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:03:37 -04:00
deviation 31 in §5: BASIC 7.0 scopes everything after `THEN` to the condition, so
2026-07-31 11:46:10 -04:00
`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.
2026-07-31 15:29:06 -04:00
- ~~**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` .
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
---
## 5. Deliberate deviations from the reference
2026-07-31 12:52:00 -04:00
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.
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
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.
Draw: implement the BASIC 7.0 graphics verbs
GRAPHIC, COLOR, DRAW, BOX, CIRCLE, PAINT, SCALE, SSHAPE, GSHAPE and LOCATE, all
against the akbasic_GraphicsBackend record rather than akgl_draw_* directly, so
src/runtime_graphics.c includes no SDL and the whole group is testable in a build
with no SDL on the machine.
The reference lists every one of these as unimplemented, so the semantics come
from Commodore BASIC 7.0 rather than from a port, and four places where a modern
renderer cannot do what a C128 did are recorded in TODO.md section 5 rather than
silently substituted:
- CIRCLE is drawn as a polygon of inc-degree segments and akgl_draw_circle is
deliberately unused. 7.0's CIRCLE takes two radii, an arc range and a rotation,
so the primitive could serve only the fully-defaulted call, and a shape that
changed character depending on whether the radii happened to be equal would be
worse than one uniformly a polygon.
- SSHAPE puts a SHAPE:<n> handle in the string variable rather than the pixels,
because a value's string is a fixed 256 bytes and a region is a device surface.
GSHAPE refuses a string without that prefix instead of parsing whatever digits
it finds and pasting an unrelated slot.
- BOX fills on a negative angle; 7.0 puts the fill flag after the rotation, which
would make a filled box a seventh argument.
- GRAPHIC stores its mode and honours only the one consequence that means
anything here -- mode 0 is text -- while still refusing an out-of-range mode,
since that is a typo worth catching.
PAINT surfaces the flood fill's AKERR_OUTOFBOUNDS as an error rather than
success. The device gives up when its span stack runs out having filled *part* of
the region, and a program that cannot tell that happened cannot recover from it.
Note the shape of that handler: HANDLE sets handled = true on the context, so a
FAIL_RETURN from inside the HANDLE block hands the caller something already
marked handled, whose FINISH_LOGIC then declines to pass it up and releases it --
the error disappears and PAINT reports success. Flag inside the block, raise
after FINISH.
COLOR, LOCATE and SCALE need no device on purpose, so a program can set itself up
before a host has lent it a renderer.
Adds a second golden corpus under tests/language/. The corpus in
deps/basicinterpret is a submodule and nothing here may add files to it, but
goal 2's new verbs still need the .bas/.txt half of their coverage. Registered
under local_ so a failure names which corpus it came from. What it can cover is
limited -- these verbs draw rather than print -- so the behaviour that reaches a
device is asserted against tests/mockdevice.h instead.
65/65 ctest, clean under -Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:17:48 -04:00
### 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
2026-07-31 10:57:02 -04:00
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.
Draw: implement the BASIC 7.0 graphics verbs
GRAPHIC, COLOR, DRAW, BOX, CIRCLE, PAINT, SCALE, SSHAPE, GSHAPE and LOCATE, all
against the akbasic_GraphicsBackend record rather than akgl_draw_* directly, so
src/runtime_graphics.c includes no SDL and the whole group is testable in a build
with no SDL on the machine.
The reference lists every one of these as unimplemented, so the semantics come
from Commodore BASIC 7.0 rather than from a port, and four places where a modern
renderer cannot do what a C128 did are recorded in TODO.md section 5 rather than
silently substituted:
- CIRCLE is drawn as a polygon of inc-degree segments and akgl_draw_circle is
deliberately unused. 7.0's CIRCLE takes two radii, an arc range and a rotation,
so the primitive could serve only the fully-defaulted call, and a shape that
changed character depending on whether the radii happened to be equal would be
worse than one uniformly a polygon.
- SSHAPE puts a SHAPE:<n> handle in the string variable rather than the pixels,
because a value's string is a fixed 256 bytes and a region is a device surface.
GSHAPE refuses a string without that prefix instead of parsing whatever digits
it finds and pasting an unrelated slot.
- BOX fills on a negative angle; 7.0 puts the fill flag after the rotation, which
would make a filled box a seventh argument.
- GRAPHIC stores its mode and honours only the one consequence that means
anything here -- mode 0 is text -- while still refusing an out-of-range mode,
since that is a typo worth catching.
PAINT surfaces the flood fill's AKERR_OUTOFBOUNDS as an error rather than
success. The device gives up when its span stack runs out having filled *part* of
the region, and a program that cannot tell that happened cannot recover from it.
Note the shape of that handler: HANDLE sets handled = true on the context, so a
FAIL_RETURN from inside the HANDLE block hands the caller something already
marked handled, whose FINISH_LOGIC then declines to pass it up and releases it --
the error disappears and PAINT reports success. Flag inside the block, raise
after FINISH.
COLOR, LOCATE and SCALE need no device on purpose, so a program can set itself up
before a host has lent it a renderer.
Adds a second golden corpus under tests/language/. The corpus in
deps/basicinterpret is a submodule and nothing here may add files to it, but
goal 2's new verbs still need the .bas/.txt half of their coverage. Registered
under local_ so a failure names which corpus it came from. What it can cover is
limited -- these verbs draw rather than print -- so the behaviour that reaches a
device is asserted against tests/mockdevice.h instead.
65/65 ctest, clean under -Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:17:48 -04:00
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.
Generate the documentation's figures from the listings they illustrate
Chapters 6 and 8 described what a verb draws in prose. Eight figures now show
it, and each one is produced by running the BASIC listing printed immediately
above it -- so a picture cannot drift away from the code beside it, which is the
way a screenshot goes wrong and the way nothing notices.
tools/screenshot.c is a second SDL host, much smaller than the frontend: dummy
video driver, software renderer, run to completion, read the target back, write
a PNG. It draws no text layer on purpose, so a READY in the corner is not noise
in a figure about BOX and no font has to be resolved.
tools/docs_screenshots.sh reads the new screenshot=NAME fence tag straight out
of the markdown. size=WxH is the second tag, and SCALE's figure uses it: the
point being made is a 320x200 listing filling a larger window, which cannot be
made on a 320x200 surface.
Two gates, answering different questions. docs_examples fails a tagged block
with no 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. Only the second catches a stale picture.
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. Regenerating is never
part of a build: the target is run deliberately, so a make cannot put eight
binary diffs in front of whoever ran it.
Drawing the BOX figure caught a defect in TODO.md itself. Deviation 16 claimed
in bold that BOX fills on a negative angle while its own paragraph said the fill
was filed rather than implemented. BOX cannot fill, and filled_rect is reached
by no verb as a result.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:37:04 -04:00
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.
Draw: implement the BASIC 7.0 graphics verbs
GRAPHIC, COLOR, DRAW, BOX, CIRCLE, PAINT, SCALE, SSHAPE, GSHAPE and LOCATE, all
against the akbasic_GraphicsBackend record rather than akgl_draw_* directly, so
src/runtime_graphics.c includes no SDL and the whole group is testable in a build
with no SDL on the machine.
The reference lists every one of these as unimplemented, so the semantics come
from Commodore BASIC 7.0 rather than from a port, and four places where a modern
renderer cannot do what a C128 did are recorded in TODO.md section 5 rather than
silently substituted:
- CIRCLE is drawn as a polygon of inc-degree segments and akgl_draw_circle is
deliberately unused. 7.0's CIRCLE takes two radii, an arc range and a rotation,
so the primitive could serve only the fully-defaulted call, and a shape that
changed character depending on whether the radii happened to be equal would be
worse than one uniformly a polygon.
- SSHAPE puts a SHAPE:<n> handle in the string variable rather than the pixels,
because a value's string is a fixed 256 bytes and a region is a device surface.
GSHAPE refuses a string without that prefix instead of parsing whatever digits
it finds and pasting an unrelated slot.
- BOX fills on a negative angle; 7.0 puts the fill flag after the rotation, which
would make a filled box a seventh argument.
- GRAPHIC stores its mode and honours only the one consequence that means
anything here -- mode 0 is text -- while still refusing an out-of-range mode,
since that is a typo worth catching.
PAINT surfaces the flood fill's AKERR_OUTOFBOUNDS as an error rather than
success. The device gives up when its span stack runs out having filled *part* of
the region, and a program that cannot tell that happened cannot recover from it.
Note the shape of that handler: HANDLE sets handled = true on the context, so a
FAIL_RETURN from inside the HANDLE block hands the caller something already
marked handled, whose FINISH_LOGIC then declines to pass it up and releases it --
the error disappears and PAINT reports success. Flag inside the block, raise
after FINISH.
COLOR, LOCATE and SCALE need no device on purpose, so a program can set itself up
before a host has lent it a renderer.
Adds a second golden corpus under tests/language/. The corpus in
deps/basicinterpret is a submodule and nothing here may add files to it, but
goal 2's new verbs still need the .bas/.txt half of their coverage. Registered
under local_ so a failure names which corpus it came from. What it can cover is
limited -- these verbs draw rather than print -- so the behaviour that reaches a
device is asserted against tests/mockdevice.h instead.
65/65 ctest, clean under -Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:17:48 -04:00
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
Draw into the whole window, not its top-left 320x200
The graphics verbs documented a coordinate transform that did not exist. With
SCALE off a coordinate went straight to akgl_draw_* as a pixel address, so an
800x600 window drew a C128 listing into its corner and left the rest unused --
while the chapter said coordinates were 320x200 and stretching to fit was the
host's business.
akbasic_GraphicsBackend gains a size entry point, require_graphics() asks it
before every verb that draws so a resized window is honoured between two
statements, and 320x200 becomes the fallback for a backend that leaves it NULL.
It is the record's one optional member, so a host written against the old header
keeps the behaviour it had.
SCALE now maps onto the device, and RGR(1)/RGR(2) report the drawing surface so
a program can use a window whose size it did not choose. RGR(0) is BASIC 7.0's
own field, the GRAPHIC mode.
SCALE also mapped xmax onto the width rather than onto the last pixel, so
SCALE 1, 319, 199 followed by DRAW 1, 319, 199 drew nothing at all -- one pixel
past the surface. Fixed in the same line, because it is what makes "SCALE gives
a C128 listing the whole window" true rather than nearly true.
The akgl test renders against a 128x128 target, deliberately smaller than the
old constants: a SCALE still dividing by them misses it entirely rather than
landing somewhere plausible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:35:20 -04:00
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.
Draw: implement the BASIC 7.0 graphics verbs
GRAPHIC, COLOR, DRAW, BOX, CIRCLE, PAINT, SCALE, SSHAPE, GSHAPE and LOCATE, all
against the akbasic_GraphicsBackend record rather than akgl_draw_* directly, so
src/runtime_graphics.c includes no SDL and the whole group is testable in a build
with no SDL on the machine.
The reference lists every one of these as unimplemented, so the semantics come
from Commodore BASIC 7.0 rather than from a port, and four places where a modern
renderer cannot do what a C128 did are recorded in TODO.md section 5 rather than
silently substituted:
- CIRCLE is drawn as a polygon of inc-degree segments and akgl_draw_circle is
deliberately unused. 7.0's CIRCLE takes two radii, an arc range and a rotation,
so the primitive could serve only the fully-defaulted call, and a shape that
changed character depending on whether the radii happened to be equal would be
worse than one uniformly a polygon.
- SSHAPE puts a SHAPE:<n> handle in the string variable rather than the pixels,
because a value's string is a fixed 256 bytes and a region is a device surface.
GSHAPE refuses a string without that prefix instead of parsing whatever digits
it finds and pasting an unrelated slot.
- BOX fills on a negative angle; 7.0 puts the fill flag after the rotation, which
would make a filled box a seventh argument.
- GRAPHIC stores its mode and honours only the one consequence that means
anything here -- mode 0 is text -- while still refusing an out-of-range mode,
since that is a typo worth catching.
PAINT surfaces the flood fill's AKERR_OUTOFBOUNDS as an error rather than
success. The device gives up when its span stack runs out having filled *part* of
the region, and a program that cannot tell that happened cannot recover from it.
Note the shape of that handler: HANDLE sets handled = true on the context, so a
FAIL_RETURN from inside the HANDLE block hands the caller something already
marked handled, whose FINISH_LOGIC then declines to pass it up and releases it --
the error disappears and PAINT reports success. Flag inside the block, raise
after FINISH.
COLOR, LOCATE and SCALE need no device on purpose, so a program can set itself up
before a host has lent it a renderer.
Adds a second golden corpus under tests/language/. The corpus in
deps/basicinterpret is a submodule and nothing here may add files to it, but
goal 2's new verbs still need the .bas/.txt half of their coverage. Registered
under local_ so a failure names which corpus it came from. What it can cover is
limited -- these verbs draw rather than print -- so the behaviour that reaches a
device is asserted against tests/mockdevice.h instead.
65/65 ctest, clean under -Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:17:48 -04:00
Sound: implement the BASIC 7.0 sound verbs and the PLAY parser
SOUND, ENVELOPE, VOL, PLAY and TEMPO against the akbasic_AudioBackend record,
plus FILTER, which is in the table only so that it can be refused with a reason.
The PLAY note-string parser and TEMPO are here rather than in libakgl because
that is where its audio commit says they belong: a tone generator synthesises
pitches, but deciding that O4CDEFG is five quarter notes starting at middle C is
a language question.
PLAY does not block. On a C128 it holds the program until the last note ends,
which section 1.6 forbids outright, so it 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(). The driver now steps one
at a time with the clock refreshed in between rather than making a single
unbounded run() call -- a tune whose notes all measured themselves against a
frozen zero would rush out at once. That loop is its own function because CATCH
expands to a break and PASS expands to a return of the context, and main()
returns an int; wrapping the loop is what the protocol prescribes for that shape.
src/audio_tables.c holds the three conversions, laid out as tables because each
is somewhere a wrong constant produces a plausible wrong pitch rather than an
error anybody would notice. Two are transcriptions -- the SID frequency formula
and its non-linear ADSR rate tables, where decay is exactly three times attack.
The third is not: BASIC 7.0 never published what a whole note lasts at a given
TEMPO, so 16000 ms at TEMPO 1 is a calibration choice putting a default quarter
note at 120 bpm, and it is labelled as a choice where it is made.
SOUND's frequency sweep is refused rather than faked, and filed upstream as
akgl_audio_sweep. The only way to fake it here is to re-issue tones from step(),
which ties audible pitch to how often the host calls us -- a tune that changes
key with the frame rate. FILTER is refused for the reason upstream already gave:
there is no filter stage and SDL3 has no primitive to build one from.
The PLAY parser is tested through akbasic_play_parse() directly rather than
through a program, because running one also runs the queue service -- and with
the clock at zero every duration has already expired, so the queue empties before
an assertion can look at it. Draining is correct behaviour and is tested on its
own; the parse tests ask a different question.
68/68 ctest, clean under -Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:29:04 -04:00
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.
Consume libakgl 0.3.0: every workaround deleted, two capabilities gained
0.3.0 closed all ten API gaps this port had filed. The four workarounds go
with them: the CMake block that declared libakgl's vendored dependencies by
hand, the akgl/actor.h include in three files, and the six vtable pointers
assigned by hand in two more, now akgl_render_bind2d().
Two gaps were capabilities rather than inconveniences, and both are now real:
The line editor takes the composed UTF-8 text the ring carries in preference
to the keycode, so shifted characters, keyboard layouts, compose keys and dead
keys all work. A double quote can be typed, which means a BASIC string literal
can be typed -- the sharp end of the old limitation. Letters are no longer
folded to upper case.
SOUND's dir/min/step reach akgl_audio_sweep instead of being refused. dir 3
sweeps once rather than oscillating and TODO.md section 5 says so. A backend
with no sweep still refuses the swept note and plays the held one.
The adaptors now carry an AKGL_VERSION_AT_LEAST(0, 3, 0) floor, verified by
temporarily demanding 0.4.0 and watching it fire.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 13:25:14 -04:00
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.
Sound: implement the BASIC 7.0 sound verbs and the PLAY parser
SOUND, ENVELOPE, VOL, PLAY and TEMPO against the akbasic_AudioBackend record,
plus FILTER, which is in the table only so that it can be refused with a reason.
The PLAY note-string parser and TEMPO are here rather than in libakgl because
that is where its audio commit says they belong: a tone generator synthesises
pitches, but deciding that O4CDEFG is five quarter notes starting at middle C is
a language question.
PLAY does not block. On a C128 it holds the program until the last note ends,
which section 1.6 forbids outright, so it 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(). The driver now steps one
at a time with the clock refreshed in between rather than making a single
unbounded run() call -- a tune whose notes all measured themselves against a
frozen zero would rush out at once. That loop is its own function because CATCH
expands to a break and PASS expands to a return of the context, and main()
returns an int; wrapping the loop is what the protocol prescribes for that shape.
src/audio_tables.c holds the three conversions, laid out as tables because each
is somewhere a wrong constant produces a plausible wrong pitch rather than an
error anybody would notice. Two are transcriptions -- the SID frequency formula
and its non-linear ADSR rate tables, where decay is exactly three times attack.
The third is not: BASIC 7.0 never published what a whole note lasts at a given
TEMPO, so 16000 ms at TEMPO 1 is a calibration choice putting a default quarter
note at 120 bpm, and it is labelled as a choice where it is made.
SOUND's frequency sweep is refused rather than faked, and filed upstream as
akgl_audio_sweep. The only way to fake it here is to re-issue tones from step(),
which ties audible pitch to how often the host calls us -- a tune that changes
key with the frame rate. FILTER is refused for the reason upstream already gave:
there is no filter stage and SDL3 has no primitive to build one from.
The PLAY parser is tested through akbasic_play_parse() directly rather than
through a program, because running one also runs the queue service -- and with
the clock at zero every duration has already expired, so the queue empties before
an assertion can look at it. Draining is correct behaviour and is tested on its
own; the parse tests ask a different question.
68/68 ctest, clean under -Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:29:04 -04:00
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.
Read the keyboard: implement GET, GETKEY and SCNCLR
The poll_key half of group E, against the akbasic_InputBackend record. The
libakgl implementation behind it is akgl_controller_poll_key(), which drains a
ring the library fills from SDL events the host pumps -- so the interpreter can
answer "is there a key waiting" without owning an event loop, which is what goal
3 requires.
GET and GETKEY differ in one way and it is the interesting one. GET takes
whatever is there including nothing, and an empty buffer is success with the
empty string rather than an error -- that is what happens on most iterations of
every GET loop ever written, and upstream is explicit that its poll reports it
the same way. GETKEY waits, and since the library may not block, waiting is
spelled as holding the step loop: the verb sets a flag and akbasic_runtime_step()
declines to advance until a key arrives. Every step still returns and a bounded
run() still comes back, so a host keeps its frame rate; the program simply does
not move past the GETKEY.
The PLAY queue is serviced before that 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 on a device that
no longer exists.
Both verbs accept an integer variable as well as a string one and give it the raw
key code, which is what a program testing for cursor or function keys needs. No
key is code zero, matching what a C128 reports. A float variable is refused: it
is neither a character nor a code.
SCNCLR goes through the text sink rather than a device, because the sink is where
PRINT already goes and is the only thing that knows what a screen means for this
host. The stdio sink treats it as a no-op; clearing a pipe means nothing.
One thing worth knowing before writing any bounded-run test, and now commented in
tests/input_verbs.c: source lines are stored indexed by line number and the
cursor starts at zero, so a step is spent on each empty slot along the way. A
program at line 10 needs eleven steps before it has run anything.
70/70 ctest, clean under -Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:35:05 -04:00
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.
2026-07-31 11:21:39 -04:00
### 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.
Own every pixel each frame, and bring the cursor back as a blinking block
The blinking backspace was not the backspace. The sink skipped rows it
believed were already clear, tracked in a drawn[] array, which assumes a frame
inherits the frame before it. SDL_RenderPresent swaps buffers, so a frame
inherits the one two back: a row erased once is clean in one buffer and dirty
in the other, and presenting alternates between them. The first character of a
backspaced wrap flickered forever.
It reproduces on X11 and never under the dummy driver or a software renderer,
which is why the suite stayed green through two rounds of fixing it. The sink
now repaints every row it owns every frame -- a frame either owns every pixel
it presents or inherits pixels it cannot reason about.
Confirmed on real hardware with the reported input: the wrapped tail's row
reads zero across five successive captures.
The cursor returns as a blinking block, half a second per cycle, off SDL's
clock. It sits in the cell after the text rather than under it, which is what
made the underscore unreadable, and it wraps to the next row when a line
exactly fills one -- where the next character actually lands.
The new regression test paints stale pixels by hand, which is what a swapped-in
buffer hands back, so the case is covered without real hardware. Every new
assertion was checked by reverting the fix and watching it fail.
Recorded honestly in TODO.md section 5: the same buffer swap means the
graphics verbs were never reliable across frames either. A one-shot DRAW lands
in one buffer and the next present shows the other. Making that work needs a
persistent surface, which is its own commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:23:41 -04:00
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.
Erase what the text layer drew: three GUI bugs, one cause
The sink painted glyphs on top of whatever was already on the renderer and
never erased anything, and the host deliberately does not clear the frame so a
DRAW survives. The grid was always correct; the screen kept the previous
frame. That surfaced as three separate-looking faults:
- a backspaced character stayed on screen, including across a line wrap
- a scroll left the old rows behind, "papered over with garbage"
- the editing cursor smeared an underscore along every column it passed
through, which read as an underline under the text being typed
render() now fills each row it is about to draw, and each row that has emptied
since it last drew, with the background first. Row by row rather than one
clear over the whole area: the text layer is authoritative over the rows it
occupies and leaves every other pixel alone, so a picture behind the text
loses only the strips the text uses instead of all of it.
echo_line() also truncates rows that hold nothing but the spaces its own erase
pass wrote, which is what backspacing back across a wrap leaves behind. A row
of spaces is text as far as render() is concerned, so it would have been
repainted forever and kept erasing whatever was under it.
The cursor glyph is removed outright, as asked. The smearing was the erase bug
rather than the cursor, so one could come back and behave -- a block would
read better than an underscore.
Four pixel-level tests, because grid assertions could never have caught any of
this. Each was checked by reverting the fix and watching it fail; two of them
were vacuous when first written and are noted as such where they are fixed.
The real-keyboard test also now rubs out a character and scrolls forty lines.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:03:37 -04:00
28. **The window is closed by the host, and that is not `QUIT`. ** Closing the window stops the
2026-07-31 11:21:39 -04:00
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.
Erase what the text layer drew: three GUI bugs, one cause
The sink painted glyphs on top of whatever was already on the renderer and
never erased anything, and the host deliberately does not clear the frame so a
DRAW survives. The grid was always correct; the screen kept the previous
frame. That surfaced as three separate-looking faults:
- a backspaced character stayed on screen, including across a line wrap
- a scroll left the old rows behind, "papered over with garbage"
- the editing cursor smeared an underscore along every column it passed
through, which read as an underline under the text being typed
render() now fills each row it is about to draw, and each row that has emptied
since it last drew, with the background first. Row by row rather than one
clear over the whole area: the text layer is authoritative over the rows it
occupies and leaves every other pixel alone, so a picture behind the text
loses only the strips the text uses instead of all of it.
echo_line() also truncates rows that hold nothing but the spaces its own erase
pass wrote, which is what backspacing back across a wrap leaves behind. A row
of spaces is text as far as render() is concerned, so it would have been
repainted forever and kept erasing whatever was under it.
The cursor glyph is removed outright, as asked. The smearing was the erase bug
rather than the cursor, so one could come back and behave -- a block would
read better than an underscore.
Four pixel-level tests, because grid assertions could never have caught any of
this. Each was checked by reverting the fix and watching it fail; two of them
were vacuous when first written and are noted as such where they are fixed.
The real-keyboard test also now rubs out a character and scrolls forty lines.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:03:37 -04:00
29. **Piped input still works in an AKGL build. ** With a terminal on stdin the window is the
2026-07-31 11:21:39 -04:00
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.
2026-07-31 12:52:00 -04:00
### 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` .
2026-07-31 11:46:10 -04:00
### Deviations in statement separation
Erase what the text layer drew: three GUI bugs, one cause
The sink painted glyphs on top of whatever was already on the renderer and
never erased anything, and the host deliberately does not clear the frame so a
DRAW survives. The grid was always correct; the screen kept the previous
frame. That surfaced as three separate-looking faults:
- a backspaced character stayed on screen, including across a line wrap
- a scroll left the old rows behind, "papered over with garbage"
- the editing cursor smeared an underscore along every column it passed
through, which read as an underline under the text being typed
render() now fills each row it is about to draw, and each row that has emptied
since it last drew, with the background first. Row by row rather than one
clear over the whole area: the text layer is authoritative over the rows it
occupies and leaves every other pixel alone, so a picture behind the text
loses only the strips the text uses instead of all of it.
echo_line() also truncates rows that hold nothing but the spaces its own erase
pass wrote, which is what backspacing back across a wrap leaves behind. A row
of spaces is text as far as render() is concerned, so it would have been
repainted forever and kept erasing whatever was under it.
The cursor glyph is removed outright, as asked. The smearing was the erase bug
rather than the cursor, so one could come back and behave -- a block would
read better than an underscore.
Four pixel-level tests, because grid assertions could never have caught any of
this. Each was checked by reverting the fix and watching it fail; two of them
were vacuous when first written and are noted as such where they are fixed.
The real-keyboard test also now rubs out a character and scrolls forty lines.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:03:37 -04:00
31. **A branch decides who owns the rest of its line. ** BASIC 7.0 scopes every statement after
2026-07-31 11:46:10 -04:00
`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.
Finish the language: every remaining verb group, and the defects that blocked them
Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and
RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of
those turned out to have been fixed or never ported and nobody had written it
down; the audit records the evidence for each.
Two of the seventeen were real. math_plus mutated its left operand when the
operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT
coverage because NEXT relied on the mutation, so tests/for_next.c came first
and NEXT now writes the counter back itself. And the binary operators summed
both numeric fields of their right operand, which no BASIC program can reach
-- that one needed a test written against the value API.
Writing the tests turned up eight defects nobody had listed. Seven are fixed:
IF A = 2 THEN was a parse error; only == worked
IF ... AND ... was a parse error, because a condition parsed as one relation
IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN
EXIT before any NEXT restarted the program and exhausted the variable pool
READ never found a DATA line above it, and swallowed the lines between
PRINT 2 + 2 at the prompt was filed as program text instead of answering
a short read discarded its bytes, so COPY produced empty files
every verb taking an argument list said "peek() returned nil token!" on none
The eighth is not fixed and cannot be quietly: a FOR whose step overshoots
runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two
errors cancel for a step of 1, which is why neither was noticed. Correcting
them changes the expected output of a checked-in acceptance file, and
tests/reference/README.md forbids editing one to suit this interpreter. It is
tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct
contract, and TODO.md items 19 and 20.
Sprites are real libakgl actors with a renderfunc of their own, because
akgl_actor_render draws every sprite square and an actor has no per-axis
scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle
or a 63-element integer array -- a string here cannot hold a zero byte.
Verbs that need hardware that does not exist are refused by name with the
reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and
DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream.
94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen
clean. The Go acceptance corpus stayed green throughout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
### Deviations in the structure and error verbs (groups A and C)
Document structures: a chapter, the architecture, and the differences
docs/16-structures.md is the feature: records, nesting, copy-on-assign, strict
pointers, lists, what is checked and what is not, and how a host shares its own
C structs. Every example in it is executed by docs_examples and byte-compared,
including the refusals -- so a message that changes fails the suite rather than
quietly making the chapter wrong.
The chapter makes one contrast explicitly, because it is the question a reader
will actually have: a misspelled *field* is refused and a misspelled *variable*
still prints zero. The rule underneath is that what the program declared gets
checked and what it did not gets shrugged at -- a variable's name is never
declared, a TYPE's field list is. Structures end up the strictest thing in the
language, not from a higher standard but because they are the only named thing
whose valid spellings are written down.
Chapter 14 gains the layout: an instance is a contiguous run of value slots with
a diagram of where the fields sit, the three-pass prescan and why each pass
exists, why the copy cannot live in akbasic_value_clone(), and why the render
depth bound is four rather than eight. Chapter 3 gains the @ suffix, chapter 13
records that all of this is an addition BASIC 7.0 has nothing like, and the verb
reference gains TYPE, POINT and DIM ... AS.
MAINTENANCE.md gains the two rules that are on a maintainer rather than on a
test: a structure copy must not go through clone, and a field chain gets its own
leaf field. TODO.md section 5 records what was invented and the three limits
that are ours, and section 8 records the two defects the work exposed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 12:03:02 -04:00
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.
Finish the language: every remaining verb group, and the defects that blocked them
Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and
RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of
those turned out to have been fixed or never ported and nobody had written it
down; the audit records the evidence for each.
Two of the seventeen were real. math_plus mutated its left operand when the
operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT
coverage because NEXT relied on the mutation, so tests/for_next.c came first
and NEXT now writes the counter back itself. And the binary operators summed
both numeric fields of their right operand, which no BASIC program can reach
-- that one needed a test written against the value API.
Writing the tests turned up eight defects nobody had listed. Seven are fixed:
IF A = 2 THEN was a parse error; only == worked
IF ... AND ... was a parse error, because a condition parsed as one relation
IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN
EXIT before any NEXT restarted the program and exhausted the variable pool
READ never found a DATA line above it, and swallowed the lines between
PRINT 2 + 2 at the prompt was filed as program text instead of answering
a short read discarded its bytes, so COPY produced empty files
every verb taking an argument list said "peek() returned nil token!" on none
The eighth is not fixed and cannot be quietly: a FOR whose step overshoots
runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two
errors cancel for a step of 1, which is why neither was noticed. Correcting
them changes the expected output of a checked-in acceptance file, and
tests/reference/README.md forbids editing one to suit this interpreter. It is
tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct
contract, and TODO.md items 19 and 20.
Sprites are real libakgl actors with a renderfunc of their own, because
akgl_actor_render draws every sprite square and an actor has no per-axis
scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle
or a 63-element integer array -- a string here cannot hold a zero byte.
Verbs that need hardware that does not exist are refused by name with the
reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and
DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream.
94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen
clean. The Go acceptance corpus stayed green throughout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
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.
Draw into the whole window, not its top-left 320x200
The graphics verbs documented a coordinate transform that did not exist. With
SCALE off a coordinate went straight to akgl_draw_* as a pixel address, so an
800x600 window drew a C128 listing into its corner and left the rest unused --
while the chapter said coordinates were 320x200 and stretching to fit was the
host's business.
akbasic_GraphicsBackend gains a size entry point, require_graphics() asks it
before every verb that draws so a resized window is honoured between two
statements, and 320x200 becomes the fallback for a backend that leaves it NULL.
It is the record's one optional member, so a host written against the old header
keeps the behaviour it had.
SCALE now maps onto the device, and RGR(1)/RGR(2) report the drawing surface so
a program can use a window whose size it did not choose. RGR(0) is BASIC 7.0's
own field, the GRAPHIC mode.
SCALE also mapped xmax onto the width rather than onto the last pixel, so
SCALE 1, 319, 199 followed by DRAW 1, 319, 199 drew nothing at all -- one pixel
past the surface. Fixed in the same line, because it is what makes "SCALE gives
a C128 listing the whole window" true rather than nearly true.
The akgl test renders against a 128x128 target, deliberately smaller than the
old constants: a SCALE still dividing by them misses it entirely rather than
landing somewhere plausible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:35:20 -04:00
33. **Sprite coordinates are device pixels, not the VIC-II's 0– 511 by 0– 255. ** `MOVSPR` takes
Finish the language: every remaining verb group, and the defects that blocked them
Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and
RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of
those turned out to have been fixed or never ported and nobody had written it
down; the audit records the evidence for each.
Two of the seventeen were real. math_plus mutated its left operand when the
operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT
coverage because NEXT relied on the mutation, so tests/for_next.c came first
and NEXT now writes the counter back itself. And the binary operators summed
both numeric fields of their right operand, which no BASIC program can reach
-- that one needed a test written against the value API.
Writing the tests turned up eight defects nobody had listed. Seven are fixed:
IF A = 2 THEN was a parse error; only == worked
IF ... AND ... was a parse error, because a condition parsed as one relation
IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN
EXIT before any NEXT restarted the program and exhausted the variable pool
READ never found a DATA line above it, and swallowed the lines between
PRINT 2 + 2 at the prompt was filed as program text instead of answering
a short read discarded its bytes, so COPY produced empty files
every verb taking an argument list said "peek() returned nil token!" on none
The eighth is not fixed and cannot be quietly: a FOR whose step overshoots
runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two
errors cancel for a step of 1, which is why neither was noticed. Correcting
them changes the expected output of a checked-in acceptance file, and
tests/reference/README.md forbids editing one to suit this interpreter. It is
tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct
contract, and TODO.md items 19 and 20.
Sprites are real libakgl actors with a renderfunc of their own, because
akgl_actor_render draws every sprite square and an actor has no per-axis
scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle
or a 63-element integer array -- a string here cannot hold a zero byte.
Verbs that need hardware that does not exist are refused by name with the
reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and
DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream.
94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen
clean. The Go acceptance corpus stayed green throughout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
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
Draw into the whole window, not its top-left 320x200
The graphics verbs documented a coordinate transform that did not exist. With
SCALE off a coordinate went straight to akgl_draw_* as a pixel address, so an
800x600 window drew a C128 listing into its corner and left the rest unused --
while the chapter said coordinates were 320x200 and stretching to fit was the
host's business.
akbasic_GraphicsBackend gains a size entry point, require_graphics() asks it
before every verb that draws so a resized window is honoured between two
statements, and 320x200 becomes the fallback for a backend that leaves it NULL.
It is the record's one optional member, so a host written against the old header
keeps the behaviour it had.
SCALE now maps onto the device, and RGR(1)/RGR(2) report the drawing surface so
a program can use a window whose size it did not choose. RGR(0) is BASIC 7.0's
own field, the GRAPHIC mode.
SCALE also mapped xmax onto the width rather than onto the last pixel, so
SCALE 1, 319, 199 followed by DRAW 1, 319, 199 drew nothing at all -- one pixel
past the surface. Fixed in the same line, because it is what makes "SCALE gives
a C128 listing the whole window" true rather than nearly true.
The akgl test renders against a 128x128 target, deliberately smaller than the
old constants: a SCALE still dividing by them misses it entirely rather than
landing somewhere plausible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:35:20 -04:00
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.
Finish the language: every remaining verb group, and the defects that blocked them
Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and
RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of
those turned out to have been fixed or never ported and nobody had written it
down; the audit records the evidence for each.
Two of the seventeen were real. math_plus mutated its left operand when the
operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT
coverage because NEXT relied on the mutation, so tests/for_next.c came first
and NEXT now writes the counter back itself. And the binary operators summed
both numeric fields of their right operand, which no BASIC program can reach
-- that one needed a test written against the value API.
Writing the tests turned up eight defects nobody had listed. Seven are fixed:
IF A = 2 THEN was a parse error; only == worked
IF ... AND ... was a parse error, because a condition parsed as one relation
IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN
EXIT before any NEXT restarted the program and exhausted the variable pool
READ never found a DATA line above it, and swallowed the lines between
PRINT 2 + 2 at the prompt was filed as program text instead of answering
a short read discarded its bytes, so COPY produced empty files
every verb taking an argument list said "peek() returned nil token!" on none
The eighth is not fixed and cannot be quietly: a FOR whose step overshoots
runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two
errors cancel for a step of 1, which is why neither was noticed. Correcting
them changes the expected output of a checked-in acceptance file, and
tests/reference/README.md forbids editing one to suit this interpreter. It is
tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct
contract, and TODO.md items 19 and 20.
Sprites are real libakgl actors with a renderfunc of their own, because
akgl_actor_render draws every sprite square and an actor has no per-axis
scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle
or a 63-element integer array -- a string here cannot hold a zero byte.
Verbs that need hardware that does not exist are refused by name with the
reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and
DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream.
94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen
clean. The Go acceptance corpus stayed green throughout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
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).
Take libakgl 0.5.0 and rename every symbol it namespaced
0.5.0 gives every exported symbol the akgl_ prefix, which is the first libakgl
release to break this project's source rather than only its ABI. The soname goes
to libakgl.so.0.5 and include/akbasic/akgl.h asserts the floor.
What moved here: akgl_render_bind2d is akgl_render_2d_bind,
akgl_sprite_sheet_coords_for_frame is akgl_spritesheet_coords_for_frame, the
renderer, camera and window globals carry the prefix, and _akgl_renderer and
_akgl_camera are akgl_default_renderer and akgl_default_camera.
The renames were applied by site rather than by pattern, because renderer is also
a parameter name in src/sprite_akgl.c and a struct member throughout
src/frontend_akgl.c -- a substitution would have rewritten both without a word.
That is the same trap upstream describes hitting, and it is worth knowing that
the defect behind the rename was not cosmetic: an exported global called renderer
collided with a test's own variable, the executable's definition preempted the
library's, and every texture load in that suite failed while the suite passed.
0.5.0 also fixes libakgl defect 26, which was one of the two reasons
src/sprite_akgl.c installs its own renderfunc: akgl_actor_render took its
destination height from the sprite's width, drawing a 24x21 Commodore sprite as a
24x24 square. The renderfunc stays, because the other reason has not moved -- an
actor carries one scalar scale and SPRITE has separate x- and y-expand bits --
but the comments and TODO.md deviation 40 no longer claim a defect that is fixed.
Every documentation figure re-renders byte-identical under the new library, which
is what says the sprite and drawing paths did not move underneath them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 08:01:43 -04:00
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.
Finish the language: every remaining verb group, and the defects that blocked them
Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and
RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of
those turned out to have been fixed or never ported and nobody had written it
down; the audit records the evidence for each.
Two of the seventeen were real. math_plus mutated its left operand when the
operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT
coverage because NEXT relied on the mutation, so tests/for_next.c came first
and NEXT now writes the counter back itself. And the binary operators summed
both numeric fields of their right operand, which no BASIC program can reach
-- that one needed a test written against the value API.
Writing the tests turned up eight defects nobody had listed. Seven are fixed:
IF A = 2 THEN was a parse error; only == worked
IF ... AND ... was a parse error, because a condition parsed as one relation
IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN
EXIT before any NEXT restarted the program and exhausted the variable pool
READ never found a DATA line above it, and swallowed the lines between
PRINT 2 + 2 at the prompt was filed as program text instead of answering
a short read discarded its bytes, so COPY produced empty files
every verb taking an argument list said "peek() returned nil token!" on none
The eighth is not fixed and cannot be quietly: a FOR whose step overshoots
runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two
errors cancel for a step of 1, which is why neither was noticed. Correcting
them changes the expected output of a checked-in acceptance file, and
tests/reference/README.md forbids editing one to suit this interpreter. It is
tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct
contract, and TODO.md items 19 and 20.
Sprites are real libakgl actors with a renderfunc of their own, because
akgl_actor_render draws every sprite square and an actor has no per-axis
scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle
or a 63-element integer array -- a string here cannot hold a zero byte.
Verbs that need hardware that does not exist are refused by name with the
reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and
DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream.
94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen
clean. The Go acceptance corpus stayed green throughout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
Give a loaded line a number when it arrives without one
A script written against LABEL and GOTO NAME never names a line number, so
the numbers it had to carry were decoration. akbasic_runtime_load(),
RUNSTREAM and DLOAD now file an unnumbered line one slot after the last one
filed; a numbered line is filed under its number and moves the cursor, so
the two mix. akbasic_runtime_file_line() is the one implementation of that
rule, so the three paths cannot drift.
The prompt is untouched. A line typed without a number is still direct mode
and still 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.
What this replaces was silent data loss: an unnumbered line was filed under
the cursor unchanged, on top of the line before it. A blank line therefore
erased whatever preceded it, and RUNSTREAM did not skip blank lines the way
the other two paths did.
That moves one golden file, and the reference had the same defect.
language/arithmetic/integer.bas has 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.
tests/reference/README.md records the divergence and TODO.md section 5 item
63 says why.
akbasic_SourceLine grows a `numbered` flag so an assigned number can be told
from a written one. RENUMBER sets it on every line it touches; NEW, DELETE
and DLOAD clear it. hadlinenumber moves to akbasic_scanner_scan(), so it
always describes the line just scanned rather than only the REPL's.
Two lines carrying the same written number still keep the last, as they
always have. That is a separate decision.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:33:27 -04:00
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.
2026-08-01 16:38:54 -04:00
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.
Finish the language: every remaining verb group, and the defects that blocked them
Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and
RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of
those turned out to have been fixed or never ported and nobody had written it
down; the audit records the evidence for each.
Two of the seventeen were real. math_plus mutated its left operand when the
operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT
coverage because NEXT relied on the mutation, so tests/for_next.c came first
and NEXT now writes the counter back itself. And the binary operators summed
both numeric fields of their right operand, which no BASIC program can reach
-- that one needed a test written against the value API.
Writing the tests turned up eight defects nobody had listed. Seven are fixed:
IF A = 2 THEN was a parse error; only == worked
IF ... AND ... was a parse error, because a condition parsed as one relation
IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN
EXIT before any NEXT restarted the program and exhausted the variable pool
READ never found a DATA line above it, and swallowed the lines between
PRINT 2 + 2 at the prompt was filed as program text instead of answering
a short read discarded its bytes, so COPY produced empty files
every verb taking an argument list said "peek() returned nil token!" on none
The eighth is not fixed and cannot be quietly: a FOR whose step overshoots
runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two
errors cancel for a step of 1, which is why neither was noticed. Correcting
them changes the expected output of a checked-in acceptance file, and
tests/reference/README.md forbids editing one to suit this interpreter. It is
tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct
contract, and TODO.md items 19 and 20.
Sprites are real libakgl actors with a renderfunc of their own, because
akgl_actor_render draws every sprite square and an actor has no per-axis
scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle
or a 63-element integer array -- a string here cannot hold a zero byte.
Verbs that need hardware that does not exist are refused by name with the
reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and
DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream.
94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen
clean. The Go acceptance corpus stayed green throughout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
---
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
---
2026-07-31 12:52:00 -04:00
## 6. Reference defects — **fix them; fidelity is no longer a reason not to**
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
2026-07-31 12:52:00 -04:00
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.
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
Finish the language: every remaining verb group, and the defects that blocked them
Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and
RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of
those turned out to have been fixed or never ported and nobody had written it
down; the audit records the evidence for each.
Two of the seventeen were real. math_plus mutated its left operand when the
operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT
coverage because NEXT relied on the mutation, so tests/for_next.c came first
and NEXT now writes the counter back itself. And the binary operators summed
both numeric fields of their right operand, which no BASIC program can reach
-- that one needed a test written against the value API.
Writing the tests turned up eight defects nobody had listed. Seven are fixed:
IF A = 2 THEN was a parse error; only == worked
IF ... AND ... was a parse error, because a condition parsed as one relation
IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN
EXIT before any NEXT restarted the program and exhausted the variable pool
READ never found a DATA line above it, and swallowed the lines between
PRINT 2 + 2 at the prompt was filed as program text instead of answering
a short read discarded its bytes, so COPY produced empty files
every verb taking an argument list said "peek() returned nil token!" on none
The eighth is not fixed and cannot be quietly: a FOR whose step overshoots
runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two
errors cancel for a step of 1, which is why neither was noticed. Correcting
them changes the expected output of a checked-in acceptance file, and
tests/reference/README.md forbids editing one to suit this interpreter. It is
tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct
contract, and TODO.md items 19 and 20.
Sprites are real libakgl actors with a renderfunc of their own, because
akgl_actor_render draws every sprite square and an actor has no per-axis
scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle
or a 63-element integer array -- a string here cannot hold a zero byte.
Verbs that need hardware that does not exist are refused by name with the
reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and
DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream.
94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen
clean. The Go acceptance corpus stayed green throughout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
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.
2026-07-31 11:35:14 -04:00
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.
Finish the language: every remaining verb group, and the defects that blocked them
Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and
RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of
those turned out to have been fixed or never ported and nobody had written it
down; the audit records the evidence for each.
Two of the seventeen were real. math_plus mutated its left operand when the
operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT
coverage because NEXT relied on the mutation, so tests/for_next.c came first
and NEXT now writes the counter back itself. And the binary operators summed
both numeric fields of their right operand, which no BASIC program can reach
-- that one needed a test written against the value API.
Writing the tests turned up eight defects nobody had listed. Seven are fixed:
IF A = 2 THEN was a parse error; only == worked
IF ... AND ... was a parse error, because a condition parsed as one relation
IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN
EXIT before any NEXT restarted the program and exhausted the variable pool
READ never found a DATA line above it, and swallowed the lines between
PRINT 2 + 2 at the prompt was filed as program text instead of answering
a short read discarded its bytes, so COPY produced empty files
every verb taking an argument list said "peek() returned nil token!" on none
The eighth is not fixed and cannot be quietly: a FOR whose step overshoots
runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two
errors cancel for a step of 1, which is why neither was noticed. Correcting
them changes the expected output of a checked-in acceptance file, and
tests/reference/README.md forbids editing one to suit this interpreter. It is
tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct
contract, and TODO.md items 19 and 20.
Sprites are real libakgl actors with a renderfunc of their own, because
akgl_actor_render draws every sprite square and an actor has no per-axis
scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle
or a 63-element integer array -- a string here cannot hold a zero byte.
Verbs that need hardware that does not exist are refused by name with the
reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and
DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream.
94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen
clean. The Go acceptance corpus stayed green throughout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
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.
2026-07-31 11:35:14 -04:00
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` .
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
2026-07-31 12:52:00 -04:00
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.
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
Document host/script variable exchange, and file the scope hazard it exposes
The host can expose variables to a BASIC program, in both directions and for
every type, with no marshalling layer -- akbasic_environment_get() finds or
creates by name, the type comes from the suffix as it does for BASIC code, and
host and script share the same akbasic_Value. Nothing new was needed to make
that work.
Testing it before writing it up turned up a hazard worth stating plainly, so the
README documents the pattern and section 6 item 17 records the gap.
Seeding before akbasic_runtime_start() and reading after the script stops is
safe, and so is updating an existing global at any time, because the parent
chain is searched. Creating one mid-run is not, in two ways, and both are
silent. A script suspended part-way through a bounded run() is usually inside a
FOR or GOSUB scope, so a variable created through obj->environment lands in that
scope and dies when it pops -- the script reads it correctly inside the loop and
gets 0 immediately after. And reaching for the root explicitly does not help:
akbasic_environment_get only auto-creates when the environment it is given is
the active one, so with a child active it returns NULL through dest without
raising, and an unchecked host dereferences it.
This one is ours rather than inherited. It falls out of the environment pool
meeting the bounded run(), a combination the reference never had because its
run() never returned.
examples/hostvars.c demonstrates both hazards rather than describing them, and
prints what it observes, so the day this is fixed that output changes and the
example needs revisiting. Like examples/embed.c it is built and run by every
build. Both README snippets were extracted and compiled as a check.
Not fixed here: akbasic_runtime_global() would close it in about fifteen lines,
but what it should do when the script is suspended inside a user function's
scope is a design question worth settling deliberately rather than discovering.
Filed with the proposed signature and the tests it wants.
ctest 61/61; ASan+UBSan 61/61; no warnings under -Wall -Wextra.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:50:55 -04:00
### Ours, not the reference's
2026-07-31 11:53:05 -04:00
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.
Document host/script variable exchange, and file the scope hazard it exposes
The host can expose variables to a BASIC program, in both directions and for
every type, with no marshalling layer -- akbasic_environment_get() finds or
creates by name, the type comes from the suffix as it does for BASIC code, and
host and script share the same akbasic_Value. Nothing new was needed to make
that work.
Testing it before writing it up turned up a hazard worth stating plainly, so the
README documents the pattern and section 6 item 17 records the gap.
Seeding before akbasic_runtime_start() and reading after the script stops is
safe, and so is updating an existing global at any time, because the parent
chain is searched. Creating one mid-run is not, in two ways, and both are
silent. A script suspended part-way through a bounded run() is usually inside a
FOR or GOSUB scope, so a variable created through obj->environment lands in that
scope and dies when it pops -- the script reads it correctly inside the loop and
gets 0 immediately after. And reaching for the root explicitly does not help:
akbasic_environment_get only auto-creates when the environment it is given is
the active one, so with a child active it returns NULL through dest without
raising, and an unchecked host dereferences it.
This one is ours rather than inherited. It falls out of the environment pool
meeting the bounded run(), a combination the reference never had because its
run() never returned.
examples/hostvars.c demonstrates both hazards rather than describing them, and
prints what it observes, so the day this is fixed that output changes and the
example needs revisiting. Like examples/embed.c it is built and run by every
build. Both README snippets were extracted and compiled as a check.
Not fixed here: akbasic_runtime_global() would close it in about fifteen lines,
but what it should do when the script is suspended inside a user function's
scope is a design question worth settling deliberately rather than discovering.
Filed with the proposed signature and the tests it wants.
ctest 61/61; ASan+UBSan 61/61; no warnings under -Wall -Wextra.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:50:55 -04:00
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
---
Finish the language: every remaining verb group, and the defects that blocked them
Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and
RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of
those turned out to have been fixed or never ported and nobody had written it
down; the audit records the evidence for each.
Two of the seventeen were real. math_plus mutated its left operand when the
operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT
coverage because NEXT relied on the mutation, so tests/for_next.c came first
and NEXT now writes the counter back itself. And the binary operators summed
both numeric fields of their right operand, which no BASIC program can reach
-- that one needed a test written against the value API.
Writing the tests turned up eight defects nobody had listed. Seven are fixed:
IF A = 2 THEN was a parse error; only == worked
IF ... AND ... was a parse error, because a condition parsed as one relation
IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN
EXIT before any NEXT restarted the program and exhausted the variable pool
READ never found a DATA line above it, and swallowed the lines between
PRINT 2 + 2 at the prompt was filed as program text instead of answering
a short read discarded its bytes, so COPY produced empty files
every verb taking an argument list said "peek() returned nil token!" on none
The eighth is not fixed and cannot be quietly: a FOR whose step overshoots
runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two
errors cancel for a step of 1, which is why neither was noticed. Correcting
them changes the expected output of a checked-in acceptance file, and
tests/reference/README.md forbids editing one to suit this interpreter. It is
tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct
contract, and TODO.md items 19 and 20.
Sprites are real libakgl actors with a renderfunc of their own, because
akgl_actor_render draws every sprite square and an actor has no per-axis
scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle
or a 63-element integer array -- a string here cannot hold a zero byte.
Verbs that need hardware that does not exist are refused by name with the
reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and
DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream.
94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen
clean. The Go acceptance corpus stayed green throughout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
### 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.
Give each DEF call its own environment, so recursion returns
The function's environment was owned by the funcdef and re-initialised on every
call, which made a function not re-entrant and cost two silent defects:
DEF DBL(N#) = N# * 2
PRINT DBL(10) + DBL(1) was 4, should be 22
The result was a pointer into the funcdef's own environment, so the second call
overwrote the first before the operator saw it -- both operands became the last
call's answer. Two *different* functions in one expression were fine, which is
most of why it was invisible.
DEF FACT(N#)
IF N# <= 1 THEN RETURN 1
RETURN N# * FACT(N# - 1)
PRINT FACT(5) never returned
The recursive call re-initialised the environment the outer call was still
using, so the loop waiting for control to come back could not see it. No error,
no bound, no diagnostic -- the one place in this interpreter that looped forever
rather than raising.
A call takes an environment from the pool now, exactly as GOSUB does. The result
is copied into a caller-scope scratch before that environment goes back, because
handing back a pointer into the callee is what made two calls collide and would
now be a pointer into a released slot as well. RETURN parks its result on the
*parent* rather than on the environment it is about to release, so nothing reads
a freed slot to find it.
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 goes with it, as dead state.
One thing this exposed but did not cause, measured against a stashed build and
recorded rather than fixed: a statement containing a failed multi-line DEF call
still completes and prints a junk value. It is visible more often now only
because runaway recursion reaches it where it used to hang.
tests/language/functions/recursion.bas deliberately does not pin that answer.
Chapter 16 loses its "walk a list with a loop, not a recursive DEF" caveat and
gains the one that is still true: a function cannot take a structure parameter
yet, so it reaches a record by name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 12:29:35 -04:00
### Found while fixing the DEF recursion hang
Let DEF take structure parameters, and give call scopes back
DEF AREA(S@ AS RECT) = S@.W# * S@.H#
DEF POKEIT(P@ AS PTR TO RECT)
A parameter names its type, exactly as DIM does. A bare `DEF F(S@)` is refused:
@ says "a structure" without saying which, so it does not state a contract the
way S$ does, and accepting it would mean checking fields at the call rather than
at the declaration -- which is the hole naming the type closes. The cost is that
there are no generic functions, and that is a real loss rather than an oversight.
Passing is by value, because a parameter is bound by assignment and assignment
copies; a pointer parameter copies its reference and lets a function change its
caller's record on purpose. Neither is a special rule. What a structure
parameter does need is its storage prepared before the copy, since a structure
variable is a run of slots and there is nothing to copy into until the run
exists.
A DEF parameter list is no longer parsed as an argument list, because a
parameter is a declaration rather than an expression: `S@ AS RECT` stopped that
parser dead with "Unbalanced parenthesis".
akbasic_value_is_truthy() learned that a pointer is true when it points at
something, which had to come with this. Without it there is no way to test for
the end of a list at all -- comparing a pointer to 0 reads a numeric field it
does not carry and answers whatever that field held. A structure is deliberately
given no truth value: it always exists, so the question has no answer worth
guessing at.
And a regression I introduced last commit, plus the older one underneath it.
prev_environment() released a scope but not the variables the scope created, so
a call leaked one slot per parameter and two hundred calls exhausted the
128-slot pool. Giving each DEF call its own scope made that reachable; it was
there for GOSUB all along, measured on a stashed build -- a subroutine with a
local of its own failed after about 128 calls before any of this work. The
release is safe because a scope's table holds only what it created, and it is
the variable slot that comes back rather than its storage, so a pointer into a
record DIMmed in that scope stays sound.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:05:32 -04:00
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.
Give each DEF call its own environment, so recursion returns
The function's environment was owned by the funcdef and re-initialised on every
call, which made a function not re-entrant and cost two silent defects:
DEF DBL(N#) = N# * 2
PRINT DBL(10) + DBL(1) was 4, should be 22
The result was a pointer into the funcdef's own environment, so the second call
overwrote the first before the operator saw it -- both operands became the last
call's answer. Two *different* functions in one expression were fine, which is
most of why it was invisible.
DEF FACT(N#)
IF N# <= 1 THEN RETURN 1
RETURN N# * FACT(N# - 1)
PRINT FACT(5) never returned
The recursive call re-initialised the environment the outer call was still
using, so the loop waiting for control to come back could not see it. No error,
no bound, no diagnostic -- the one place in this interpreter that looped forever
rather than raising.
A call takes an environment from the pool now, exactly as GOSUB does. The result
is copied into a caller-scope scratch before that environment goes back, because
handing back a pointer into the callee is what made two calls collide and would
now be a pointer into a released slot as well. RETURN parks its result on the
*parent* rather than on the environment it is about to release, so nothing reads
a freed slot to find it.
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 goes with it, as dead state.
One thing this exposed but did not cause, measured against a stashed build and
recorded rather than fixed: a statement containing a failed multi-line DEF call
still completes and prints a junk value. It is visible more often now only
because runaway recursion reaches it where it used to hang.
tests/language/functions/recursion.bas deliberately does not pin that answer.
Chapter 16 loses its "walk a list with a loop, not a recursive DEF" caveat and
gains the one that is still true: a function cannot take a structure parameter
yet, so it reaches a record by name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 12:29:35 -04:00
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.
Document structures: a chapter, the architecture, and the differences
docs/16-structures.md is the feature: records, nesting, copy-on-assign, strict
pointers, lists, what is checked and what is not, and how a host shares its own
C structs. Every example in it is executed by docs_examples and byte-compared,
including the refusals -- so a message that changes fails the suite rather than
quietly making the chapter wrong.
The chapter makes one contrast explicitly, because it is the question a reader
will actually have: a misspelled *field* is refused and a misspelled *variable*
still prints zero. The rule underneath is that what the program declared gets
checked and what it did not gets shrugged at -- a variable's name is never
declared, a TYPE's field list is. Structures end up the strictest thing in the
language, not from a higher standard but because they are the only named thing
whose valid spellings are written down.
Chapter 14 gains the layout: an instance is a contiguous run of value slots with
a diagram of where the fields sit, the three-pass prescan and why each pass
exists, why the copy cannot live in akbasic_value_clone(), and why the render
depth bound is four rather than eight. Chapter 3 gains the @ suffix, chapter 13
records that all of this is an addition BASIC 7.0 has nothing like, and the verb
reference gains TYPE, POINT and DIM ... AS.
MAINTENANCE.md gains the two rules that are on a maintainer rather than on a
test: a structure copy must not go through clone, and a field chain gets its own
leaf field. TODO.md section 5 records what was invented and the three limits
that are ours, and section 8 records the two defects the work exposed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 12:03:02 -04:00
### 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.
Give each DEF call its own environment, so recursion returns
The function's environment was owned by the funcdef and re-initialised on every
call, which made a function not re-entrant and cost two silent defects:
DEF DBL(N#) = N# * 2
PRINT DBL(10) + DBL(1) was 4, should be 22
The result was a pointer into the funcdef's own environment, so the second call
overwrote the first before the operator saw it -- both operands became the last
call's answer. Two *different* functions in one expression were fine, which is
most of why it was invisible.
DEF FACT(N#)
IF N# <= 1 THEN RETURN 1
RETURN N# * FACT(N# - 1)
PRINT FACT(5) never returned
The recursive call re-initialised the environment the outer call was still
using, so the loop waiting for control to come back could not see it. No error,
no bound, no diagnostic -- the one place in this interpreter that looped forever
rather than raising.
A call takes an environment from the pool now, exactly as GOSUB does. The result
is copied into a caller-scope scratch before that environment goes back, because
handing back a pointer into the callee is what made two calls collide and would
now be a pointer into a released slot as well. RETURN parks its result on the
*parent* rather than on the environment it is about to release, so nothing reads
a freed slot to find it.
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 goes with it, as dead state.
One thing this exposed but did not cause, measured against a stashed build and
recorded rather than fixed: a statement containing a failed multi-line DEF call
still completes and prints a junk value. It is visible more often now only
because runaway recursion reaches it where it used to hang.
tests/language/functions/recursion.bas deliberately does not pin that answer.
Chapter 16 loses its "walk a list with a loop, not a recursive DEF" caveat and
gains the one that is still true: a function cannot take a structure parameter
yet, so it reaches a record by name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 12:29:35 -04:00
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` .
Refuse an operand the numeric path cannot read
math_minus, math_multiply and math_divide were `if ( INTEGER ) ... else <treat
as float>`, and that else was a catch-all rather than a float branch: it 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 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 and that asymmetry is the point: the
left one picks the branch and must be a number, while the right is read through
rval_as_int(), which handles -1/0 deliberately. `5 - (A# == 1)` is 6, and that
is the same property that lets AND and OR double as logical operators, so
refusing a truth value on the right would have broken every condition in the
language.
Found by asking what a structure operand would do to this path, which is where
AKBASIC_TYPE_STRUCT is about to arrive. The structures work did not create the
defect; it made an already-reachable one worth chasing.
Two stale comments went with it. src/value.c's header and a duplicate block
above rval_as_int both cited "TODO.md section 12", which does not exist -- the
defect list is section 6 -- and the duplicate described the summing behaviour
item 5 had already removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:22:50 -04:00
### 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.
Document every error code a script can see, as chapter 15
ER# held numbers nothing explained. The appendix lists the four error classes
the interpreter prints, the eight codes it owns and 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 -- so the numbers
are checked rather than claimed. The eighth, 516, is not usefully trappable and
the chapter says why: entering a handler takes a scope, and the pool being empty
is what raised it.
Two things worth a reader's attention came out of writing it. Two codes register
the same ERR() text, so a program must compare the number and print the text.
And VAL reports libakerror's Value Error rather than the interpreter's 517,
which makes that number the platform's rather than ours -- filed as section 6
item 21, not fixed here, because deciding which libakstdlib failures to
translate is a boundary question and ENOENT out of DOPEN is the counter-case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:36:36 -04:00
### 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.
Document optional line numbers
Chapter 2 gets the rule and the refusal, chapter 4 gets the payoff for
LABEL, chapter 9 says DSAVE writes the numbers it handed out and to
RENUMBER first if you want gaps, chapter 10 shows a host loading numberless
source, chapter 13 gets the QuickBASIC-shaped divergence, and chapter 14's
source[] passage gets its second half.
Chapter 14 said "two prescans" and listed two; there were three before this
and there are four now, so it lists all four and says which of them reports
against the right line.
TODO.md section 6 records four things found on the way and deliberately not
fixed: set_label() filing into the active scope rather than the root, three
prescans reporting the wrong line number, duplicate written line numbers
still replacing silently, and renumber.c's file-scope scratch arrays.
examples/embed.c runs the same program twice, numbered and not, so the
example compiles the feature rather than describing it. Its header pointed
at ./build/examples/embed, which is not where the binary lands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:43:14 -04:00
### 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.
2026-08-02 02:17:17 -04:00
### 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.
Add two Breakout examples and the tutorials that build them
Two complete games in `examples/breakout/`, both 100% BASIC: `characters/`
draws its wall in the text grid with two `DATA` sprites for the ball and
paddle, and `sprites/` loads CC0 artwork and captures its whole screen with
`SSHAPE`/`SPRSAV`. They take opposite shapes for reasons that are entirely
this interpreter's, which is what the chapters are for.
`docs/17-tutorial-breakout.md` and `docs/18-tutorial-breakout-artwork.md`
build each one a step at a time, and end in a checklist of the rules a real
program runs into: create every name before the loop starts, write a text row
whole, loop with `GOTO` rather than `DO`, put the float on the left. Every
trap is a runnable block with its own output rather than a claim -- the value
pool dying at four thousand names, the skipped `BEGIN` block that breaks its
caller's `RETURN`, `SSHAPE` ignoring a subscript, `READ`'s single cursor.
Five figures, generated from the listings beside them by `docs_screenshots`,
and a `breakout_art` setup so the ones that load artwork load the example's
own. The character game's wall cannot be photographed -- the screenshot host
omits the text layer on purpose -- so it is shown as compared output instead.
`docs/07-sound.md` never said `SOUND`'s frequency is a SID register value
rather than hertz, which both games depend on. It says so now, with the
conversion from `src/audio_tables.c:84`.
`TODO.md` gains the thirteen defects the two games turned up -- §6 items 30
to 33 and all of §9 -- each with a reduction that fits on a screen, the file
and line of the cause, and what a fix would touch.
Verified: `docs_examples` passes in both build configurations,
`docs_screenshots --check` re-renders all thirteen figures and byte-compares
them, the full 109-test suite passes in both builds, every quoted fragment
was checked to appear verbatim in the listing it came from, and every
relative link and anchor resolves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:54:42 -04:00
### 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.
Stop a scalar created inside a scope costing value-pool slots
A scalar now lives in the variable record (`akbasic_Variable::inlinevalue`)
rather than drawing from the value pool, so a `GOSUB` local, a `FOR` counter and
a `DEF` parameter cost nothing at all.
The pool is a bump allocator with no free, and its comment justified that with
"nothing in BASIC destroys a variable". Scope exit does: it marks the variable
slot unused, `new_variable()` memsets the slot it hands back -- clearing
`values` -- and `variable_init()` therefore took *fresh* slots for a variable
whose old ones were still counted. Every scope that created a local leaked, with
no diagnostic until the pool ran dry on whichever line happened to be unlucky.
Six thousand `GOSUB`s creating one local used to die on the 4091st at `LOC# = 1`
with "Array of 1 elements does not fit in the 0 remaining value slots". They now
run. A `DEF` called eight thousand times used to die between the four and five
thousandth -- the leaking slot was the call scope's parameter, which is a scalar
-- and both forms now run. A game creating one name per tick was dead in half a
minute; the Breakout in examples/ was, after twenty-five seconds.
**A `@` name is the one exclusion, and it is the whole of it.** A structure or a
pointer to one keeps pool storage, because a pointer into a record outlives the
scope that DIMmed it -- docs/16-structures.md says nothing is reclaimed and
`prev_environment()` relies on it. The name suffix is the right test rather than
`structtype`, which the DIM path sets *after* calling `variable_init()`. A local
array therefore still leaks, deliberately, and is now the narrow rule the
tutorial teaches.
`SWAP` needed the other half: it copies whole variable records, so the `values`
pointer that came over named the other variable's inline slot -- which by then
held this variable's own old value -- and SWAP silently did nothing. Caught by
tests/language/housekeeping/verbs.bas, which is the golden corpus earning its
keep.
tests/value_pool.c is the new coverage. It asserts the mechanism as well as the
consequence: a later change that moved arrays inline too would pass every
behavioural case and quietly break the pointer guarantee. The sharpest case
takes the pool's whole 4096 slots in four arrays after two hundred scope
entries, so one leaked slot has nowhere to go.
Chapter 17 Step 3 taught "declare every name at the top" and no longer needs to.
It now teaches what is still true -- a name first seen inside a subroutine dies
at RETURN, so a routine cannot answer its caller through one -- and its
demonstration is the array case, which still fails.
TODO.md section 6 item 30 and section 9 item 1, both struck.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:47:49 -04:00
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
Add two Breakout examples and the tutorials that build them
Two complete games in `examples/breakout/`, both 100% BASIC: `characters/`
draws its wall in the text grid with two `DATA` sprites for the ball and
paddle, and `sprites/` loads CC0 artwork and captures its whole screen with
`SSHAPE`/`SPRSAV`. They take opposite shapes for reasons that are entirely
this interpreter's, which is what the chapters are for.
`docs/17-tutorial-breakout.md` and `docs/18-tutorial-breakout-artwork.md`
build each one a step at a time, and end in a checklist of the rules a real
program runs into: create every name before the loop starts, write a text row
whole, loop with `GOTO` rather than `DO`, put the float on the left. Every
trap is a runnable block with its own output rather than a claim -- the value
pool dying at four thousand names, the skipped `BEGIN` block that breaks its
caller's `RETURN`, `SSHAPE` ignoring a subscript, `READ`'s single cursor.
Five figures, generated from the listings beside them by `docs_screenshots`,
and a `breakout_art` setup so the ones that load artwork load the example's
own. The character game's wall cannot be photographed -- the screenshot host
omits the text layer on purpose -- so it is shown as compared output instead.
`docs/07-sound.md` never said `SOUND`'s frequency is a SID register value
rather than hertz, which both games depend on. It says so now, with the
conversion from `src/audio_tables.c:84`.
`TODO.md` gains the thirteen defects the two games turned up -- §6 items 30
to 33 and all of §9 -- each with a reduction that fits on a screen, the file
and line of the cause, and what a fix would touch.
Verified: `docs_examples` passes in both build configurations,
`docs_screenshots --check` re-renders all thirteen figures and byte-compares
them, the full 109-test suite passes in both builds, every quoted fragment
was checked to appear verbatim in the listing it came from, and every
relative link and anchor resolves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:54:42 -04:00
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.
Forward WINDOW through the tee sink
`akbasic_sink_init_tee()` wired write, writeln, readline, clear and --
conditionally -- moveto, and never `window`. The standalone AKGL frontend runs
the interpreter against a tee, so the fully implemented `sink_window()`
underneath it was unreachable: `WINDOW 0, 0, 20, 4` answered "WINDOW needs a
text device with a character grid, and this one has none" on the one build that
has a character grid. It reads as an oversight rather than a decision, because
`moveto` directly above it is forwarded with reasoning that applies unchanged.
`tee_window()` is that function, modelled on `tee_moveto()`, offered only when a
half can take it so the refusal still reads correctly through a stdio-only pair.
**Writing the test found a second one.** Both optional entry points are assigned
conditionally, and neither initializer cleared them first -- so a caller with a
sink on the stack got whatever was in that memory, and `CHAR` and `WINDOW`
decide whether they can act by testing those pointers for NULL. Both
`akbasic_sink_init_tee()` and `akbasic_sink_init_stdio()` now clear them. An
initializer that leaves a field alone is not an initializer.
tests/sink_tee.c gains a stand-in sink with a grid -- the two stdio halves have
neither entry point, which is exactly why they could not show that either is
forwarded -- and asserts both directions, the arguments arriving intact, and
that a grid-less pair still offers neither.
Verified end to end: `WINDOW 0, 0, 20, 4` then `PRINT` succeeds under
build-akgl/basic and still refuses by name under build/basic.
What this does *not* fix is that a drawing still has to be re-issued every
frame: the frontend never clears and SDL is double-buffered, so a drawing issued
once appears in one buffer only. Shrinking the text area makes the rest of the
window the program's; keeping something there is still the program's job. That
half is documented rather than fixed, and is filed as TODO.md section 9 item 5.
TODO.md section 6 item 31, struck.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:57:57 -04:00
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()`
Add two Breakout examples and the tutorials that build them
Two complete games in `examples/breakout/`, both 100% BASIC: `characters/`
draws its wall in the text grid with two `DATA` sprites for the ball and
paddle, and `sprites/` loads CC0 artwork and captures its whole screen with
`SSHAPE`/`SPRSAV`. They take opposite shapes for reasons that are entirely
this interpreter's, which is what the chapters are for.
`docs/17-tutorial-breakout.md` and `docs/18-tutorial-breakout-artwork.md`
build each one a step at a time, and end in a checklist of the rules a real
program runs into: create every name before the loop starts, write a text row
whole, loop with `GOTO` rather than `DO`, put the float on the left. Every
trap is a runnable block with its own output rather than a claim -- the value
pool dying at four thousand names, the skipped `BEGIN` block that breaks its
caller's `RETURN`, `SSHAPE` ignoring a subscript, `READ`'s single cursor.
Five figures, generated from the listings beside them by `docs_screenshots`,
and a `breakout_art` setup so the ones that load artwork load the example's
own. The character game's wall cannot be photographed -- the screenshot host
omits the text layer on purpose -- so it is shown as compared output instead.
`docs/07-sound.md` never said `SOUND`'s frequency is a SID register value
rather than hertz, which both games depend on. It says so now, with the
conversion from `src/audio_tables.c:84`.
`TODO.md` gains the thirteen defects the two games turned up -- §6 items 30
to 33 and all of §9 -- each with a reduction that fits on a screen, the file
and line of the cause, and what a fix would touch.
Verified: `docs_examples` passes in both build configurations,
`docs_screenshots --check` re-renders all thirteen figures and byte-compares
them, the full 109-test suite passes in both builds, every quoted fragment
was checked to appear verbatim in the listing it came from, and every
relative link and anchor resolves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:54:42 -04:00
(`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.
Let a program ask how big the text grid is
`RGR(1)` and `RGR(2)` gave the window in pixels and nothing gave columns, rows
or the cell size -- so anything placing a character *and* a sprite at the same
spot had to hardcode a number measured by hand against whatever font the host
loaded. The Breakout in examples/ does exactly that, `CW# = 16`, and it is the
one thing in that listing that breaks on a different font or window.
**`RWINDOW` is BASIC 7.0's own answer and had never been implemented here.**
`RWINDOW(0)` is the current text window's rows and `RWINDOW(1)` its columns.
`RWINDOW(2)` reports a C128's 40 or 80 column screen mode, and this interpreter
has neither -- refused by name, because answering 0 would be a plausible lie,
which is worse than a refusal that says why.
The cell size in pixels is `RGR(3)` and `RGR(4)`, beside the surface's own
dimensions rather than on `RWINDOW`. Two reasons: a cell size is a fact about
the surface, and `RWINDOW` reports the *window*, so dividing `RGR(1)` by a
column count stops being right the moment a program calls `WINDOW`.
Both read a new optional `grid` entry point on `akbasic_TextSink` -- columns,
rows, cell width, cell height -- implemented by the akgl sink and forwarded by
the tee, in the shape `moveto` and `window` already had. NULL everywhere else,
so both verbs refuse by name against a sink with no grid. `akbasic_sink_init_
stdio()` clears it for the same reason it now clears the other two.
Measured on the standalone build: `RGR(3)` answers 16 and `RWINDOW` answers 50
columns by 37 rows -- the three numbers the Breakout listing had written out as
constants -- and `RWINDOW` follows a `WINDOW` call while `RGR(3)` does not.
tests/console_verbs.c drives the answers through a stand-in sink with a grid,
since the harness sink is stdio and has none; tests/graphics_verbs.c covers the
new `RGR` fields, their refusal, and the moved range bound. The `c excerpt=`
block in docs/10-embedding.md moves with the header, which is `docs_examples`
doing its job.
TODO.md section 6 item 31's second half, struck.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 00:05:17 -04:00
~~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
Add two Breakout examples and the tutorials that build them
Two complete games in `examples/breakout/`, both 100% BASIC: `characters/`
draws its wall in the text grid with two `DATA` sprites for the ball and
paddle, and `sprites/` loads CC0 artwork and captures its whole screen with
`SSHAPE`/`SPRSAV`. They take opposite shapes for reasons that are entirely
this interpreter's, which is what the chapters are for.
`docs/17-tutorial-breakout.md` and `docs/18-tutorial-breakout-artwork.md`
build each one a step at a time, and end in a checklist of the rules a real
program runs into: create every name before the loop starts, write a text row
whole, loop with `GOTO` rather than `DO`, put the float on the left. Every
trap is a runnable block with its own output rather than a claim -- the value
pool dying at four thousand names, the skipped `BEGIN` block that breaks its
caller's `RETURN`, `SSHAPE` ignoring a subscript, `READ`'s single cursor.
Five figures, generated from the listings beside them by `docs_screenshots`,
and a `breakout_art` setup so the ones that load artwork load the example's
own. The character game's wall cannot be photographed -- the screenshot host
omits the text layer on purpose -- so it is shown as compared output instead.
`docs/07-sound.md` never said `SOUND`'s frequency is a SID register value
rather than hertz, which both games depend on. It says so now, with the
conversion from `src/audio_tables.c:84`.
`TODO.md` gains the thirteen defects the two games turned up -- §6 items 30
to 33 and all of §9 -- each with a reduction that fits on a screen, the file
and line of the cause, and what a fix would touch.
Verified: `docs_examples` passes in both build configurations,
`docs_screenshots --check` re-renders all thirteen figures and byte-compares
them, the full 109-test suite passes in both builds, every quoted fragment
was checked to appear verbatim in the listing it came from, and every
relative link and anchor resolves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:54:42 -04:00
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.
Land a character written past a short row's terminator
A grid row is a NUL-terminated string, and `putchar_at()` wrote the character,
advanced and terminated -- so `CHAR 1, 40, 1, "#"` on an otherwise empty row
stored the `#` at column 40 with `text[1][0]` still `'\0'`, and the render loop,
which stops at the terminator, drew nothing at all.
The silent nothing is the trap. The write succeeded, the cursor moved, the
stdout mirror showed the character, and only the window stayed blank -- so the
program looked right everywhere except where it mattered. The documented
truncation on the way *back* is fine and is unaffected.
`putchar_at()` now fills the gap with spaces before storing.
**Padded there rather than in `sink_moveto()`**, which TODO.md proposed: this way
`moveto` stays read-only -- the objection that entry raised against its own
suggestion -- and a row is only ever padded when a character actually arrives.
The pad fills from the terminator rather than replacing it. The buffer is not
cleared between rows, so replacing only the terminator would expose the tail of
whatever longer row used to be there: writing "ABCDEFGHIJ", truncating it to
"ABCX", then writing at column 7 must give "ABCX Z" and not "ABCX FGZ".
tests/akgl_backends.c asserts all three cases, and the middle one keeps the
truncation pinned.
TODO.md section 6 item 32, struck.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 00:13:42 -04:00
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` )
Add two Breakout examples and the tutorials that build them
Two complete games in `examples/breakout/`, both 100% BASIC: `characters/`
draws its wall in the text grid with two `DATA` sprites for the ball and
paddle, and `sprites/` loads CC0 artwork and captures its whole screen with
`SSHAPE`/`SPRSAV`. They take opposite shapes for reasons that are entirely
this interpreter's, which is what the chapters are for.
`docs/17-tutorial-breakout.md` and `docs/18-tutorial-breakout-artwork.md`
build each one a step at a time, and end in a checklist of the rules a real
program runs into: create every name before the loop starts, write a text row
whole, loop with `GOTO` rather than `DO`, put the float on the left. Every
trap is a runnable block with its own output rather than a claim -- the value
pool dying at four thousand names, the skipped `BEGIN` block that breaks its
caller's `RETURN`, `SSHAPE` ignoring a subscript, `READ`'s single cursor.
Five figures, generated from the listings beside them by `docs_screenshots`,
and a `breakout_art` setup so the ones that load artwork load the example's
own. The character game's wall cannot be photographed -- the screenshot host
omits the text layer on purpose -- so it is shown as compared output instead.
`docs/07-sound.md` never said `SOUND`'s frequency is a SID register value
rather than hertz, which both games depend on. It says so now, with the
conversion from `src/audio_tables.c:84`.
`TODO.md` gains the thirteen defects the two games turned up -- §6 items 30
to 33 and all of §9 -- each with a reduction that fits on a screen, the file
and line of the cause, and what a fix would touch.
Verified: `docs_examples` passes in both build configurations,
`docs_screenshots --check` re-renders all thirteen figures and byte-compares
them, the full 109-test suite passes in both builds, every quoted fragment
was checked to appear verbatim in the listing it came from, and every
relative link and anchor resolves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:54:42 -04:00
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.
Enter a TRAP handler when the variable table is full
Two separable faults, both on the path a program takes when it is already in
trouble.
`akbasic_trap_set_error_variables()` reached `ER#` and `EL#` through
`akbasic_runtime_global()`, which *creates* a name the program never used -- and
creating one takes a variable slot. So a program that had filled the 128-slot
table could not have its handler entered at all, and because the failure
happened inside the error path rather than raising, nothing was reported and the
program carried on with the failing statement's effect quietly missing. A wrong
answer delivered as a right one, which is worse than an abort.
`akbasic_runtime_reserve_globals()` now creates both at runtime init, where
there is always room, and `clear_variables()` puts them back after `CLR` and
`NEW` empty the table.
Second: `report_and_reraise()` used a plain `PASS` around the report, so a
failure while reporting *replaced* the error the program had actually made --
"Maximum runtime variables reached" in place of the subscript that was out of
range. The secondary failure is now logged and the original is re-raised, which
is what a user needs to hear.
**The reduction in TODO.md no longer reproduces, and not because of this.** The
value-pool fix in the previous commit made a scalar free, so the pool can no
longer be emptied by creating names. The defect was still live through the
variable table: 124 names and a TRAP armed, and the handler was silently
skipped. That is what the new test in tests/trap_verbs.c pins, together with the
invariant -- both globals present before a program runs.
Verified by reverting the fix against the new tests: both fail, and the second
prints the log line the swallow used to eat, "could not report a BASIC error 515
(Out Of Bounds): Maximum runtime variables reached".
TODO.md section 6 item 33, struck.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:48:56 -04:00
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#`
Add two Breakout examples and the tutorials that build them
Two complete games in `examples/breakout/`, both 100% BASIC: `characters/`
draws its wall in the text grid with two `DATA` sprites for the ball and
paddle, and `sprites/` loads CC0 artwork and captures its whole screen with
`SSHAPE`/`SPRSAV`. They take opposite shapes for reasons that are entirely
this interpreter's, which is what the chapters are for.
`docs/17-tutorial-breakout.md` and `docs/18-tutorial-breakout-artwork.md`
build each one a step at a time, and end in a checklist of the rules a real
program runs into: create every name before the loop starts, write a text row
whole, loop with `GOTO` rather than `DO`, put the float on the left. Every
trap is a runnable block with its own output rather than a claim -- the value
pool dying at four thousand names, the skipped `BEGIN` block that breaks its
caller's `RETURN`, `SSHAPE` ignoring a subscript, `READ`'s single cursor.
Five figures, generated from the listings beside them by `docs_screenshots`,
and a `breakout_art` setup so the ones that load artwork load the example's
own. The character game's wall cannot be photographed -- the screenshot host
omits the text layer on purpose -- so it is shown as compared output instead.
`docs/07-sound.md` never said `SOUND`'s frequency is a SID register value
rather than hertz, which both games depend on. It says so now, with the
conversion from `src/audio_tables.c:84`.
`TODO.md` gains the thirteen defects the two games turned up -- §6 items 30
to 33 and all of §9 -- each with a reduction that fits on a screen, the file
and line of the cause, and what a fix would touch.
Verified: `docs_examples` passes in both build configurations,
`docs_screenshots --check` re-renders all thirteen figures and byte-compares
them, the full 109-test suite passes in both builds, every quoted fragment
was checked to appear verbatim in the listing it came from, and every
relative link and anchor resolves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:54:42 -04:00
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.
Rewrite the two game tutorials as instructions rather than commentary
Chapters 17 and 18 read as a code review of a finished listing: they explained
why each decision had been made, walked through the project's own history, and
led with what had once been broken. A reader who wanted to build the game got
the reasoning and had to reconstruct the program.
Both are now step-by-step. Each opens with a picture of the finished game and a
bullet list of the steps, each bullet is a section, and each section states its
goal, shows the code, and says how to check it. Chapter 17 is sixteen steps and
Chapter 18 is thirteen, and the last of each is the assembly: the order of the
file, the full declaration block, and the routines the earlier steps referred to.
Project history is gone -- it belongs in Chapter 14 and in git -- and where a
listing has to do something awkward, the tutorial shows how first and names the
`TODO.md` item that will make it unnecessary second.
**Three defects had no entry anywhere**, which the rewrite found by trying to
state each rule as a rule. §6 item 35: `a - b + c` computes `a - (b + c)`,
because `subtraction()` sits above `addition()` as its own precedence level and
the inner loop eats the `+`. Item 36: only one unparenthesised `AND` or `OR` is
matched, which is item 12's `if`-where-`while` on the one operator pair item 12
did not reach. Item 37: a `GOTO` out of a `FOR` or a `DO` leaks the loop's scope,
so a main loop written that way stops on the thirty-second lost life -- which is
why both games are built out of `LABEL` and `GOTO`, and it is a workaround rather
than a preference. Chapter 3 gains the identifier rule the third trial ran into:
there is no underscore in a name, and the error says `UNKNOWN TOKEN _`.
**`tools/screenshot.c` learned to draw the text layer**, behind a new `text=1`
fence attribute, because Chapter 17's game is characters in the grid and a figure
without that layer is two sprites on black. It opens the bundled font at the size
the standalone frontend uses, so a figure's cell size is the reader's cell size,
and it uses the akgl sink alone rather than a tee so the program's output lands in
the picture instead of on the stdout the caller reads to decide a figure failed.
Both new figures -- `breakout-game.png` and `breakout-game-artwork.png` -- are
generated from listings in the chapters like every other one.
Verified by handing each chapter, alone, to an agent on a much smaller model and
telling it to build the game from the tutorial text with the `examples/` tree off
limits. The first pass scored 3.5 and 3 out of 10 and named what was missing:
routines referred to but never shown, the third level layout, the sprite `DATA`,
the font table, edits to earlier routines that were never marked as edits. Those
are now in. The second pass built a 658-line Chapter 17 game that plays itself
for ninety seconds with the score at 1890 and no error line, and the third built
a 1053-line Chapter 18 game with 61 labels, no invented routines, no gaps found,
and forty seconds clean. 9/10 and 8/10.
One real bug in the new prose, caught in review: Chapter 18's `HITBAR` did not
set the `HIT#` that `BALLPADDLE` reads to decide whether the paddle already
caught the ball. Both suites green in both configurations, `docs_examples` and
`docs_screenshots --check` pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 08:08:23 -04:00
### 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.
File the four things this work deliberately left alone
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
2026-08-02 09:51:17 -04:00
### 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.
Say what was hit, which way is out, and how far
The narrowphase has been producing a contact since it went in, and the interpreter
was throwing it away. `RCOLLISION(n, f)` reports it: what was hit (a sprite or a
`SOLID` rectangle), which one, the contact normal, the penetration depth, the
contact point, and which axis to reverse.
**The normal points out of the other thing and toward this one**, so a program
moves along it by the depth and is exactly clear. That sign is the one assertion
in the new test that could not be caught any other way -- both parties of a
sprite-against-sprite hit get their own record, each pointing the way *that*
sprite has to move, and sharing one would tell both to go the same direction,
which is how two things end up stuck inside each other.
**Field 7 is the one that deletes the most BASIC.** It is the minimum translation
axis, computed from the normal in C, and it is there because doing it in BASIC
means comparing two floats -- which is exactly where this dialect's left-operand
rule catches people. `BALLBRICKS`/`TESTCELL` in the artwork breakout spend six
lines computing an overlap rectangle and comparing its width to its height to get
this number.
The record is **sticky and deepest-wins**: replaced whenever that sprite is in a
contact and otherwise left alone, so `BUMP` stays the event and this stays the
detail of it. Making it clear itself when nothing touches would break the pairing,
because `BUMP` accumulates across steps and a once-a-frame poll would find the
detail already gone. Reading `BUMP` clears both, so they cannot disagree.
Deliberately narrower than `akgl_Contact`: no actor pointers, because BASIC has no
actor; no tile fields, because there is no tilemap; no z, because every test is
planar; and **no `dt` and no `sensor`**, which libakgl documents as filled in by
the resolver. This interpreter never resolves anything, so those two come back
zero and mean nothing, and an always-zero field in a reference table is a lie.
Documented with the two caveats that matter: fields 2, 3 and 4 are floats and want
a `%` variable, and the contact *point* is exact only for two boxes -- libakgl's
solver returns a point on the portal it converged to, while the normal and depth
are exact for every pair.
Chapter 8's collision section stops claiming only type 1 exists, which has been
false since the previous commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 10:53:26 -04:00
38. **Finish moving `COLLISION` and `BUMP` onto the subsystem. ** **Half done. ** The contact
is no longer discarded: `RCOLLISION(n, f)` reports what was hit, the normal, the depth,
the contact point and the minimum translation axis, and
`tests/akgl_backends.c::test_contact_geometry` pins the normal's * sign * for both parties
of a sprite-against-sprite hit -- getting that backwards tells both to move the same way,
and nothing else would have noticed. What stands is the pairing and the threshold below. What landed is the narrowphase
File the four things this work deliberately left alone
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
2026-08-02 09:51:17 -04:00
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.
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
## 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.
Consume libakgl 0.3.0: every workaround deleted, two capabilities gained
0.3.0 closed all ten API gaps this port had filed. The four workarounds go
with them: the CMake block that declared libakgl's vendored dependencies by
hand, the akgl/actor.h include in three files, and the six vtable pointers
assigned by hand in two more, now akgl_render_bind2d().
Two gaps were capabilities rather than inconveniences, and both are now real:
The line editor takes the composed UTF-8 text the ring carries in preference
to the keycode, so shifted characters, keyboard layouts, compose keys and dead
keys all work. A double quote can be typed, which means a BASIC string literal
can be typed -- the sharp end of the old limitation. Letters are no longer
folded to upper case.
SOUND's dir/min/step reach akgl_audio_sweep instead of being refused. dir 3
sweeps once rather than oscillating and TODO.md section 5 says so. A backend
with no sweep still refuses the swept note and plays the held one.
The adaptors now carry an AKGL_VERSION_AT_LEAST(0, 3, 0) floor, verified by
temporarily demanding 0.4.0 and watching it fire.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 13:25:14 -04:00
**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.
Bump libakgl to 42b60f7: every gap this port filed is now closed
All four capability gaps filed against libakgl have landed upstream, so nothing
in this repository is waiting on that library any more.
text measurement -> akgl_text_measure, akgl_text_measure_wrapped
immediate drawing -> akgl_draw_point/_line/_rect/_filled_rect/_circle/
_flood_fill/_copy_region/_paste_region
audio -> akgl_audio_init/_tone/_envelope/_waveform/_volume/
_stop/_voice_active/_mix
console input -> akgl_controller_poll_key, akgl_controller_flush_keys
The signatures came back close to what was filed, and two details are worth
recording for whoever writes src/sink_akgl.c next. The draw calls take an
akgl_RenderBackend * as their first argument rather than reaching for a global
renderer, which fits goal 3's rule that the interpreter draws through whatever
renderer the host already initialized. And the audio API is a synthesised-voice
one -- a tone on a numbered voice plus a separate ADSR envelope -- which is the
shape PLAY and ENVELOPE actually need, rather than the sample playback
SDL3_mixer would have offered.
Checked rather than assumed: libakgl's status band is still 256-260, so the
coordinated range map in CLAUDE.md is unaffected and akbasic's 512-767 does not
move.
Documentation that described these as open is corrected in the same commit --
section 7's gap list, section 3's "known gap" note, the priority list, the
dependency table, and the README's unimplemented-verbs section all said blocked
and no longer are. Section 3 also gains the thing that replaced the gap: 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
akgl_text_measure_wrapped takes the same wraplength argument
akgl_text_rendertextat does so the measurement and the draw cannot disagree
about where a line breaks.
One caveat recorded rather than papered over: -DAKBASIC_WITH_AKGL=ON has never
been configured in this repository, because until now there was nothing to build
against. The akbasic_akgl target is unproven and needs libakgl's own submodules
present. Expect to fix something there on the first attempt.
Default build unaffected: ctest 61/61, doxygen exits 0, no warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:56:02 -04:00
| 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` |
Consume libakgl 0.3.0: every workaround deleted, two capabilities gained
0.3.0 closed all ten API gaps this port had filed. The four workarounds go
with them: the CMake block that declared libakgl's vendored dependencies by
hand, the akgl/actor.h include in three files, and the six vtable pointers
assigned by hand in two more, now akgl_render_bind2d().
Two gaps were capabilities rather than inconveniences, and both are now real:
The line editor takes the composed UTF-8 text the ring carries in preference
to the keycode, so shifted characters, keyboard layouts, compose keys and dead
keys all work. A double quote can be typed, which means a BASIC string literal
can be typed -- the sharp end of the old limitation. Letters are no longer
folded to upper case.
SOUND's dir/min/step reach akgl_audio_sweep instead of being refused. dir 3
sweeps once rather than oscillating and TODO.md section 5 says so. A backend
with no sweep still refuses the swept note and plays the held one.
The adaptors now carry an AKGL_VERSION_AT_LEAST(0, 3, 0) floor, verified by
temporarily demanding 0.4.0 and watching it fire.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 13:25:14 -04:00
| 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.
Bump libakgl to 42b60f7: every gap this port filed is now closed
All four capability gaps filed against libakgl have landed upstream, so nothing
in this repository is waiting on that library any more.
text measurement -> akgl_text_measure, akgl_text_measure_wrapped
immediate drawing -> akgl_draw_point/_line/_rect/_filled_rect/_circle/
_flood_fill/_copy_region/_paste_region
audio -> akgl_audio_init/_tone/_envelope/_waveform/_volume/
_stop/_voice_active/_mix
console input -> akgl_controller_poll_key, akgl_controller_flush_keys
The signatures came back close to what was filed, and two details are worth
recording for whoever writes src/sink_akgl.c next. The draw calls take an
akgl_RenderBackend * as their first argument rather than reaching for a global
renderer, which fits goal 3's rule that the interpreter draws through whatever
renderer the host already initialized. And the audio API is a synthesised-voice
one -- a tone on a numbered voice plus a separate ADSR envelope -- which is the
shape PLAY and ENVELOPE actually need, rather than the sample playback
SDL3_mixer would have offered.
Checked rather than assumed: libakgl's status band is still 256-260, so the
coordinated range map in CLAUDE.md is unaffected and akbasic's 512-767 does not
move.
Documentation that described these as open is corrected in the same commit --
section 7's gap list, section 3's "known gap" note, the priority list, the
dependency table, and the README's unimplemented-verbs section all said blocked
and no longer are. Section 3 also gains the thing that replaced the gap: 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
akgl_text_measure_wrapped takes the same wraplength argument
akgl_text_rendertextat does so the measurement and the draw cannot disagree
about where a line breaks.
One caveat recorded rather than papered over: -DAKBASIC_WITH_AKGL=ON has never
been configured in this repository, because until now there was nothing to build
against. The akbasic_akgl target is unproven and needs libakgl's own submodules
present. Expect to fix something there on the first attempt.
Default build unaffected: ctest 61/61, doxygen exits 0, no warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:56:02 -04:00
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.
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
---
## 8. Status
2026-07-31 12:52:00 -04:00
**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
2026-08-01 16:46:44 -04:00
requirement; two cases have diverged on purpose since, and
`tests/reference/README.md` lists them.
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
| Gate | Result |
|---|---|
2026-08-01 16:46:44 -04:00
| `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 |
Generate the documentation's figures from the listings they illustrate
Chapters 6 and 8 described what a verb draws in prose. Eight figures now show
it, and each one is produced by running the BASIC listing printed immediately
above it -- so a picture cannot drift away from the code beside it, which is the
way a screenshot goes wrong and the way nothing notices.
tools/screenshot.c is a second SDL host, much smaller than the frontend: dummy
video driver, software renderer, run to completion, read the target back, write
a PNG. It draws no text layer on purpose, so a READY in the corner is not noise
in a figure about BOX and no font has to be resolved.
tools/docs_screenshots.sh reads the new screenshot=NAME fence tag straight out
of the markdown. size=WxH is the second tag, and SCALE's figure uses it: the
point being made is a 320x200 listing filling a larger window, which cannot be
made on a 320x200 surface.
Two gates, answering different questions. docs_examples fails a tagged block
with no 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. Only the second catches a stale picture.
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. Regenerating is never
part of a build: the target is run deliberately, so a make cannot put eight
binary diffs in front of whoever ran it.
Drawing the BOX figure caught a defect in TODO.md itself. Deviation 16 claimed
in bold that BOX fills on a negative angle while its own paragraph said the fill
was filed rather than implemented. BOX cannot fill, and filled_rect is reached
by no verb as a result.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:37:04 -04:00
| `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 |
2026-07-31 12:35:40 -04:00
| 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 |
2026-08-01 16:46:44 -04:00
| ASan + UBSan | 109/109 |
| Line coverage | 94.9% (6767/7130) — above the 90% gate |
| Function coverage | 98.5% (447/454) |
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
| Warnings | none under `-Wall -Wextra` |
2026-07-31 08:51:27 -04:00
| `doxygen Doxyfile` | clean |
Delete src/convert.c: libakstdlib 0.2.0 grew the contract it existed for
That file wrapped strtoll and strtod because libakstdlib's aksl_ato* family
could not report a conversion failure at all -- atoi("not a number") returned
success with 0, turning four diagnosable errors into wrong answers. Its own note
said what to do when that changed: "When it grows it, delete src/convert.c and
switch the call sites over." 0.2.0 grew it, so it is gone.
Six call sites go straight to the library now: both literal constructors 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. aksl_strtoll(str, NULL, base, &dest) rather than
aksl_atoll wherever a base is involved, because the ato* forms are base 10 and
grammar.c picks base 8 or 16 off the lexeme's prefix; the NULL endptr is what
makes trailing junk an error rather than a stopping point.
The raised status changed from AKBASIC_ERR_VALUE to AKERR_VALUE, and the message
with it -- VAL("garbage") now says `no digits in "garbage"`. Section 1.8 makes
message text part of the acceptance contract, so that was checked against the
corpus before touching anything: no golden file contained a conversion message,
which is also why section 1.9 had been asking for one. Two exist now, so the next
change to that text has to move a golden file, and one of them pins the
reference's octal-literal defect (section 6 item 10) while it is still
deliberately reproduced.
tests/convert.c became tests/numeric_contract.c rather than being deleted with
the code. The assertions did not stop being worth making when the wrapper went
away -- they became assertions about a contract this port depends on and no
longer owns, and a regression in it would make four things quietly return zero.
Repoints the CI mutation job, which was bounded to src/convert.c and
src/symtab.c. src/symtab.c alone measures 74.1% against the gate of 65.
src/audio_tables.c was measured as a replacement second file and scored 64.7%:
that is a real gap rather than a reason to skip it, since almost every survivor
is in akbasic_audio_state_init, where nothing asserts a freshly initialised audio
state is actually zeroed -- the same gap this job's history records closing for
symtab. Recorded in TODO.md so the file can earn its place back.
One thing worth knowing before reading any score here, and now written down: the
harness's ICR operator only rewrites the constants 0 and 1, so a lookup table of
other values produces no mutants and a high score over one says nothing about
whether its entries are right. tests/audio_verbs.c now asserts all 32 ADSR
entries against the datasheet anyway, because a hand-edit typo is the real
failure mode there -- but that could not and did not move the mutation number,
and claiming otherwise would be the kind of thing this file exists to stop.
72/72 core, 73/73 with libakgl, 72/72 under ASan+UBSan, coverage 93.6% line and
97.8% function, clean under -Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:28:04 -04:00
| Mutation (`src/symtab.c` ) | 74.1%, against a gate of 65 — see `.gitea/workflows/ci.yaml` |
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
Execute every documented example as a test
docs/ and README.md carry 85 fenced blocks. Every one was checked by hand
exactly once, when it was written, which is not a standard that survives a
changing interpreter -- and four were already wrong: two transcripts showing a
leading space PRINT does not emit, akbasic_TextSink in README.md missing the
two members it had grown hours earlier, and FILTER's refusal quoted with
wording the code does not use.
tests/docs_examples.sh reads a fence-tag vocabulary and runs what it finds.
BASIC programs and transcripts run and are byte-compared against an `output`
block; C snippets compile with -fsyntax-only against the real include path,
which CMake writes out because it is transitive through akerror, akstdlib and
akgl; shell blocks run in a sandbox. Anything that would reconfigure the build
tree, hit the network or re-enter the suite is tagged norun with the reason in
MAINTENANCE.md, and the two cmake blocks stay hand-maintained by decision.
An untagged block is a failure rather than a default, and the pass line
reports what it executed by kind. Both exist because the way a harness like
this dies is by quietly matching nothing and passing -- which it duly did on
the first CTest run, where a generator expression evaluating to nothing still
contributed an empty argument that the script read as a filename. The count is
what caught it.
The excerpt check earns its own mention: a block tagged
`c excerpt=include/akbasic/sink.h` must still appear in that header, comments
and whitespace ignored. Compiling it would only redefine the type, so a
compile check could not have found the stale struct, and did not.
Registered as the CTest case docs_examples in both configurations. Fixing the
four wrong examples turned up two interpreter defects, fixed in the previous
commit and recorded in TODO.md section 8.
MAINTENANCE.md is new: the fence-tag reference, what to do when the case
fails, and the conventions that until now only existed inside source comments
-- the three test lists and how two of them invert "passed", the sorted verb
table, that a golden file is never edited to suit this interpreter, and that a
fix gets mutation-checked with a file copy rather than git checkout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:41:36 -04:00
**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` .
2026-08-01 07:36:00 -04:00
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
Execute every documented example as a test
docs/ and README.md carry 85 fenced blocks. Every one was checked by hand
exactly once, when it was written, which is not a standard that survives a
changing interpreter -- and four were already wrong: two transcripts showing a
leading space PRINT does not emit, akbasic_TextSink in README.md missing the
two members it had grown hours earlier, and FILTER's refusal quoted with
wording the code does not use.
tests/docs_examples.sh reads a fence-tag vocabulary and runs what it finds.
BASIC programs and transcripts run and are byte-compared against an `output`
block; C snippets compile with -fsyntax-only against the real include path,
which CMake writes out because it is transitive through akerror, akstdlib and
akgl; shell blocks run in a sandbox. Anything that would reconfigure the build
tree, hit the network or re-enter the suite is tagged norun with the reason in
MAINTENANCE.md, and the two cmake blocks stay hand-maintained by decision.
An untagged block is a failure rather than a default, and the pass line
reports what it executed by kind. Both exist because the way a harness like
this dies is by quietly matching nothing and passing -- which it duly did on
the first CTest run, where a generator expression evaluating to nothing still
contributed an empty argument that the script read as a filename. The count is
what caught it.
The excerpt check earns its own mention: a block tagged
`c excerpt=include/akbasic/sink.h` must still appear in that header, comments
and whitespace ignored. Compiling it would only redefine the type, so a
compile check could not have found the stale struct, and did not.
Registered as the CTest case docs_examples in both configurations. Fixing the
four wrong examples turned up two interpreter defects, fixed in the previous
commit and recorded in TODO.md section 8.
MAINTENANCE.md is new: the fence-tag reference, what to do when the case
fails, and the conventions that until now only existed inside source comments
-- the three test lists and how two of them invert "passed", the sorted verb
table, that a golden file is never edited to suit this interpreter, and that a
fix gets mutation-checked with a file copy rather than git checkout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:41:36 -04:00
running it — which is the argument for the harness rather than a footnote to it.
2026-08-01 07:36:00 -04:00
**A fourth is open, and it is the same defect as item 2 on a path that fix did not cover.**
Document the interpreter's architecture as chapter fourteen
Chapters 1 through 13 describe the language. Nothing described the machine
that runs it, and the answers were spread across header comments, TODO.md
sections written for a different purpose, and the source itself. Somebody
embedding the interpreter, debugging something it did, or adding a verb had
to reconstruct the shape from all three.
docs/14-architecture.md is that shape, and only that: the three targets and
the driver, the single akbasic_Runtime and why nothing is file-scope,
akbasic_runtime_step() unrolled with the reason each stage sits where it
does, the four modes and what set_mode() does beyond assigning, a line's
journey from text through tokens and leaves to a verb handler, the dispatch
table, the pool map with what each exhaustion actually says, environments
doubling as block state, the two kinds of error, devices, and interrupts.
It defers rather than restates. The headers are the authority on every
function's contract and the chapter says so up front; where a rule is subtle
the header comment already states it at more length than a chapter should.
MAINTENANCE.md keeps the conventions and now points here for the mechanism,
so there is still one copy of each.
Two sections are the reason it exists at all. Debugging: reading a TRON trace
as evidence about the loop rather than the lines, reading an akerror stack
trace and what it is not, four breakpoints and the expressions worth printing
at them, narrowing with ctest -R and the mock devices, and a symptom-to-cause
table. Changing it: the verb recipe end to end including the private
src/verbs.h prototype that is easy to miss, the rule that a missing
dependency capability gets filed upstream rather than worked around, and the
five constraints goal 3 puts on any change.
A `text` fence tag comes with it. Every fenced block in docs/ is executed and
an untagged one is a hard error, so six block diagrams had nowhere to live.
The tag means never executed, it is counted in the skip line like `cmake`,
and MAINTENANCE.md documents it -- the alternative was an indented block the
extractor never sees, and a picture nobody decided about is indistinguishable
from a test nobody ran.
tests/docs_examples.sh now makes --root and --basic absolute before it
starts. Both are used from inside a sandbox directory it cd's into, so the
invocation MAINTENANCE.md itself documents -- --root . --basic ./build/basic
-- failed every example with "exited 127" and every setup= with "setup
failed". CTest passes absolute paths and never saw it; running one document
by hand hits it immediately.
Writing the error section turned up a defect and TODO.md section 8 records
it. The ATTEMPT blocks that turn a script's mistake into an error line wrap
parsing and interpretation but not scanning, so a line with more than 32
tokens escapes as an interpreter error: stack trace, exit 1, and at a prompt
the REPL is gone. That is the same shape as section 8 item 2, on a path that
fix did not cover. Not fixed here -- it is a behaviour change and wants its
own tests -- but written down with the three call sites and what would cover
them.
Both configurations stay green: 95/95 and 94/94. docs_examples now runs 37
programs, 9 transcripts, 45 output comparisons, 3 C snippets, 2 excerpts and
2 shell blocks, and skips 9 text blocks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:14:17 -04:00
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.
2026-08-01 16:25:05 -04:00
**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.
2026-07-31 08:51:27 -04:00
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.
Generate the documentation's figures from the listings they illustrate
Chapters 6 and 8 described what a verb draws in prose. Eight figures now show
it, and each one is produced by running the BASIC listing printed immediately
above it -- so a picture cannot drift away from the code beside it, which is the
way a screenshot goes wrong and the way nothing notices.
tools/screenshot.c is a second SDL host, much smaller than the frontend: dummy
video driver, software renderer, run to completion, read the target back, write
a PNG. It draws no text layer on purpose, so a READY in the corner is not noise
in a figure about BOX and no font has to be resolved.
tools/docs_screenshots.sh reads the new screenshot=NAME fence tag straight out
of the markdown. size=WxH is the second tag, and SCALE's figure uses it: the
point being made is a 320x200 listing filling a larger window, which cannot be
made on a 320x200 surface.
Two gates, answering different questions. docs_examples fails a tagged block
with no 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. Only the second catches a stale picture.
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. Regenerating is never
part of a build: the target is run deliberately, so a make cannot put eight
binary diffs in front of whoever ran it.
Drawing the BOX figure caught a defect in TODO.md itself. Deviation 16 claimed
in bold that BOX fills on a negative angle while its own paragraph said the fill
was filed rather than implemented. BOX cannot fill, and filled_rect is reached
by no verb as a result.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:37:04 -04:00
**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.
2026-07-31 08:51:27 -04:00
**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
2026-07-31 12:09:02 -04:00
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
2026-07-31 08:51:27 -04:00
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
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
`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 |
|---|---|---|
Take libakerror 2.0.1, and guard the exit status it fixes
2.0.0 makes the error pool and the status registry thread safe, and it is an ABI
break carrying the soname to libakerror.so.2. The break is a quiet one:
__akerr_last_ignored became thread-local and akerr_next_error() now returns a
context that already holds a reference, so objects compiled against a 1.x header
and linked against 2.x count every reference twice and never give a slot back.
Nothing about that fails to link, which is exactly what a guard is for --
include/akbasic/error.h feature-tests AKERR_THREAD_SAFE instead of
AKERR_FIRST_CONSUMER_STATUS, which 2.0.0 also still defines and which therefore
no longer distinguishes anything.
2.0.1 is the release this band needed most. The default unhandled-error handler
ended in exit(errctx->status), and a process exit status is one byte:
AKBASIC_ERR_BASE is 512, and 512 truncates to 0, so an unhandled
AKBASIC_ERR_SYNTAX reported success to anything watching $?. Every other code in
the band came out as some unrelated error's number. akerr_exit() substitutes 125
for anything a byte cannot carry, and a probe raising AKBASIC_ERR_DEVICE through
FINISH_NORETURN now exits 125 rather than 7.
It was latent here rather than live -- src/main.c handles the context and returns
EXIT_FAILURE, and every test with a top-level ATTEMPT carries a HANDLE_DEFAULT --
but "no caller relies on it today" is not a property a header can keep true.
tests/version_check.c asserts the mapping and fails if AKBASIC_ERR_BASE ever
stops truncating to zero, because that is the day this stops being about our base.
Chapter 10 gains a threading section: libakerror is safe from any thread now, and
this interpreter is not and has no lock anywhere in it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 08:00:24 -04:00
| `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. |
Consume libakgl 0.2.0 and libakstdlib 0.2.0
Rebasing the defect filing onto libakgl's origin/main brought two upstream
commits with it -- "Consume libakstdlib 0.2.0" and "Bump libakgl to 0.2.0" --
and libakgl's sources now use the 0.2.0 akstdlib API. Our CMakeLists declares
akstdlib::akstdlib from our own tree first, so libakgl was compiling 0.2.0-era
code against 0.1.0 headers and failing on aksl_fwrite, aksl_fread and
aksl_realpath. The two have to move together.
The code change is one call site. aksl_fwrite now takes a required size_t
*nmemb_out and reports a short transfer as AKERR_IO rather than a silent
success, so DSAVE onto a full disk is an error a program sees where before it
reported nothing. The count is passed and discarded on purpose: the library does
the noticing now.
The documentation change is larger, because libakstdlib 0.2.0 fixed all six of
the confirmed defects TODO.md section 1.9 was built around. That section was a
table of bans; leaving it would send an agent around a workaround for functions
that now work. The aksl_ato* family raises AKERR_VALUE on no digits or trailing
junk and ERANGE on overflow -- exactly the contract that section demanded --
aksl_list_append no longer truncates, aksl_list_iterate no longer skips the
first half, AKERR_ITERATOR_BREAK stops a tree traversal, and aksl_realpath no
longer reads uninitialised memory. One caveat survives: aksl_strhash_djb2 still
sign-extends char, which section 1.3 already covers and the symbol tables still
cannot reach.
src/convert.c has therefore outlived its reason, and is deliberately left in
place. Its own note said to delete it when libakstdlib grew the contract, and
that condition is now met -- but doing it touches four call sites, changes the
raised status from AKBASIC_ERR_VALUE to AKERR_VALUE where section 1.8 says
message text is part of the acceptance contract, and would silently gut the CI
mutation job, which is bounded to src/convert.c and src/symtab.c. Section 1.9
now lists all of that. Worth doing on purpose rather than as a side effect of a
version bump.
libakgl defect #14 is closed by its own 1066ac7, which bumped it to 0.2.0 while
this was being written. The requirement is no longer pinned by submodule commit
in the README, because AKGL_VERSION_AT_LEAST(0, 2, 0) finally means something.
70/70 core, 71/71 with libakgl, 70/70 under ASan+UBSan, clean under
-Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:53:41 -04:00
| `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. |
Take libakgl 0.7.0, and refuse an over-long type or field name
0.6.0 and 0.7.0 break 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 so it
can drop the exit-status trap its own suites needed. That is the same defect
include/akbasic/error.h guards for this band, now fixed at the source rather
than worked around in two places.
The floor moves to 0.7.0 anyway. The soname carries MAJOR.MINOR while the major
is 0, so deciding for ourselves which of libakgl's minor releases were really
compatible is exactly the judgement it exists to take away.
0.7.0 is largely about a defect class worth checking for here rather than
assuming past: ten unterminated strncpy calls into fixed-width name fields. Ours
are clean -- every one is bounded to size - 1 and every buffer is either
explicitly terminated afterwards or memset first, checked site by site. But two
of them, both written last week in src/structtype.c, truncated an over-long name
silently, and truncation is an error everywhere else in this interpreter. It
matters more for a name than for a value: two long type names trimmed to the
same 31 characters collide, and the second is then reported as redeclaring a
type the program never wrote.
Both are refused now, showing the name as written. The check was unreachable as
first drafted, because the prescan read words into a buffer the size of the
limit and so trimmed them before anything could look -- the scratch is twice the
limit now, which is what makes the two cases distinguishable at all.
tests/struct_types.c pins that it stays reachable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:30:35 -04:00
| `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. |
Consume libakgl 0.2.0 and libakstdlib 0.2.0
Rebasing the defect filing onto libakgl's origin/main brought two upstream
commits with it -- "Consume libakstdlib 0.2.0" and "Bump libakgl to 0.2.0" --
and libakgl's sources now use the 0.2.0 akstdlib API. Our CMakeLists declares
akstdlib::akstdlib from our own tree first, so libakgl was compiling 0.2.0-era
code against 0.1.0 headers and failing on aksl_fwrite, aksl_fread and
aksl_realpath. The two have to move together.
The code change is one call site. aksl_fwrite now takes a required size_t
*nmemb_out and reports a short transfer as AKERR_IO rather than a silent
success, so DSAVE onto a full disk is an error a program sees where before it
reported nothing. The count is passed and discarded on purpose: the library does
the noticing now.
The documentation change is larger, because libakstdlib 0.2.0 fixed all six of
the confirmed defects TODO.md section 1.9 was built around. That section was a
table of bans; leaving it would send an agent around a workaround for functions
that now work. The aksl_ato* family raises AKERR_VALUE on no digits or trailing
junk and ERANGE on overflow -- exactly the contract that section demanded --
aksl_list_append no longer truncates, aksl_list_iterate no longer skips the
first half, AKERR_ITERATOR_BREAK stops a tree traversal, and aksl_realpath no
longer reads uninitialised memory. One caveat survives: aksl_strhash_djb2 still
sign-extends char, which section 1.3 already covers and the symbol tables still
cannot reach.
src/convert.c has therefore outlived its reason, and is deliberately left in
place. Its own note said to delete it when libakstdlib grew the contract, and
that condition is now met -- but doing it touches four call sites, changes the
raised status from AKBASIC_ERR_VALUE to AKERR_VALUE where section 1.8 says
message text is part of the acceptance contract, and would silently gut the CI
mutation job, which is bounded to src/convert.c and src/symtab.c. Section 1.9
now lists all of that. Worth doing on purpose rather than as a side effect of a
version bump.
libakgl defect #14 is closed by its own 1066ac7, which bumped it to 0.2.0 while
this was being written. The requirement is no longer pinned by submodule commit
in the README, because AKGL_VERSION_AT_LEAST(0, 2, 0) finally means something.
70/70 core, 71/71 with libakgl, 70/70 under ASan+UBSan, clean under
-Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:53:41 -04:00
Take libakerror 2.0.1, and guard the exit status it fixes
2.0.0 makes the error pool and the status registry thread safe, and it is an ABI
break carrying the soname to libakerror.so.2. The break is a quiet one:
__akerr_last_ignored became thread-local and akerr_next_error() now returns a
context that already holds a reference, so objects compiled against a 1.x header
and linked against 2.x count every reference twice and never give a slot back.
Nothing about that fails to link, which is exactly what a guard is for --
include/akbasic/error.h feature-tests AKERR_THREAD_SAFE instead of
AKERR_FIRST_CONSUMER_STATUS, which 2.0.0 also still defines and which therefore
no longer distinguishes anything.
2.0.1 is the release this band needed most. The default unhandled-error handler
ended in exit(errctx->status), and a process exit status is one byte:
AKBASIC_ERR_BASE is 512, and 512 truncates to 0, so an unhandled
AKBASIC_ERR_SYNTAX reported success to anything watching $?. Every other code in
the band came out as some unrelated error's number. akerr_exit() substitutes 125
for anything a byte cannot carry, and a probe raising AKBASIC_ERR_DEVICE through
FINISH_NORETURN now exits 125 rather than 7.
It was latent here rather than live -- src/main.c handles the context and returns
EXIT_FAILURE, and every test with a top-level ATTEMPT carries a HANDLE_DEFAULT --
but "no caller relies on it today" is not a property a header can keep true.
tests/version_check.c asserts the mapping and fails if AKBASIC_ERR_BASE ever
stops truncating to zero, because that is the day this stops being about our base.
Chapter 10 gains a threading section: libakerror is safe from any thread now, and
this interpreter is not and has no lock anywhere in it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 08:00:24 -04:00
**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.
Consume libakgl 0.2.0 and libakstdlib 0.2.0
Rebasing the defect filing onto libakgl's origin/main brought two upstream
commits with it -- "Consume libakstdlib 0.2.0" and "Bump libakgl to 0.2.0" --
and libakgl's sources now use the 0.2.0 akstdlib API. Our CMakeLists declares
akstdlib::akstdlib from our own tree first, so libakgl was compiling 0.2.0-era
code against 0.1.0 headers and failing on aksl_fwrite, aksl_fread and
aksl_realpath. The two have to move together.
The code change is one call site. aksl_fwrite now takes a required size_t
*nmemb_out and reports a short transfer as AKERR_IO rather than a silent
success, so DSAVE onto a full disk is an error a program sees where before it
reported nothing. The count is passed and discarded on purpose: the library does
the noticing now.
The documentation change is larger, because libakstdlib 0.2.0 fixed all six of
the confirmed defects TODO.md section 1.9 was built around. That section was a
table of bans; leaving it would send an agent around a workaround for functions
that now work. The aksl_ato* family raises AKERR_VALUE on no digits or trailing
junk and ERANGE on overflow -- exactly the contract that section demanded --
aksl_list_append no longer truncates, aksl_list_iterate no longer skips the
first half, AKERR_ITERATOR_BREAK stops a tree traversal, and aksl_realpath no
longer reads uninitialised memory. One caveat survives: aksl_strhash_djb2 still
sign-extends char, which section 1.3 already covers and the symbol tables still
cannot reach.
src/convert.c has therefore outlived its reason, and is deliberately left in
place. Its own note said to delete it when libakstdlib grew the contract, and
that condition is now met -- but doing it touches four call sites, changes the
raised status from AKBASIC_ERR_VALUE to AKERR_VALUE where section 1.8 says
message text is part of the acceptance contract, and would silently gut the CI
mutation job, which is bounded to src/convert.c and src/symtab.c. Section 1.9
now lists all of that. Worth doing on purpose rather than as a side effect of a
version bump.
libakgl defect #14 is closed by its own 1066ac7, which bumped it to 0.2.0 while
this was being written. The requirement is no longer pinned by submodule commit
in the README, because AKGL_VERSION_AT_LEAST(0, 2, 0) finally means something.
70/70 core, 71/71 with libakgl, 70/70 under ASan+UBSan, clean under
-Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:53:41 -04:00
**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.
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
Add mutation testing, and split the release gates into their own workflow
Ports libakstdlib's mutation harness (the newest of the three) to scripts/
mutation_test.py, adds the namespaced `mutation` CMake target the sibling
libraries have, and splits CI in two.
.gitea/workflows/ci.yaml keeps the push path: suite, ASan+UBSan, coverage, and a
mutation run bounded to src/convert.c and src/symtab.c -- about four minutes,
scoring 77.8% against a gate of 65.
.gitea/workflows/release.yaml is new and manual (workflow_dispatch). It carries
the doxygen gate, moved out of ci.yaml, and a whole-tree mutation run: 3675
mutants and hours of runner time, which is a release cost rather than a
per-commit one. Both artifacts a release wants -- api-documentation and
mutation-report -- come out of it. Two optional inputs narrow the run or change
the threshold; they arrive through the environment rather than being
interpolated into the shell, because ${{ }} substitution happens before the
shell sees the line.
The harness paid for itself immediately, which is the point of it. Three real
gaps, each checked to be a genuine bug rather than an equivalent mutant:
- errno was never asserted clear before a strtoll. Confirmed with a standalone
probe that strtoll leaves a stale errno untouched on success, so without the
`errno = 0` a valid conversion raises ERANGE.
- Nothing exercised a maximum-length symbol-table key, so every MAX_KEY - 1
off-by-one in a strncpy and its NUL terminator survived. The same hole exists
for strings in src/value.c and is filed.
- Nothing asserted a freshly initialised table was actually zeroed.
Closing the first two took the measured score from 73.1% to 77.8%.
I set the push-path threshold to 75 first, on an estimate. Measuring gave 73.1%
and the job would have failed on its first run -- the earlier per-file figure was
too high because the captured output had been truncated to its last lines and I
counted fewer survivors than there were. It is 65 now, and the gate was run as
written and confirmed to exit 0.
src/value.c is the file most worth mutating and is deliberately off the push
path: 368 mutants at ~11s each is about 70 minutes, because almost everything
links against it. A partial run over it found the same maximum-length-string
hole plus two genuinely equivalent mutants that only exist because of reference
defect section 6 item 5 -- adding both of the right operand's numeric fields
works only while the unused one is zero, so + and - are interchangeable there.
Recorded in TODO.md.
ctest 61/61; doxygen exits 0; both workflows' steps were executed locally, both
input paths included.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:46:53 -04:00
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:
Add a Gitea workflow
Fills the gap TODO.md recorded last commit: the sibling libraries all carry
.gitea/workflows/ci.yaml and this one did not, so every gate was run by hand.
Four jobs, in the shape libakerror and libakstdlib use, down to the JUnit
reporting and the annotate_only note about Gitea 404ing on the Checks API.
cmake_build configures, builds and runs the 61-case suite with JUnit output.
docs runs `doxygen Doxyfile` -- a real gate, since WARN_AS_ERROR is set -- and
uploads build/docs/html as the api-documentation artifact. sanitizers runs the
whole suite under ASan and UBSan, which libakstdlib's TODO.md calls its own
highest-value missing item and which matters here because this library is all
fixed pools and manual buffer arithmetic. coverage gates at 90% of lines against
an actual 92.3% and uploads the html report.
Two things worth knowing, both checked rather than assumed.
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 would clone SDL,
SDL_image, SDL_mixer, SDL_ttf and jansson for a target that is never configured.
The nested submodules are uninitialized in this working tree today and the build
is green, which is the proof. The docs job takes no submodules at all -- verified
by running doxygen against a tree with deps/ absent.
There is no branch-coverage gate. Branch coverage reads 17.3% and is not a
meaningful number here, for the reason libakgl's and libakstdlib's TODO.md both
record: the akerror control-flow macros expand into large branch trees at every
call site, most unreachable in normal operation. Gating on it would mean writing
tests for libakerror's macros.
All four jobs were executed locally end to end before committing, not just
eyeballed: 61/61 plain, 61/61 under ASan+UBSan, doxygen exit 0 producing 441
files, and gcovr exit 0 at the 90 gate. The gate was also confirmed to bite by
raising it to 99.
Not added: a mutation-testing job, which all three siblings have. There is no
scripts/mutation_test.py in this repository to run, and porting libakerror's is
its own piece of work -- filed in TODO.md, where it matters more than usual
because the akerror macros expand at their call sites and coverage cannot see
them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:09:38 -04:00
- 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.
Delete src/convert.c: libakstdlib 0.2.0 grew the contract it existed for
That file wrapped strtoll and strtod because libakstdlib's aksl_ato* family
could not report a conversion failure at all -- atoi("not a number") returned
success with 0, turning four diagnosable errors into wrong answers. Its own note
said what to do when that changed: "When it grows it, delete src/convert.c and
switch the call sites over." 0.2.0 grew it, so it is gone.
Six call sites go straight to the library now: both literal constructors 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. aksl_strtoll(str, NULL, base, &dest) rather than
aksl_atoll wherever a base is involved, because the ato* forms are base 10 and
grammar.c picks base 8 or 16 off the lexeme's prefix; the NULL endptr is what
makes trailing junk an error rather than a stopping point.
The raised status changed from AKBASIC_ERR_VALUE to AKERR_VALUE, and the message
with it -- VAL("garbage") now says `no digits in "garbage"`. Section 1.8 makes
message text part of the acceptance contract, so that was checked against the
corpus before touching anything: no golden file contained a conversion message,
which is also why section 1.9 had been asking for one. Two exist now, so the next
change to that text has to move a golden file, and one of them pins the
reference's octal-literal defect (section 6 item 10) while it is still
deliberately reproduced.
tests/convert.c became tests/numeric_contract.c rather than being deleted with
the code. The assertions did not stop being worth making when the wrapper went
away -- they became assertions about a contract this port depends on and no
longer owns, and a regression in it would make four things quietly return zero.
Repoints the CI mutation job, which was bounded to src/convert.c and
src/symtab.c. src/symtab.c alone measures 74.1% against the gate of 65.
src/audio_tables.c was measured as a replacement second file and scored 64.7%:
that is a real gap rather than a reason to skip it, since almost every survivor
is in akbasic_audio_state_init, where nothing asserts a freshly initialised audio
state is actually zeroed -- the same gap this job's history records closing for
symtab. Recorded in TODO.md so the file can earn its place back.
One thing worth knowing before reading any score here, and now written down: the
harness's ICR operator only rewrites the constants 0 and 1, so a lookup table of
other values produces no mutants and a high score over one says nothing about
whether its entries are right. tests/audio_verbs.c now asserts all 32 ADSR
entries against the datasheet anyway, because a hand-edit typo is the real
failure mode there -- but that could not and did not move the mutation number,
and claiming otherwise would be the kind of thing this file exists to stop.
72/72 core, 73/73 with libakgl, 72/72 under ASan+UBSan, coverage 93.6% line and
97.8% function, clean under -Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:28:04 -04:00
- 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
Add mutation testing, and split the release gates into their own workflow
Ports libakstdlib's mutation harness (the newest of the three) to scripts/
mutation_test.py, adds the namespaced `mutation` CMake target the sibling
libraries have, and splits CI in two.
.gitea/workflows/ci.yaml keeps the push path: suite, ASan+UBSan, coverage, and a
mutation run bounded to src/convert.c and src/symtab.c -- about four minutes,
scoring 77.8% against a gate of 65.
.gitea/workflows/release.yaml is new and manual (workflow_dispatch). It carries
the doxygen gate, moved out of ci.yaml, and a whole-tree mutation run: 3675
mutants and hours of runner time, which is a release cost rather than a
per-commit one. Both artifacts a release wants -- api-documentation and
mutation-report -- come out of it. Two optional inputs narrow the run or change
the threshold; they arrive through the environment rather than being
interpolated into the shell, because ${{ }} substitution happens before the
shell sees the line.
The harness paid for itself immediately, which is the point of it. Three real
gaps, each checked to be a genuine bug rather than an equivalent mutant:
- errno was never asserted clear before a strtoll. Confirmed with a standalone
probe that strtoll leaves a stale errno untouched on success, so without the
`errno = 0` a valid conversion raises ERANGE.
- Nothing exercised a maximum-length symbol-table key, so every MAX_KEY - 1
off-by-one in a strncpy and its NUL terminator survived. The same hole exists
for strings in src/value.c and is filed.
- Nothing asserted a freshly initialised table was actually zeroed.
Closing the first two took the measured score from 73.1% to 77.8%.
I set the push-path threshold to 75 first, on an estimate. Measuring gave 73.1%
and the job would have failed on its first run -- the earlier per-file figure was
too high because the captured output had been truncated to its last lines and I
counted fewer survivors than there were. It is 65 now, and the gate was run as
written and confirmed to exit 0.
src/value.c is the file most worth mutating and is deliberately off the push
path: 368 mutants at ~11s each is about 70 minutes, because almost everything
links against it. A partial run over it found the same maximum-length-string
hole plus two genuinely equivalent mutants that only exist because of reference
defect section 6 item 5 -- adding both of the right operand's numeric fields
works only while the unused one is zero, so + and - are interchangeable there.
Recorded in TODO.md.
ctest 61/61; doxygen exits 0; both workflows' steps were executed locally, both
input paths included.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:46:53 -04:00
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.
Document the public API in libakgl's Doxygen style
Adds a Doxyfile in the shape libakgl uses -- fifteen deliberate lines, including
WARN_IF_UNDOCUMENTED and WARN_AS_ERROR=FAIL_ON_WARNINGS -- and fills in the 70
public declarations that had no doc block, bringing include/akbasic to 114 of
114.
libakgl is the model rather than libakstdlib. Measured before starting: libakgl
documents 122 of 122 public declarations and libakstdlib 3 of 25, and
libakstdlib's Doxyfile is the unedited doxygen default, PROJECT_NAME = "My
Project" and an empty INPUT. So the house standard is libakgl's, down to the
boilerplate phrasing for the recurring parameters -- "Object to initialize,
inspect, or modify", "Output destination populated by the function", "`NULL` on
success, otherwise an error context owned by the caller".
Worth knowing what the gate actually gates. EXTRACT_ALL=YES suppresses
doxygen's undocumented-entity warnings, so the rule it enforces is that a
*partial* block is an error: document one @param and you must document them all.
Verified by deleting a @param and confirming a non-zero exit, then restoring it.
Full coverage is therefore a convention this commit adopts rather than something
the tool made me do.
Where a contract is non-obvious the block says so rather than restating the
signature: math_plus explains why it alone mutates its left operand, new_unary
notes that hanging the operand on .right is what makes the parser miscount a
negative literal argument, leaf_to_string warns that an assignment renders with
an empty operator, and stop_waiting records that a verb nobody is waiting for is
tolerated. Each cross-references its TODO.md section 6 item.
Also records in TODO.md that this repository has no CI, which libakgl and
libakstdlib both have -- so ctest, the sanitizer build, coverage and this new
doxygen gate are all run by hand today.
ctest 61/61; doxygen Doxyfile exits 0; no warnings under -Wall -Wextra.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:00:16 -04:00
Port the BASIC interpreter from Go to C
Reproduces deps/basicinterpret in C, in the idiom of the ak* libraries. All 41
.bas files in the reference's corpus produce byte-identical stdout, including
the trailing double newline on an error line -- that comes from basicError
building a string ending in \n and handing it to Println, and
array_outofbounds.txt encodes it.
The corpus is driven in place from the submodule as 41 individual CTest cases
rather than copied, so it cannot drift from upstream. Eighteen unit tests cover
what the corpus cannot reach.
Three structural changes carry most of the work. Go's three reflection lookups
(Command*, Function*, ParseCommand*) become one sorted dispatch table in
src/verbs.c searched with bsearch; adding a verb is a row and two functions. The
five Go maps become one fixed open-addressed table over aksl_strhash_djb2. And
run(), which owned the process until MODE_QUIT, splits into step() plus a
bounded run() -- goal 3 requires a host game to be able to bound execution, and
nothing in the library now terminates the process or touches SDL.
Output goes through an akbasic_TextSink vtable. src/sink_stdio.c is what makes
the corpus runnable with no SDL present; the akgl-backed sink is still to come
and is blocked on libakgl having no text-measurement call.
src/convert.c exists because libakstdlib's aksl_ato* family cannot report a
conversion failure (its TODO.md 2.1.5). The reference checks strconv's error at
four sites and turns it into a BASIC error; routing those through aksl_atoi
would have turned four diagnosable errors into wrong answers, with VAL("garbage")
quietly returning 0. TODO.md 1.9 records which libakstdlib calls are cleared for
use here and which are not.
Reference defects are reproduced, not fixed: the golden files encode the observed
behaviour and a silent correction is a behaviour change. TODO.md section 6 lists
sixteen, and tests/known_reference_defects.c asserts the *correct* contract for
six of them under AKBASIC_KNOWN_FAILING_TESTS, so a fix shows up as
"unexpectedly passed". Five of the sixteen were found by this port and are new:
subtraction stops after one operator so 1-2-3 computes 1-2 and abandons the rest
of the line (a wrong answer, not a refused one); a unary-minus argument inflates
a function's arity so ABS(-9) is rejected; a comparison operator in a line's
final column is dropped; hex literals never survive the scanner; and the
"Reserved word in variable name" check is dead code.
Where the reference reaches undefined behaviour by a route that is defined in Go
-- an out-of-range shift, a negative string multiplier, integer division by zero
-- this raises instead of inheriting the UB. No golden case exercises any of
them.
The top-level CMakeLists shadows add_test, set_tests_properties and
add_custom_target around all three add_subdirectory calls. Without it libakerror's
tests land in our suite as Not Run, and its un-namespaced `coverage` target stops
a coverage build from configuring at all. Test targets are akbasic_test_<name>:
bare test_<name> collides with libakstdlib's, which is what broke libakgl's
configure in c2b16d3.
ctest 59/59; ASan+UBSan 59/59; 92.3% line and 96.9% function coverage; no
warnings under -Wall -Wextra. Branch coverage is not a target, for the reason
libakstdlib and libakgl both record: the akerror macros expand into large branch
trees at every call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:56 -04:00
What remains, in priority order:
2026-07-31 11:21:39 -04:00
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.
2026-07-31 12:10:03 -04:00
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.
2026-07-31 10:57:02 -04:00
4. ~~**CI does not cover `-DAKBASIC_WITH_AKGL=ON`.**~~ **Done ** — the `akgl_build` job in
Cover -DAKBASIC_WITH_AKGL=ON in CI
A fifth push job, akgl_build: the text sink and the graphics, audio and input
backends, plus the akgl_backends suite that drives them against a real software
renderer under the dummy video and audio drivers and reads the pixels back.
It stays a separate job rather than a flag on cmake_build because
AKBASIC_WITH_AKGL is off by default and that default is the claim being made:
the interpreter and its whole 70-case suite build and pass on a machine with no
SDL. Keeping the two jobs apart proves that on every push instead of asserting
it.
Deferring this assumed it needed submodules: recursive and a long build. Both
guesses were wrong, and measurement is the only reason we know:
- 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". Six submodules initialised non-recursively
take about 25 seconds instead.
- The build is roughly a minute of CPU across 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
reports "Using system freetype library" and links libfreetype.so.6; without them
it would reach for deps/SDL_ttf/external/freetype, which the checkout
deliberately does not clone. Two apt packages against two more submodules.
Every step was run verbatim from a clean clone before being written down --
checkout, submodule init, configure, build and test -- rather than inferred from
the working tree. 71/71.
Also corrects three counts elsewhere in the file that had gone stale: the suite
is 70 cases rather than 61, line coverage is 93.5% rather than 92.3%, and branch
coverage reads 18% rather than 17%. Those numbers are assertions about the gate,
so a wrong one is worse than none.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:06:24 -04:00
`.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.
2026-07-31 12:52:00 -04:00
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.
2026-07-31 11:38:45 -04:00
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.
Add mutation testing, and split the release gates into their own workflow
Ports libakstdlib's mutation harness (the newest of the three) to scripts/
mutation_test.py, adds the namespaced `mutation` CMake target the sibling
libraries have, and splits CI in two.
.gitea/workflows/ci.yaml keeps the push path: suite, ASan+UBSan, coverage, and a
mutation run bounded to src/convert.c and src/symtab.c -- about four minutes,
scoring 77.8% against a gate of 65.
.gitea/workflows/release.yaml is new and manual (workflow_dispatch). It carries
the doxygen gate, moved out of ci.yaml, and a whole-tree mutation run: 3675
mutants and hours of runner time, which is a release cost rather than a
per-commit one. Both artifacts a release wants -- api-documentation and
mutation-report -- come out of it. Two optional inputs narrow the run or change
the threshold; they arrive through the environment rather than being
interpolated into the shell, because ${{ }} substitution happens before the
shell sees the line.
The harness paid for itself immediately, which is the point of it. Three real
gaps, each checked to be a genuine bug rather than an equivalent mutant:
- errno was never asserted clear before a strtoll. Confirmed with a standalone
probe that strtoll leaves a stale errno untouched on success, so without the
`errno = 0` a valid conversion raises ERANGE.
- Nothing exercised a maximum-length symbol-table key, so every MAX_KEY - 1
off-by-one in a strncpy and its NUL terminator survived. The same hole exists
for strings in src/value.c and is filed.
- Nothing asserted a freshly initialised table was actually zeroed.
Closing the first two took the measured score from 73.1% to 77.8%.
I set the push-path threshold to 75 first, on an estimate. Measuring gave 73.1%
and the job would have failed on its first run -- the earlier per-file figure was
too high because the captured output had been truncated to its last lines and I
counted fewer survivors than there were. It is 65 now, and the gate was run as
written and confirmed to exit 0.
src/value.c is the file most worth mutating and is deliberately off the push
path: 368 mutants at ~11s each is about 70 minutes, because almost everything
links against it. A partial run over it found the same maximum-length-string
hole plus two genuinely equivalent mutants that only exist because of reference
defect section 6 item 5 -- adding both of the right operand's numeric fields
works only while the unused one is zero, so + and - are interchangeable there.
Recorded in TODO.md.
ctest 61/61; doxygen exits 0; both workflows' steps were executed locally, both
input paths included.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:46:53 -04:00
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
Delete src/convert.c: libakstdlib 0.2.0 grew the contract it existed for
That file wrapped strtoll and strtod because libakstdlib's aksl_ato* family
could not report a conversion failure at all -- atoi("not a number") returned
success with 0, turning four diagnosable errors into wrong answers. Its own note
said what to do when that changed: "When it grows it, delete src/convert.c and
switch the call sites over." 0.2.0 grew it, so it is gone.
Six call sites go straight to the library now: both literal constructors 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. aksl_strtoll(str, NULL, base, &dest) rather than
aksl_atoll wherever a base is involved, because the ato* forms are base 10 and
grammar.c picks base 8 or 16 off the lexeme's prefix; the NULL endptr is what
makes trailing junk an error rather than a stopping point.
The raised status changed from AKBASIC_ERR_VALUE to AKERR_VALUE, and the message
with it -- VAL("garbage") now says `no digits in "garbage"`. Section 1.8 makes
message text part of the acceptance contract, so that was checked against the
corpus before touching anything: no golden file contained a conversion message,
which is also why section 1.9 had been asking for one. Two exist now, so the next
change to that text has to move a golden file, and one of them pins the
reference's octal-literal defect (section 6 item 10) while it is still
deliberately reproduced.
tests/convert.c became tests/numeric_contract.c rather than being deleted with
the code. The assertions did not stop being worth making when the wrapper went
away -- they became assertions about a contract this port depends on and no
longer owns, and a regression in it would make four things quietly return zero.
Repoints the CI mutation job, which was bounded to src/convert.c and
src/symtab.c. src/symtab.c alone measures 74.1% against the gate of 65.
src/audio_tables.c was measured as a replacement second file and scored 64.7%:
that is a real gap rather than a reason to skip it, since almost every survivor
is in akbasic_audio_state_init, where nothing asserts a freshly initialised audio
state is actually zeroed -- the same gap this job's history records closing for
symtab. Recorded in TODO.md so the file can earn its place back.
One thing worth knowing before reading any score here, and now written down: the
harness's ICR operator only rewrites the constants 0 and 1, so a lookup table of
other values produces no mutants and a high score over one says nothing about
whether its entries are right. tests/audio_verbs.c now asserts all 32 ADSR
entries against the datasheet anyway, because a hand-edit typo is the real
failure mode there -- but that could not and did not move the mutation number,
and claiming otherwise would be the kind of thing this file exists to stop.
72/72 core, 73/73 with libakgl, 72/72 under ASan+UBSan, coverage 93.6% line and
97.8% function, clean under -Wall -Wextra, doxygen clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:28:04 -04:00
almost everything links against it), which is why CI runs `src/symtab.c` instead and the
Add mutation testing, and split the release gates into their own workflow
Ports libakstdlib's mutation harness (the newest of the three) to scripts/
mutation_test.py, adds the namespaced `mutation` CMake target the sibling
libraries have, and splits CI in two.
.gitea/workflows/ci.yaml keeps the push path: suite, ASan+UBSan, coverage, and a
mutation run bounded to src/convert.c and src/symtab.c -- about four minutes,
scoring 77.8% against a gate of 65.
.gitea/workflows/release.yaml is new and manual (workflow_dispatch). It carries
the doxygen gate, moved out of ci.yaml, and a whole-tree mutation run: 3675
mutants and hours of runner time, which is a release cost rather than a
per-commit one. Both artifacts a release wants -- api-documentation and
mutation-report -- come out of it. Two optional inputs narrow the run or change
the threshold; they arrive through the environment rather than being
interpolated into the shell, because ${{ }} substitution happens before the
shell sees the line.
The harness paid for itself immediately, which is the point of it. Three real
gaps, each checked to be a genuine bug rather than an equivalent mutant:
- errno was never asserted clear before a strtoll. Confirmed with a standalone
probe that strtoll leaves a stale errno untouched on success, so without the
`errno = 0` a valid conversion raises ERANGE.
- Nothing exercised a maximum-length symbol-table key, so every MAX_KEY - 1
off-by-one in a strncpy and its NUL terminator survived. The same hole exists
for strings in src/value.c and is filed.
- Nothing asserted a freshly initialised table was actually zeroed.
Closing the first two took the measured score from 73.1% to 77.8%.
I set the push-path threshold to 75 first, on an estimate. Measuring gave 73.1%
and the job would have failed on its first run -- the earlier per-file figure was
too high because the captured output had been truncated to its last lines and I
counted fewer survivors than there were. It is 65 now, and the gate was run as
written and confirmed to exit 0.
src/value.c is the file most worth mutating and is deliberately off the push
path: 368 mutants at ~11s each is about 70 minutes, because almost everything
links against it. A partial run over it found the same maximum-length-string
hole plus two genuinely equivalent mutants that only exist because of reference
defect section 6 item 5 -- adding both of the right operand's numeric fields
works only while the unused one is zero, so + and - are interchangeable there.
Recorded in TODO.md.
ctest 61/61; doxygen exits 0; both workflows' steps were executed locally, both
input paths included.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:46:53 -04:00
whole-tree run is a local `cmake --build build --target mutation` .
Execute every documented example as a test
docs/ and README.md carry 85 fenced blocks. Every one was checked by hand
exactly once, when it was written, which is not a standard that survives a
changing interpreter -- and four were already wrong: two transcripts showing a
leading space PRINT does not emit, akbasic_TextSink in README.md missing the
two members it had grown hours earlier, and FILTER's refusal quoted with
wording the code does not use.
tests/docs_examples.sh reads a fence-tag vocabulary and runs what it finds.
BASIC programs and transcripts run and are byte-compared against an `output`
block; C snippets compile with -fsyntax-only against the real include path,
which CMake writes out because it is transitive through akerror, akstdlib and
akgl; shell blocks run in a sandbox. Anything that would reconfigure the build
tree, hit the network or re-enter the suite is tagged norun with the reason in
MAINTENANCE.md, and the two cmake blocks stay hand-maintained by decision.
An untagged block is a failure rather than a default, and the pass line
reports what it executed by kind. Both exist because the way a harness like
this dies is by quietly matching nothing and passing -- which it duly did on
the first CTest run, where a generator expression evaluating to nothing still
contributed an empty argument that the script read as a filename. The count is
what caught it.
The excerpt check earns its own mention: a block tagged
`c excerpt=include/akbasic/sink.h` must still appear in that header, comments
and whitespace ignored. Compiling it would only redefine the type, so a
compile check could not have found the stale struct, and did not.
Registered as the CTest case docs_examples in both configurations. Fixing the
four wrong examples turned up two interpreter defects, fixed in the previous
commit and recorded in TODO.md section 8.
MAINTENANCE.md is new: the fence-tag reference, what to do when the case
fails, and the conventions that until now only existed inside source comments
-- the three test lists and how two of them invert "passed", the sorted verb
table, that a golden file is never edited to suit this interpreter, and that a
fix gets mutation-checked with a file copy rather than git checkout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:41:36 -04:00
Draw into the whole window, not its top-left 320x200
The graphics verbs documented a coordinate transform that did not exist. With
SCALE off a coordinate went straight to akgl_draw_* as a pixel address, so an
800x600 window drew a C128 listing into its corner and left the rest unused --
while the chapter said coordinates were 320x200 and stretching to fit was the
host's business.
akbasic_GraphicsBackend gains a size entry point, require_graphics() asks it
before every verb that draws so a resized window is honoured between two
statements, and 320x200 becomes the fallback for a backend that leaves it NULL.
It is the record's one optional member, so a host written against the old header
keeps the behaviour it had.
SCALE now maps onto the device, and RGR(1)/RGR(2) report the drawing surface so
a program can use a window whose size it did not choose. RGR(0) is BASIC 7.0's
own field, the GRAPHIC mode.
SCALE also mapped xmax onto the width rather than onto the last pixel, so
SCALE 1, 319, 199 followed by DRAW 1, 319, 199 drew nothing at all -- one pixel
past the surface. Fixed in the same line, because it is what makes "SCALE gives
a C128 listing the whole window" true rather than nearly true.
The akgl test renders against a 128x128 target, deliberately smaller than the
old constants: a SCALE still dividing by them misses it entirely rather than
landing somewhere plausible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:35:20 -04:00
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.
Execute every documented example as a test
docs/ and README.md carry 85 fenced blocks. Every one was checked by hand
exactly once, when it was written, which is not a standard that survives a
changing interpreter -- and four were already wrong: two transcripts showing a
leading space PRINT does not emit, akbasic_TextSink in README.md missing the
two members it had grown hours earlier, and FILTER's refusal quoted with
wording the code does not use.
tests/docs_examples.sh reads a fence-tag vocabulary and runs what it finds.
BASIC programs and transcripts run and are byte-compared against an `output`
block; C snippets compile with -fsyntax-only against the real include path,
which CMake writes out because it is transitive through akerror, akstdlib and
akgl; shell blocks run in a sandbox. Anything that would reconfigure the build
tree, hit the network or re-enter the suite is tagged norun with the reason in
MAINTENANCE.md, and the two cmake blocks stay hand-maintained by decision.
An untagged block is a failure rather than a default, and the pass line
reports what it executed by kind. Both exist because the way a harness like
this dies is by quietly matching nothing and passing -- which it duly did on
the first CTest run, where a generator expression evaluating to nothing still
contributed an empty argument that the script read as a filename. The count is
what caught it.
The excerpt check earns its own mention: a block tagged
`c excerpt=include/akbasic/sink.h` must still appear in that header, comments
and whitespace ignored. Compiling it would only redefine the type, so a
compile check could not have found the stale struct, and did not.
Registered as the CTest case docs_examples in both configurations. Fixing the
four wrong examples turned up two interpreter defects, fixed in the previous
commit and recorded in TODO.md section 8.
MAINTENANCE.md is new: the fence-tag reference, what to do when the case
fails, and the conventions that until now only existed inside source comments
-- the three test lists and how two of them invert "passed", the sorted verb
table, that a golden file is never edited to suit this interpreter, and that a
fix gets mutation-checked with a file copy rather than git checkout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:41:36 -04:00
Document every error code a script can see, as chapter 15
ER# held numbers nothing explained. The appendix lists the four error classes
the interpreter prints, the eight codes it owns and 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 -- so the numbers
are checked rather than claimed. The eighth, 516, is not usefully trappable and
the chapter says why: entering a handler takes a scope, and the pool being empty
is what raised it.
Two things worth a reader's attention came out of writing it. Two codes register
the same ERR() text, so a program must compare the number and print the text.
And VAL reports libakerror's Value Error rather than the interpreter's 517,
which makes that number the platform's rather than ours -- filed as section 6
item 21, not fixed here, because deciding which libakstdlib failures to
translate is a boundary question and ENOENT out of DOPEN is the counter-case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:36:36 -04:00
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.
Execute every documented example as a test
docs/ and README.md carry 85 fenced blocks. Every one was checked by hand
exactly once, when it was written, which is not a standard that survives a
changing interpreter -- and four were already wrong: two transcripts showing a
leading space PRINT does not emit, akbasic_TextSink in README.md missing the
two members it had grown hours earlier, and FILTER's refusal quoted with
wording the code does not use.
tests/docs_examples.sh reads a fence-tag vocabulary and runs what it finds.
BASIC programs and transcripts run and are byte-compared against an `output`
block; C snippets compile with -fsyntax-only against the real include path,
which CMake writes out because it is transitive through akerror, akstdlib and
akgl; shell blocks run in a sandbox. Anything that would reconfigure the build
tree, hit the network or re-enter the suite is tagged norun with the reason in
MAINTENANCE.md, and the two cmake blocks stay hand-maintained by decision.
An untagged block is a failure rather than a default, and the pass line
reports what it executed by kind. Both exist because the way a harness like
this dies is by quietly matching nothing and passing -- which it duly did on
the first CTest run, where a generator expression evaluating to nothing still
contributed an empty argument that the script read as a filename. The count is
what caught it.
The excerpt check earns its own mention: a block tagged
`c excerpt=include/akbasic/sink.h` must still appear in that header, comments
and whitespace ignored. Compiling it would only redefine the type, so a
compile check could not have found the stale struct, and did not.
Registered as the CTest case docs_examples in both configurations. Fixing the
four wrong examples turned up two interpreter defects, fixed in the previous
commit and recorded in TODO.md section 8.
MAINTENANCE.md is new: the fence-tag reference, what to do when the case
fails, and the conventions that until now only existed inside source comments
-- the three test lists and how two of them invert "passed", the sorted verb
table, that a golden file is never edited to suit this interpreter, and that a
fix gets mutation-checked with a file copy rather than git checkout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:41:36 -04:00
Document every error code a script can see, as chapter 15
ER# held numbers nothing explained. The appendix lists the four error classes
the interpreter prints, the eight codes it owns and 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 -- so the numbers
are checked rather than claimed. The eighth, 516, is not usefully trappable and
the chapter says why: entering a handler takes a scope, and the pool being empty
is what raised it.
Two things worth a reader's attention came out of writing it. Two codes register
the same ERR() text, so a program must compare the number and print the text.
And VAL reports libakerror's Value Error rather than the interpreter's 517,
which makes that number the platform's rather than ours -- filed as section 6
item 21, not fixed here, because deciding which libakstdlib failures to
translate is a boundary question and ENOENT out of DOPEN is the counter-case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:36:36 -04:00
**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.
Execute every documented example as a test
docs/ and README.md carry 85 fenced blocks. Every one was checked by hand
exactly once, when it was written, which is not a standard that survives a
changing interpreter -- and four were already wrong: two transcripts showing a
leading space PRINT does not emit, akbasic_TextSink in README.md missing the
two members it had grown hours earlier, and FILTER's refusal quoted with
wording the code does not use.
tests/docs_examples.sh reads a fence-tag vocabulary and runs what it finds.
BASIC programs and transcripts run and are byte-compared against an `output`
block; C snippets compile with -fsyntax-only against the real include path,
which CMake writes out because it is transitive through akerror, akstdlib and
akgl; shell blocks run in a sandbox. Anything that would reconfigure the build
tree, hit the network or re-enter the suite is tagged norun with the reason in
MAINTENANCE.md, and the two cmake blocks stay hand-maintained by decision.
An untagged block is a failure rather than a default, and the pass line
reports what it executed by kind. Both exist because the way a harness like
this dies is by quietly matching nothing and passing -- which it duly did on
the first CTest run, where a generator expression evaluating to nothing still
contributed an empty argument that the script read as a filename. The count is
what caught it.
The excerpt check earns its own mention: a block tagged
`c excerpt=include/akbasic/sink.h` must still appear in that header, comments
and whitespace ignored. Compiling it would only redefine the type, so a
compile check could not have found the stale struct, and did not.
Registered as the CTest case docs_examples in both configurations. Fixing the
four wrong examples turned up two interpreter defects, fixed in the previous
commit and recorded in TODO.md section 8.
MAINTENANCE.md is new: the fence-tag reference, what to do when the case
fails, and the conventions that until now only existed inside source comments
-- the three test lists and how two of them invert "passed", the sorted verb
table, that a golden file is never edited to suit this interpreter, and that a
fix gets mutation-checked with a file copy rather than git checkout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:41:36 -04:00
Generate the documentation's figures from the listings they illustrate
Chapters 6 and 8 described what a verb draws in prose. Eight figures now show
it, and each one is produced by running the BASIC listing printed immediately
above it -- so a picture cannot drift away from the code beside it, which is the
way a screenshot goes wrong and the way nothing notices.
tools/screenshot.c is a second SDL host, much smaller than the frontend: dummy
video driver, software renderer, run to completion, read the target back, write
a PNG. It draws no text layer on purpose, so a READY in the corner is not noise
in a figure about BOX and no font has to be resolved.
tools/docs_screenshots.sh reads the new screenshot=NAME fence tag straight out
of the markdown. size=WxH is the second tag, and SCALE's figure uses it: the
point being made is a 320x200 listing filling a larger window, which cannot be
made on a 320x200 surface.
Two gates, answering different questions. docs_examples fails a tagged block
with no 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. Only the second catches a stale picture.
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. Regenerating is never
part of a build: the target is run deliberately, so a make cannot put eight
binary diffs in front of whoever ran it.
Drawing the BOX figure caught a defect in TODO.md itself. Deviation 16 claimed
in bold that BOX fills on a negative angle while its own paragraph said the fill
was filed rather than implemented. BOX cannot fill, and filled_rect is reached
by no verb as a result.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:37:04 -04:00
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.
Add two Breakout examples and the tutorials that build them
Two complete games in `examples/breakout/`, both 100% BASIC: `characters/`
draws its wall in the text grid with two `DATA` sprites for the ball and
paddle, and `sprites/` loads CC0 artwork and captures its whole screen with
`SSHAPE`/`SPRSAV`. They take opposite shapes for reasons that are entirely
this interpreter's, which is what the chapters are for.
`docs/17-tutorial-breakout.md` and `docs/18-tutorial-breakout-artwork.md`
build each one a step at a time, and end in a checklist of the rules a real
program runs into: create every name before the loop starts, write a text row
whole, loop with `GOTO` rather than `DO`, put the float on the left. Every
trap is a runnable block with its own output rather than a claim -- the value
pool dying at four thousand names, the skipped `BEGIN` block that breaks its
caller's `RETURN`, `SSHAPE` ignoring a subscript, `READ`'s single cursor.
Five figures, generated from the listings beside them by `docs_screenshots`,
and a `breakout_art` setup so the ones that load artwork load the example's
own. The character game's wall cannot be photographed -- the screenshot host
omits the text layer on purpose -- so it is shown as compared output instead.
`docs/07-sound.md` never said `SOUND`'s frequency is a SID register value
rather than hertz, which both games depend on. It says so now, with the
conversion from `src/audio_tables.c:84`.
`TODO.md` gains the thirteen defects the two games turned up -- §6 items 30
to 33 and all of §9 -- each with a reduction that fits on a screen, the file
and line of the cause, and what a fix would touch.
Verified: `docs_examples` passes in both build configurations,
`docs_screenshots --check` re-renders all thirteen figures and byte-compares
them, the full 109-test suite passes in both builds, every quoted fragment
was checked to appear verbatim in the listing it came from, and every
relative link and anchor resolves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:54:42 -04:00
---
## 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.
Stop a scalar created inside a scope costing value-pool slots
A scalar now lives in the variable record (`akbasic_Variable::inlinevalue`)
rather than drawing from the value pool, so a `GOSUB` local, a `FOR` counter and
a `DEF` parameter cost nothing at all.
The pool is a bump allocator with no free, and its comment justified that with
"nothing in BASIC destroys a variable". Scope exit does: it marks the variable
slot unused, `new_variable()` memsets the slot it hands back -- clearing
`values` -- and `variable_init()` therefore took *fresh* slots for a variable
whose old ones were still counted. Every scope that created a local leaked, with
no diagnostic until the pool ran dry on whichever line happened to be unlucky.
Six thousand `GOSUB`s creating one local used to die on the 4091st at `LOC# = 1`
with "Array of 1 elements does not fit in the 0 remaining value slots". They now
run. A `DEF` called eight thousand times used to die between the four and five
thousandth -- the leaking slot was the call scope's parameter, which is a scalar
-- and both forms now run. A game creating one name per tick was dead in half a
minute; the Breakout in examples/ was, after twenty-five seconds.
**A `@` name is the one exclusion, and it is the whole of it.** A structure or a
pointer to one keeps pool storage, because a pointer into a record outlives the
scope that DIMmed it -- docs/16-structures.md says nothing is reclaimed and
`prev_environment()` relies on it. The name suffix is the right test rather than
`structtype`, which the DIM path sets *after* calling `variable_init()`. A local
array therefore still leaks, deliberately, and is now the narrow rule the
tutorial teaches.
`SWAP` needed the other half: it copies whole variable records, so the `values`
pointer that came over named the other variable's inline slot -- which by then
held this variable's own old value -- and SWAP silently did nothing. Caught by
tests/language/housekeeping/verbs.bas, which is the golden corpus earning its
keep.
tests/value_pool.c is the new coverage. It asserts the mechanism as well as the
consequence: a later change that moved arrays inline too would pass every
behavioural case and quietly break the pointer guarantee. The sharpest case
takes the pool's whole 4096 slots in four arrays after two hundred scope
entries, so one leaked slot has nowhere to go.
Chapter 17 Step 3 taught "declare every name at the top" and no longer needs to.
It now teaches what is still true -- a name first seen inside a subroutine dies
at RETURN, so a routine cannot answer its caller through one -- and its
demonstration is the array case, which still fails.
TODO.md section 6 item 30 and section 9 item 1, both struck.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:47:49 -04:00
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:
Add two Breakout examples and the tutorials that build them
Two complete games in `examples/breakout/`, both 100% BASIC: `characters/`
draws its wall in the text grid with two `DATA` sprites for the ball and
paddle, and `sprites/` loads CC0 artwork and captures its whole screen with
`SSHAPE`/`SPRSAV`. They take opposite shapes for reasons that are entirely
this interpreter's, which is what the chapters are for.
`docs/17-tutorial-breakout.md` and `docs/18-tutorial-breakout-artwork.md`
build each one a step at a time, and end in a checklist of the rules a real
program runs into: create every name before the loop starts, write a text row
whole, loop with `GOTO` rather than `DO`, put the float on the left. Every
trap is a runnable block with its own output rather than a claim -- the value
pool dying at four thousand names, the skipped `BEGIN` block that breaks its
caller's `RETURN`, `SSHAPE` ignoring a subscript, `READ`'s single cursor.
Five figures, generated from the listings beside them by `docs_screenshots`,
and a `breakout_art` setup so the ones that load artwork load the example's
own. The character game's wall cannot be photographed -- the screenshot host
omits the text layer on purpose -- so it is shown as compared output instead.
`docs/07-sound.md` never said `SOUND`'s frequency is a SID register value
rather than hertz, which both games depend on. It says so now, with the
conversion from `src/audio_tables.c:84`.
`TODO.md` gains the thirteen defects the two games turned up -- §6 items 30
to 33 and all of §9 -- each with a reduction that fits on a screen, the file
and line of the cause, and what a fix would touch.
Verified: `docs_examples` passes in both build configurations,
`docs_screenshots --check` re-renders all thirteen figures and byte-compares
them, the full 109-test suite passes in both builds, every quoted fragment
was checked to appear verbatim in the listing it came from, and every
relative link and anchor resolves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:54:42 -04:00
```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.
Release the scope a skipped BEGIN block's loop pushed
`akbasic_parse_for()` and `akbasic_parse_do()` create their environment while
the line is *parsed*; whether to skip it is decided afterwards, when the line is
evaluated. So a loop inside a block that was not taken pushed a scope, its body
was skipped, and the `NEXT` or `LOOP` that would have popped it was skipped too.
Nothing else ever would.
At the top level that exhausted the pool after thirty-two skips. Inside a
routine it was far more confusing: the orphan sat between the routine and its
caller, so the `RETURN` after the block reported "RETURN outside the context of
GOSUB" from a routine that plainly *was* entered by a `GOSUB` -- naming the one
construct that was not at fault, which is why it cost an evening to find.
The skip now releases what parsing pushed.
**Narrower than it first looks.** Releasing on any skip breaks
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 absorbs the inner `NEXT` so the outer `NEXT` still finds
its own `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
decidable because nothing inside a skipped block ever runs to arm a `NEXT` wait.
Both halves are asserted side by side in tests/structure_verbs.c, the second one
citing the golden case that caught it.
The forty-skip case names its own step budget: a skipped line is not free, and
forty passes over a five-line block cost about 2700 steps against the shared
runner's 2000.
Chapter 18's trap 3 becomes history rather than a warning, and the note in Step
5 that called `GOTO`-guarded loops "not a style choice" now says why the shape
is kept anyway.
TODO.md section 9 item 2, struck.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:55:10 -04:00
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:
Add two Breakout examples and the tutorials that build them
Two complete games in `examples/breakout/`, both 100% BASIC: `characters/`
draws its wall in the text grid with two `DATA` sprites for the ball and
paddle, and `sprites/` loads CC0 artwork and captures its whole screen with
`SSHAPE`/`SPRSAV`. They take opposite shapes for reasons that are entirely
this interpreter's, which is what the chapters are for.
`docs/17-tutorial-breakout.md` and `docs/18-tutorial-breakout-artwork.md`
build each one a step at a time, and end in a checklist of the rules a real
program runs into: create every name before the loop starts, write a text row
whole, loop with `GOTO` rather than `DO`, put the float on the left. Every
trap is a runnable block with its own output rather than a claim -- the value
pool dying at four thousand names, the skipped `BEGIN` block that breaks its
caller's `RETURN`, `SSHAPE` ignoring a subscript, `READ`'s single cursor.
Five figures, generated from the listings beside them by `docs_screenshots`,
and a `breakout_art` setup so the ones that load artwork load the example's
own. The character game's wall cannot be photographed -- the screenshot host
omits the text layer on purpose -- so it is shown as compared output instead.
`docs/07-sound.md` never said `SOUND`'s frequency is a SID register value
rather than hertz, which both games depend on. It says so now, with the
conversion from `src/audio_tables.c:84`.
`TODO.md` gains the thirteen defects the two games turned up -- §6 items 30
to 33 and all of §9 -- each with a reduction that fits on a screen, the file
and line of the cause, and what a fix would touch.
Verified: `docs_examples` passes in both build configurations,
`docs_screenshots --check` re-renders all thirteen figures and byte-compares
them, the full 109-test suite passes in both builds, every quoted fragment
was checked to appear verbatim in the listing it came from, and every
relative link and anchor resolves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:54:42 -04:00
```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.
Bring the tutorials back in line with the fixed interpreter
Eleven of the thirteen defects these two games found are fixed, and the
chapters that taught around them said things that are no longer true.
Chapter 17: the drawing verbs are no longer invisible, they are covered by a
text layer that `WINDOW` can now shrink; the cell size is `RGR(3)` and the grid
is `RWINDOW`, not a hand-measured constant; a character written past a short
row's end lands. Chapter 18: traps 3 and 4 -- the skipped block that broke its
caller's `RETURN`, and `SSHAPE` ignoring a subscript -- become history rather
than warnings, and Step 3 no longer claims a sprite is the *only* way to put a
picture up, only the one that costs nothing.
**Neither `.bas` listing changes, and both READMEs now say why.** The character
game still writes `CW# = 16` and the artwork game still keeps six brick stamps
in six scalars. What those listings are worth is being what a program written
against those constraints looks like, with comments explaining what each one was
avoiding -- rewriting them to pretend the problems never existed would throw
that away. So Chapter 17 Step 2 shows the `RGR(3)`/`RWINDOW` form and says
plainly that the listing beside it does not use it. That is the one place in
either chapter where a quoted fragment is not verbatim from the game.
Also: the closing pointers now say which entries are struck and which stand, and
the budgets table in Chapter 18 notes that several of those budgets were tighter
when the game was written.
TODO.md section 9 item 3 is updated rather than struck: the `WINDOW` half is
fixed, the default text area owning the whole window is not, and whether that
default is right is a question rather than a defect.
Verified against the fixed interpreter: both games run 45 seconds on the SDL
frontend with no error line and the score climbing; both suites are green in
both configurations; `docs_examples` passes and `docs_screenshots --check`
re-renders all thirteen figures byte-identically; coverage is 95.0% of lines
against the 90 gate, with src/variable.c at 100%; and every other quoted
fragment still appears verbatim in the listing it came from.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 00:31:06 -04:00
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:
Add two Breakout examples and the tutorials that build them
Two complete games in `examples/breakout/`, both 100% BASIC: `characters/`
draws its wall in the text grid with two `DATA` sprites for the ball and
paddle, and `sprites/` loads CC0 artwork and captures its whole screen with
`SSHAPE`/`SPRSAV`. They take opposite shapes for reasons that are entirely
this interpreter's, which is what the chapters are for.
`docs/17-tutorial-breakout.md` and `docs/18-tutorial-breakout-artwork.md`
build each one a step at a time, and end in a checklist of the rules a real
program runs into: create every name before the loop starts, write a text row
whole, loop with `GOTO` rather than `DO`, put the float on the left. Every
trap is a runnable block with its own output rather than a claim -- the value
pool dying at four thousand names, the skipped `BEGIN` block that breaks its
caller's `RETURN`, `SSHAPE` ignoring a subscript, `READ`'s single cursor.
Five figures, generated from the listings beside them by `docs_screenshots`,
and a `breakout_art` setup so the ones that load artwork load the example's
own. The character game's wall cannot be photographed -- the screenshot host
omits the text layer on purpose -- so it is shown as compared output instead.
`docs/07-sound.md` never said `SOUND`'s frequency is a SID register value
rather than hertz, which both games depend on. It says so now, with the
conversion from `src/audio_tables.c:84`.
`TODO.md` gains the thirteen defects the two games turned up -- §6 items 30
to 33 and all of §9 -- each with a reduction that fits on a screen, the file
and line of the cause, and what a fix would touch.
Verified: `docs_examples` passes in both build configurations,
`docs_screenshots --check` re-renders all thirteen figures and byte-compares
them, the full 109-test suite passes in both builds, every quoted fragment
was checked to appear verbatim in the listing it came from, and every
relative link and anchor resolves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:54:42 -04:00
```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.
Write down that the left operand decides integer or float arithmetic
No behaviour change, by decision. `A# * 0.45` is 0 and `0.45 * A#` is 1.35,
because every operator branches on `self->valuetype` and converts the right
operand to match. It is inherited from the Go reference, a C128 promotes to
float instead, and this interpreter is at least consistent about it -- so a
dialect saying the left operand wins is a defensible position, and changing it
to promotion would alter the result of every mixed expression in every existing
program.
**The defect was that nobody said so.** Chapter 3's "Numbers" did not mention
it, Chapter 13 did not list it among the differences, and nothing fails when a
program gets it wrong -- it computes something else and carries on. The game in
examples/ lost its per-level speed increase to `5.6 + LEVEL# * 0.45` evaluating
to a flat 5.6, and bled velocity out of every bounce through `0 - BLVX%(B#)`
quantising to whole pixels. Both read correctly. Neither produced a diagnostic.
Now said in three places: a section in Chapter 3 with the demonstration and the
two rules that keep a program out of it (put the float on the left, put the
answer somewhere with a `%` on it), a row in Chapter 13 naming it as the
difference from 7.0 most likely to turn a working listing into a quietly wrong
one, and the reasoning on value.h where the operators are declared.
tests/value_arithmetic.c pins it in both directions across multiply and
subtract, with a comment saying it is the documented contract rather than an
accident -- so promotion becomes a decision somebody takes deliberately rather
than a change that could slip in under a passing suite.
TODO.md section 9 item 4, struck as a documentation outcome.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 00:23:20 -04:00
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:
Add two Breakout examples and the tutorials that build them
Two complete games in `examples/breakout/`, both 100% BASIC: `characters/`
draws its wall in the text grid with two `DATA` sprites for the ball and
paddle, and `sprites/` loads CC0 artwork and captures its whole screen with
`SSHAPE`/`SPRSAV`. They take opposite shapes for reasons that are entirely
this interpreter's, which is what the chapters are for.
`docs/17-tutorial-breakout.md` and `docs/18-tutorial-breakout-artwork.md`
build each one a step at a time, and end in a checklist of the rules a real
program runs into: create every name before the loop starts, write a text row
whole, loop with `GOTO` rather than `DO`, put the float on the left. Every
trap is a runnable block with its own output rather than a claim -- the value
pool dying at four thousand names, the skipped `BEGIN` block that breaks its
caller's `RETURN`, `SSHAPE` ignoring a subscript, `READ`'s single cursor.
Five figures, generated from the listings beside them by `docs_screenshots`,
and a `breakout_art` setup so the ones that load artwork load the example's
own. The character game's wall cannot be photographed -- the screenshot host
omits the text layer on purpose -- so it is shown as compared output instead.
`docs/07-sound.md` never said `SOUND`'s frequency is a SID register value
rather than hertz, which both games depend on. It says so now, with the
conversion from `src/audio_tables.c:84`.
`TODO.md` gains the thirteen defects the two games turned up -- §6 items 30
to 33 and all of §9 -- each with a reduction that fits on a screen, the file
and line of the cause, and what a fix would touch.
Verified: `docs_examples` passes in both build configurations,
`docs_screenshots --check` re-renders all thirteen figures and byte-compares
them, the full 109-test suite passes in both builds, every quoted fragment
was checked to appear verbatim in the listing it came from, and every
relative link and anchor resolves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:54:42 -04:00
```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.
Write down that the left operand decides integer or float arithmetic
No behaviour change, by decision. `A# * 0.45` is 0 and `0.45 * A#` is 1.35,
because every operator branches on `self->valuetype` and converts the right
operand to match. It is inherited from the Go reference, a C128 promotes to
float instead, and this interpreter is at least consistent about it -- so a
dialect saying the left operand wins is a defensible position, and changing it
to promotion would alter the result of every mixed expression in every existing
program.
**The defect was that nobody said so.** Chapter 3's "Numbers" did not mention
it, Chapter 13 did not list it among the differences, and nothing fails when a
program gets it wrong -- it computes something else and carries on. The game in
examples/ lost its per-level speed increase to `5.6 + LEVEL# * 0.45` evaluating
to a flat 5.6, and bled velocity out of every bounce through `0 - BLVX%(B#)`
quantising to whole pixels. Both read correctly. Neither produced a diagnostic.
Now said in three places: a section in Chapter 3 with the demonstration and the
two rules that keep a program out of it (put the float on the left, put the
answer somewhere with a `%` on it), a row in Chapter 13 naming it as the
difference from 7.0 most likely to turn a working listing into a quietly wrong
one, and the reasoning on value.h where the operators are declared.
tests/value_arithmetic.c pins it in both directions across multiply and
subtract, with a comment saying it is the documented contract rather than an
accident -- so promotion becomes a decision somebody takes deliberately rather
than a change that could slip in under a passing suite.
TODO.md section 9 item 4, struck as a documentation outcome.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 00:23:20 -04:00
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
Add two Breakout examples and the tutorials that build them
Two complete games in `examples/breakout/`, both 100% BASIC: `characters/`
draws its wall in the text grid with two `DATA` sprites for the ball and
paddle, and `sprites/` loads CC0 artwork and captures its whole screen with
`SSHAPE`/`SPRSAV`. They take opposite shapes for reasons that are entirely
this interpreter's, which is what the chapters are for.
`docs/17-tutorial-breakout.md` and `docs/18-tutorial-breakout-artwork.md`
build each one a step at a time, and end in a checklist of the rules a real
program runs into: create every name before the loop starts, write a text row
whole, loop with `GOTO` rather than `DO`, put the float on the left. Every
trap is a runnable block with its own output rather than a claim -- the value
pool dying at four thousand names, the skipped `BEGIN` block that breaks its
caller's `RETURN`, `SSHAPE` ignoring a subscript, `READ`'s single cursor.
Five figures, generated from the listings beside them by `docs_screenshots`,
and a `breakout_art` setup so the ones that load artwork load the example's
own. The character game's wall cannot be photographed -- the screenshot host
omits the text layer on purpose -- so it is shown as compared output instead.
`docs/07-sound.md` never said `SOUND`'s frequency is a SID register value
rather than hertz, which both games depend on. It says so now, with the
conversion from `src/audio_tables.c:84`.
`TODO.md` gains the thirteen defects the two games turned up -- §6 items 30
to 33 and all of §9 -- each with a reduction that fits on a screen, the file
and line of the cause, and what a fix would touch.
Verified: `docs_examples` passes in both build configurations,
`docs_screenshots --check` re-renders all thirteen figures and byte-compares
them, the full 109-test suite passes in both builds, every quoted fragment
was checked to appear verbatim in the listing it came from, and every
relative link and anchor resolves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:54:42 -04:00
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.
Honour a subscript in SSHAPE and GSHAPE
`shape_variable()` took the identifier off the leaf and looked the variable up
without ever evaluating the subscript, and both verbs then addressed element
zero with a literal. So `SSHAPE SH$(2), ...` wrote the handle into `SH$(0)` and
`GSHAPE SH$(2)` stamped whatever was in `SH$(0)`.
Ordinary assignment and `PRINT` honour the subscript, which is what made this
expensive: a program keeping several saved shapes in an array got every one of
them resolving to the same element, silently, and the only symptom was that
every stamp came out as the last shape captured. The Breakout in examples/ keeps
its six brick stamps in six separate scalars for exactly this reason.
`SPRSAV` was the counter-example and is the model -- it evaluates its argument
and handles an array element correctly. The subscript resolution itself is now
shared: `collect_subscripts()` comes out of src/environment.c as
`akbasic_environment_collect_subscripts()`, so a verb taking a variable by name
resolves a subscript the same way assignment does rather than each verb deciding
for itself.
tests/graphics_verbs.c covers TODO.md's reduction -- which used to print
"[SHAPE:0] []" and now prints "[] [SHAPE:0]" -- and the case a program actually
wants: two shapes captured into two elements, each stamped back through its own,
asserted against the device log so a fix that merely made the strings look right
would not pass.
Chapter 18's trap 4 becomes history, and Chapter 6 says an array works.
TODO.md section 9 item 6, struck.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 00:10:24 -04:00
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` :
Add two Breakout examples and the tutorials that build them
Two complete games in `examples/breakout/`, both 100% BASIC: `characters/`
draws its wall in the text grid with two `DATA` sprites for the ball and
paddle, and `sprites/` loads CC0 artwork and captures its whole screen with
`SSHAPE`/`SPRSAV`. They take opposite shapes for reasons that are entirely
this interpreter's, which is what the chapters are for.
`docs/17-tutorial-breakout.md` and `docs/18-tutorial-breakout-artwork.md`
build each one a step at a time, and end in a checklist of the rules a real
program runs into: create every name before the loop starts, write a text row
whole, loop with `GOTO` rather than `DO`, put the float on the left. Every
trap is a runnable block with its own output rather than a claim -- the value
pool dying at four thousand names, the skipped `BEGIN` block that breaks its
caller's `RETURN`, `SSHAPE` ignoring a subscript, `READ`'s single cursor.
Five figures, generated from the listings beside them by `docs_screenshots`,
and a `breakout_art` setup so the ones that load artwork load the example's
own. The character game's wall cannot be photographed -- the screenshot host
omits the text layer on purpose -- so it is shown as compared output instead.
`docs/07-sound.md` never said `SOUND`'s frequency is a SID register value
rather than hertz, which both games depend on. It says so now, with the
conversion from `src/audio_tables.c:84`.
`TODO.md` gains the thirteen defects the two games turned up -- §6 items 30
to 33 and all of §9 -- each with a reduction that fits on a screen, the file
and line of the cause, and what a fix would touch.
Verified: `docs_examples` passes in both build configurations,
`docs_screenshots --check` re-renders all thirteen figures and byte-compares
them, the full 109-test suite passes in both builds, every quoted fragment
was checked to appear verbatim in the listing it came from, and every
relative link and anchor resolves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:54:42 -04:00
```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.
Accept GRAPHIC CLR and a negative DATA item
Two parse handlers refusing something the documentation promises. Landing
together because they are the same defect twice -- a verb's own argument shape
falling through to a general path that cannot see it -- in one file, found by
one program, and verified in one pass.
**`GRAPHIC CLR`** is given as `GRAPHIC mode | CLR` in both docs/06-graphics.md
and docs/11-verb-reference.md, and was refused: `CLR` is a verb of its own, so
the generic arglist path scanned it as a command token and the expression parser
answered "Expected expression or literal". `akbasic_parse_graphic()` takes it as
this verb's keyword argument and emits mode 5 -- which `akbasic_cmd_graphic()`
already treats as "drop the saved shapes and go back to text", so both spellings
are one statement and the exec handler is untouched. The documentation was right
all along; nothing in it changes.
**`DATA -5`** was refused by `akbasic_parse_data()`, and only there: `READ` scans
the source text directly (src/data.c) and always returned the -5 intact. So the
value was right and *reaching* the statement raised -- which, since section 4
settled that `DATA` at run time is a no-op, is what a program does with every
`DATA` line it walks past. A table of coordinates or velocities is full of
negative numbers, which is how a game found it.
The fix accepts a unary minus over a numeric literal and nothing else: `DATA -A#`
is still a mistake worth naming, and `akbasic_leaf_is_literal()` keeps meaning
what it says because other callers rely on it.
tests/read_data.c covers both mechanisms -- reading a negative item and reaching
the line after it -- with a mixed-sign table and a negative float, since the two
were never the same code path.
TODO.md section 9 items 7 and 8, struck.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 00:18:53 -04:00
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
Add two Breakout examples and the tutorials that build them
Two complete games in `examples/breakout/`, both 100% BASIC: `characters/`
draws its wall in the text grid with two `DATA` sprites for the ball and
paddle, and `sprites/` loads CC0 artwork and captures its whole screen with
`SSHAPE`/`SPRSAV`. They take opposite shapes for reasons that are entirely
this interpreter's, which is what the chapters are for.
`docs/17-tutorial-breakout.md` and `docs/18-tutorial-breakout-artwork.md`
build each one a step at a time, and end in a checklist of the rules a real
program runs into: create every name before the loop starts, write a text row
whole, loop with `GOTO` rather than `DO`, put the float on the left. Every
trap is a runnable block with its own output rather than a claim -- the value
pool dying at four thousand names, the skipped `BEGIN` block that breaks its
caller's `RETURN`, `SSHAPE` ignoring a subscript, `READ`'s single cursor.
Five figures, generated from the listings beside them by `docs_screenshots`,
and a `breakout_art` setup so the ones that load artwork load the example's
own. The character game's wall cannot be photographed -- the screenshot host
omits the text layer on purpose -- so it is shown as compared output instead.
`docs/07-sound.md` never said `SOUND`'s frequency is a SID register value
rather than hertz, which both games depend on. It says so now, with the
conversion from `src/audio_tables.c:84`.
`TODO.md` gains the thirteen defects the two games turned up -- §6 items 30
to 33 and all of §9 -- each with a reduction that fits on a screen, the file
and line of the cause, and what a fix would touch.
Verified: `docs_examples` passes in both build configurations,
`docs_screenshots --check` re-renders all thirteen figures and byte-compares
them, the full 109-test suite passes in both builds, every quoted fragment
was checked to appear verbatim in the listing it came from, and every
relative link and anchor resolves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:54:42 -04:00
`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.
Accept GRAPHIC CLR and a negative DATA item
Two parse handlers refusing something the documentation promises. Landing
together because they are the same defect twice -- a verb's own argument shape
falling through to a general path that cannot see it -- in one file, found by
one program, and verified in one pass.
**`GRAPHIC CLR`** is given as `GRAPHIC mode | CLR` in both docs/06-graphics.md
and docs/11-verb-reference.md, and was refused: `CLR` is a verb of its own, so
the generic arglist path scanned it as a command token and the expression parser
answered "Expected expression or literal". `akbasic_parse_graphic()` takes it as
this verb's keyword argument and emits mode 5 -- which `akbasic_cmd_graphic()`
already treats as "drop the saved shapes and go back to text", so both spellings
are one statement and the exec handler is untouched. The documentation was right
all along; nothing in it changes.
**`DATA -5`** was refused by `akbasic_parse_data()`, and only there: `READ` scans
the source text directly (src/data.c) and always returned the -5 intact. So the
value was right and *reaching* the statement raised -- which, since section 4
settled that `DATA` at run time is a no-op, is what a program does with every
`DATA` line it walks past. A table of coordinates or velocities is full of
negative numbers, which is how a game found it.
The fix accepts a unary minus over a numeric literal and nothing else: `DATA -A#`
is still a mistake worth naming, and `akbasic_leaf_is_literal()` keeps meaning
what it says because other callers rely on it.
tests/read_data.c covers both mechanisms -- reading a negative item and reaching
the line after it -- with a mixed-sign table and a negative float, since the two
were never the same code path.
TODO.md section 9 items 7 and 8, struck.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 00:18:53 -04:00
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
Add two Breakout examples and the tutorials that build them
Two complete games in `examples/breakout/`, both 100% BASIC: `characters/`
draws its wall in the text grid with two `DATA` sprites for the ball and
paddle, and `sprites/` loads CC0 artwork and captures its whole screen with
`SSHAPE`/`SPRSAV`. They take opposite shapes for reasons that are entirely
this interpreter's, which is what the chapters are for.
`docs/17-tutorial-breakout.md` and `docs/18-tutorial-breakout-artwork.md`
build each one a step at a time, and end in a checklist of the rules a real
program runs into: create every name before the loop starts, write a text row
whole, loop with `GOTO` rather than `DO`, put the float on the left. Every
trap is a runnable block with its own output rather than a claim -- the value
pool dying at four thousand names, the skipped `BEGIN` block that breaks its
caller's `RETURN`, `SSHAPE` ignoring a subscript, `READ`'s single cursor.
Five figures, generated from the listings beside them by `docs_screenshots`,
and a `breakout_art` setup so the ones that load artwork load the example's
own. The character game's wall cannot be photographed -- the screenshot host
omits the text layer on purpose -- so it is shown as compared output instead.
`docs/07-sound.md` never said `SOUND`'s frequency is a SID register value
rather than hertz, which both games depend on. It says so now, with the
conversion from `src/audio_tables.c:84`.
`TODO.md` gains the thirteen defects the two games turned up -- §6 items 30
to 33 and all of §9 -- each with a reduction that fits on a screen, the file
and line of the cause, and what a fix would touch.
Verified: `docs_examples` passes in both build configurations,
`docs_screenshots --check` re-renders all thirteen figures and byte-compares
them, the full 109-test suite passes in both builds, every quoted fragment
was checked to appear verbatim in the listing it came from, and every
relative link and anchor resolves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:54:42 -04:00
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` .
Keep what a program draws, instead of making it a sprite
A drawing lasted exactly one frame. The verbs are immediate, they went to the
back buffer, SDL double-buffers and the frontend never clears -- so the only way
to keep a picture was to capture it with `SSHAPE` and install it as a sprite,
which is what `examples/breakout/sprites/breakout.bas` spends two of its eight
sprites doing. That was TODO.md section 9 item 9.
The drawing verbs now render into a layer texture the frame composites under the
text and the sprites. Draw once; it is there on every frame after.
**Bracketed around the step phase, not around each verb.** One pair of
`SDL_SetRenderTarget` calls a frame instead of one per `DRAW`, and it is also
what makes `SSHAPE` read back what the program has just drawn rather than
whatever the last frame left.
**The layer is transparent where nothing was drawn.** It covers the whole window
and composites underneath, so an opaque one would black out the frame the moment
a program issued a single `DRAW`. And a fresh SDL target texture's contents are
undefined, so it is cleared on creation -- skipping that puts uninitialised
memory under the first frame's text and looks like a driver bug rather than a
missing memset.
**The line editor forced a wrinkle worth naming.** `akbasic_frontend_akgl_pump()`
is called from two places with different answers to "is a render target current":
the frame loop calls it between steps, and the sink's editor calls it from
*inside* a step, borrowing a frame while it waits for a typed line. SDL refuses
to present while a target is current, so the pump ends the layer, presents, and
puts it back only if it was the one that ended it. `akgl_frontend` caught this --
it drives a REPL session, and it failed with "You can't present on a render
target" the first time the brackets went in.
This does not make a drawing *visible* on its own. The text layer still repaints
every row it owns, opaque, every frame, and by default it owns the whole window;
`WINDOW` shrinks it and that half was already fixed. The two together are what a
picture needed, and the tests assert both -- a pixel still there a frame later
with nothing redrawn, and a pixel below a shrunk text area surviving the text
repaint. The second assertion wipes to a non-black colour first, because against
black it could not tell a transparent layer from an opaque one.
The tests found two of their own bugs on the way: `stop_runtime()` was not
tearing the graphics backend down, so re-initialising it dropped a live texture
on the floor; and a first draft called `begin()` before `start_runtime()`, which
re-inits the backend, so the assertion read back off an orphaned render target
and passed while proving nothing.
Chapters 6 and 13 stop saying a drawing has to be redrawn every frame, because it
does not. The batch-boundary tear stays documented -- it bites an `SSHAPE`
capture, which matters much less now that capturing is not the only way to keep a
picture.
Both games still run clean. 111 with akgl, 110 without.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
2026-08-02 10:46:10 -04:00
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.**~~
**Done for the interpreter; the listing has not been converted yet -- that is §6 item 39. **
The drawing verbs now render into a layer the frame composites under the text and the
sprites (`akbasic_graphics_akgl_begin` /`_end` /`_render` , `src/graphics_akgl.c` ), so a
drawing survives the frame it was made in. With `WINDOW` already reachable to shrink the
text area, the two halves together are what a picture needed: draw it once, uncover it,
and it stays. `tests/akgl_backends.c` asserts both -- a pixel still present a frame later
with nothing redrawn, and a pixel below a shrunk text area surviving the text repaint.
**The layer is transparent where nothing was drawn ** , which is not a detail: it covers the
whole window and composites * underneath * , so an opaque one would black out the frame the
moment a program issued a single `DRAW` . A fresh SDL target texture's contents are
undefined, so it is cleared on creation; skipping that put uninitialised memory under the
first frame's text and looked like a driver bug.
The remaining half of item 3 -- whether the text layer * should * own the whole window by
default -- is untouched and is still a real question rather than a defect.
The original report:
File the four things this work deliberately left alone
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
2026-08-02 09:51:17 -04:00
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.
Add two Breakout examples and the tutorials that build them
Two complete games in `examples/breakout/`, both 100% BASIC: `characters/`
draws its wall in the text grid with two `DATA` sprites for the ball and
paddle, and `sprites/` loads CC0 artwork and captures its whole screen with
`SSHAPE`/`SPRSAV`. They take opposite shapes for reasons that are entirely
this interpreter's, which is what the chapters are for.
`docs/17-tutorial-breakout.md` and `docs/18-tutorial-breakout-artwork.md`
build each one a step at a time, and end in a checklist of the rules a real
program runs into: create every name before the loop starts, write a text row
whole, loop with `GOTO` rather than `DO`, put the float on the left. Every
trap is a runnable block with its own output rather than a claim -- the value
pool dying at four thousand names, the skipped `BEGIN` block that breaks its
caller's `RETURN`, `SSHAPE` ignoring a subscript, `READ`'s single cursor.
Five figures, generated from the listings beside them by `docs_screenshots`,
and a `breakout_art` setup so the ones that load artwork load the example's
own. The character game's wall cannot be photographed -- the screenshot host
omits the text layer on purpose -- so it is shown as compared output instead.
`docs/07-sound.md` never said `SOUND`'s frequency is a SID register value
rather than hertz, which both games depend on. It says so now, with the
conversion from `src/audio_tables.c:84`.
`TODO.md` gains the thirteen defects the two games turned up -- §6 items 30
to 33 and all of §9 -- each with a reduction that fits on a screen, the file
and line of the cause, and what a fix would touch.
Verified: `docs_examples` passes in both build configurations,
`docs_screenshots --check` re-renders all thirteen figures and byte-compares
them, the full 109-test suite passes in both builds, every quoted fragment
was checked to appear verbatim in the listing it came from, and every
relative link and anchor resolves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:54:42 -04:00
**A note on what this list is.** Items 1, 2, 6, 7 and 8 are defects by any reading. Item 3 is a
File the four things this work deliberately left alone
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
2026-08-02 09:51:17 -04:00
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,
Add two Breakout examples and the tutorials that build them
Two complete games in `examples/breakout/`, both 100% BASIC: `characters/`
draws its wall in the text grid with two `DATA` sprites for the ball and
paddle, and `sprites/` loads CC0 artwork and captures its whole screen with
`SSHAPE`/`SPRSAV`. They take opposite shapes for reasons that are entirely
this interpreter's, which is what the chapters are for.
`docs/17-tutorial-breakout.md` and `docs/18-tutorial-breakout-artwork.md`
build each one a step at a time, and end in a checklist of the rules a real
program runs into: create every name before the loop starts, write a text row
whole, loop with `GOTO` rather than `DO`, put the float on the left. Every
trap is a runnable block with its own output rather than a claim -- the value
pool dying at four thousand names, the skipped `BEGIN` block that breaks its
caller's `RETURN`, `SSHAPE` ignoring a subscript, `READ`'s single cursor.
Five figures, generated from the listings beside them by `docs_screenshots`,
and a `breakout_art` setup so the ones that load artwork load the example's
own. The character game's wall cannot be photographed -- the screenshot host
omits the text layer on purpose -- so it is shown as compared output instead.
`docs/07-sound.md` never said `SOUND`'s frequency is a SID register value
rather than hertz, which both games depend on. It says so now, with the
conversion from `src/audio_tables.c:84`.
`TODO.md` gains the thirteen defects the two games turned up -- §6 items 30
to 33 and all of §9 -- each with a reduction that fits on a screen, the file
and line of the cause, and what a fix would touch.
Verified: `docs_examples` passes in both build configurations,
`docs_screenshots --check` re-renders all thirteen figures and byte-compares
them, the full 109-test suite passes in both builds, every quoted fragment
was checked to appear verbatim in the listing it came from, and every
relative link and anchor resolves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:54:42 -04:00
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.