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
|
|
|
|
# 14. Architecture
|
|
|
|
|
|
|
|
|
|
|
|
This chapter is for whoever has to understand the interpreter from the inside: to
|
|
|
|
|
|
embed it, to debug something it did, or to change it. Everything before this chapter
|
|
|
|
|
|
describes the language. This one describes the machine that runs it.
|
|
|
|
|
|
|
|
|
|
|
|
It is deliberately not a repeat of the API. **The headers under `include/akbasic/` are
|
|
|
|
|
|
the authority on every function's contract** — `doxygen Doxyfile` renders them, and each
|
|
|
|
|
|
one carries its parameters, its return, and the errors it raises. Where a rule is
|
|
|
|
|
|
subtle, the header comment states it at more length than a chapter should. What is here
|
|
|
|
|
|
instead is the shape: what talks to what, in what order, and why it was built that way.
|
|
|
|
|
|
|
|
|
|
|
|
| Question | Read |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| What does this function do? | the header, or the Doxygen it renders to |
|
|
|
|
|
|
| What does this verb do? | [Chapter 11](11-verb-reference.md), and `src/runtime_*.c` |
|
|
|
|
|
|
| Why is it built like this? | this chapter |
|
|
|
|
|
|
| What are the conventions for changing it? | [`MAINTENANCE.md`](../MAINTENANCE.md) |
|
|
|
|
|
|
| What is currently wrong with it? | [`TODO.md`](../TODO.md) |
|
|
|
|
|
|
|
|
|
|
|
|
## Three libraries and a driver
|
|
|
|
|
|
|
|
|
|
|
|
The build separates the interpreter from everything that could own a screen, and the
|
|
|
|
|
|
separation is in the build graph rather than in a comment.
|
|
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
|
+---------------------------+ +---------------------------+
|
|
|
|
|
|
| basic (src/main.c) | | your game |
|
|
|
|
|
|
| argv, QUIT, | | window, renderer, |
|
|
|
|
|
|
| FINISH_NORETURN | | frame loop |
|
|
|
|
|
|
+-------------+-------------+ +-------------+-------------+
|
|
|
|
|
|
| |
|
|
|
|
|
|
+-------------v-------------+ |
|
|
|
|
|
|
| akbasic_frontend | | a host that already
|
|
|
|
|
|
| SDL window, font, event | | has a renderer skips
|
|
|
|
|
|
| pump, 256 steps a frame | | this target entirely
|
|
|
|
|
|
+-------------+-------------+ |
|
|
|
|
|
|
| |
|
|
|
|
|
|
+-------------v---------------------------------v-------------+
|
|
|
|
|
|
| akbasic_akgl sink | graphics | audio | input | sprites |
|
|
|
|
|
|
| function-pointer records that draw and play |
|
|
|
|
|
|
| through a renderer somebody else created |
|
|
|
|
|
|
+------------------------------+------------------------------+
|
|
|
|
|
|
|
|
|
|
|
|
|
+------------------------------v------------------------------+
|
|
|
|
|
|
| akbasic scanner -> parser -> evaluator, the pools, |
|
|
|
|
|
|
| the verb table, values, environments |
|
|
|
|
|
|
| no SDL, no malloc, no exit(), no globals |
|
|
|
|
|
|
+------------------------------+------------------------------+
|
|
|
|
|
|
|
|
|
|
|
|
|
+------------------------------v------------------------------+
|
|
|
|
|
|
| libakstdlib / libakerror |
|
|
|
|
|
|
+-------------------------------------------------------------+
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
| Target | What it is | A game links it? |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| `akbasic` | The interpreter. No SDL, nothing that terminates the process | Always |
|
|
|
|
|
|
| `akbasic_akgl` | The sink and the four device backends, drawing through *your* renderer | If you want them |
|
|
|
|
|
|
| `akbasic_frontend` | The standalone program's host: creates the window, owns the loop | **No.** You are the host |
|
|
|
|
|
|
| `basic` | The driver: argv, `QUIT`, and the one `FINISH_NORETURN` in the tree | — |
|
|
|
|
|
|
|
|
|
|
|
|
Where a thing lives:
|
|
|
|
|
|
|
|
|
|
|
|
| Area | Source |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| Tokens, AST leaves | `src/grammar.c`, `include/akbasic/grammar.h` |
|
|
|
|
|
|
| Scanner | `src/scanner.c` |
|
|
|
|
|
|
| Parser | `src/parser.c`, and the verbs with their own syntax in `src/parser_commands.c` |
|
|
|
|
|
|
| The step loop, evaluation, pools | `src/runtime.c` |
|
|
|
|
|
|
| Verb and function implementations | `src/runtime_*.c`, one file per verb group |
|
|
|
|
|
|
| The dispatch table | `src/verbs.c` |
|
|
|
|
|
|
| Scopes and block state | `src/environment.c` |
|
|
|
|
|
|
| Values, variables, symbol table | `src/value.c`, `src/variable.c`, `src/symtab.c` |
|
|
|
|
|
|
| Text sinks | `src/sink_stdio.c`, `src/sink_tee.c`, `src/sink_akgl.c` |
|
|
|
|
|
|
| Device backends | `src/graphics_akgl.c`, `src/audio_akgl.c`, `src/input_akgl.c`, `src/sprite_akgl.c` |
|
|
|
|
|
|
|
|
|
|
|
|
## One struct holds everything
|
|
|
|
|
|
|
|
|
|
|
|
There is no file-scope mutable state anywhere in the library. Every pool, every cursor
|
|
|
|
|
|
and every piece of subsystem state hangs off one `akbasic_Runtime`, which the caller
|
|
|
|
|
|
owns and passes by pointer. Two interpreters in one process do not interfere, which is
|
|
|
|
|
|
the whole reason for the rule.
|
|
|
|
|
|
|
|
|
|
|
|
It is several megabytes, because the pools are inline. Put it in static storage or in
|
|
|
|
|
|
your own state — never on a default stack. `src/main.c`, `tests/harness.h` and both
|
|
|
|
|
|
examples all do the same thing for the same reason.
|
|
|
|
|
|
|
|
|
|
|
|
`akbasic_Runtime` carries five kinds of thing, and it is worth knowing which is which
|
|
|
|
|
|
before you go reading it:
|
|
|
|
|
|
|
|
|
|
|
|
| Kind | Fields |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| The program | `source[]`, indexed **by line number**, and `sourcepath` |
|
|
|
|
|
|
| Pools | `environments[]`, `variables[]`, `functions[]`, `valuepool` |
|
|
|
|
|
|
| Execution state | `mode`, `run_finished_mode`, `environment`, `errclass`, `skiprestofline`, `trace`, `stopped` |
|
|
|
|
|
|
| Borrowed devices | `sink`, `graphics`, `audio`, `input`, `sprites` — any of the last four may be `NULL` |
|
|
|
|
|
|
| Per-subsystem BASIC state | `gfx`, `audio_state`, `input_state`, `sprite_state`, `format_state`, `console_state`, `data_state`, `disk_state` |
|
|
|
|
|
|
|
|
|
|
|
|
That last row is the one people ask about. `COLOR`, `TEMPO`, `SCALE`, the `PUDEF`
|
|
|
|
|
|
characters and the eight sprites' positions live on the *runtime*, not on the device
|
|
|
|
|
|
that renders them, because they are the program's state and not the hardware's. A host
|
|
|
|
|
|
that swaps one renderer for another does not expect the script's colours to go with it,
|
|
|
|
|
|
and `RSPPOS` answers correctly with no sprite device attached at all.
|
|
|
|
|
|
|
|
|
|
|
|
`source[]` being indexed by line number is worth pausing on. A line's number *is* its
|
|
|
|
|
|
array slot, so `GOTO 500` is one assignment — `environment->nextline = 500` — and no
|
|
|
|
|
|
search. Blank slots are skipped by the step loop. That is why line numbers are capped at
|
|
|
|
|
|
9998 and why a nine-line program still costs a 9999-entry array.
|
|
|
|
|
|
|
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
|
|
|
|
It is also why **a loaded line does not have to arrive with a number**. Execution needs a
|
|
|
|
|
|
slot, not a number somebody typed, so a line that comes without one is simply given the
|
|
|
|
|
|
slot after the last line filed. `akbasic_runtime_file_line()` is the one place that rule
|
|
|
|
|
|
lives, shared by `akbasic_runtime_load()`, `RUNSTREAM` and `DLOAD`; the step loop, the
|
|
|
|
|
|
branch verbs, the four prescans and `RENUMBER` never learn that anything changed, because
|
|
|
|
|
|
all of them already worked in slots. `akbasic_SourceLine::numbered` records which kind a
|
|
|
|
|
|
line was, and exactly one thing reads it — `akbasic_runtime_check_targets()`, which
|
|
|
|
|
|
refuses `GOTO 100` when line 100 is a number nobody wrote.
|
|
|
|
|
|
|
|
|
|
|
|
The prompt is deliberately outside all of this: `process_line_repl()` files a line only
|
|
|
|
|
|
when it *had* a number, because there the number is what separates program text from a
|
|
|
|
|
|
statement to run now.
|
|
|
|
|
|
|
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
|
|
|
|
## One step
|
|
|
|
|
|
|
|
|
|
|
|
`akbasic_runtime_step()` is the whole loop, unrolled to a single iteration. The
|
|
|
|
|
|
reference's `run()` did not return until the program quit; here the caller owns the loop
|
|
|
|
|
|
and the library owns exactly one turn of it.
|
|
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
|
akbasic_runtime_step(rt)
|
|
|
|
|
|
|
|
|
|
|
|
|
+-- akbasic_play_service() release the next queued note if its time is up
|
|
|
|
|
|
+-- akbasic_sprite_service() advance continuous MOVSPR motion
|
|
|
|
|
|
+-- akbasic_collision_service() look for collisions where the sprites now are
|
|
|
|
|
|
|
|
|
|
|
|
|
+-- mode == QUIT ? ------------------------------------> return
|
|
|
|
|
|
|
|
|
|
|
|
|
+-- akbasic_input_service() GETKEY waiting? -----> return (no line runs)
|
|
|
|
|
|
+-- akbasic_console_update_clock()
|
|
|
|
|
|
+-- akbasic_console_service() SLEEP or WAIT holding? -> return
|
|
|
|
|
|
|
|
|
|
|
|
|
+-- akbasic_runtime_zero() reset the per-line value pool
|
|
|
|
|
|
+-- akbasic_scanner_zero() reset the scanner cursor
|
|
|
|
|
|
|
|
|
|
|
|
|
+-- switch ( mode )
|
|
|
|
|
|
| RUNSTREAM -> process_line_runstream() read a line, file it
|
|
|
|
|
|
| REPL -> process_line_repl() read a line, run it or file it
|
|
|
|
|
|
| RUN -> service_interrupts() enter a handler if one is due
|
|
|
|
|
|
| process_line_run() run source[nextline]
|
|
|
|
|
|
|
|
|
|
|
|
|
+-- errclass set ? -> set_mode(run_finished_mode)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Three things in that order are deliberate.
|
|
|
|
|
|
|
|
|
|
|
|
**The service calls run before the `QUIT` check.** A program's last notes still come out
|
|
|
|
|
|
while a host keeps calling `step()` after the script has ended.
|
|
|
|
|
|
|
|
|
|
|
|
**They run before the blocking checks, too.** A program sitting in `GETKEY` still has its
|
|
|
|
|
|
music paced and its sprites moved. `GETKEY` holding the program means *this step
|
|
|
|
|
|
executes no source line* — it does not mean the step does not return. It always returns.
|
|
|
|
|
|
|
|
|
|
|
|
**Interrupts are serviced between lines and nowhere else.** 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.
|
|
|
|
|
|
|
|
|
|
|
|
`akbasic_runtime_run(rt, n)` is `step()` in a `while` with a budget: at most `n` steps,
|
|
|
|
|
|
then return regardless. That bound is the only thing standing between a script containing
|
|
|
|
|
|
`10 GOTO 10` and your frame rate. `n <= 0` means unbounded, which is right for a test and
|
Write down that a redraw has to fit inside one batch
"Drawing does not persist across frames; redraw it every frame" was the advice,
and it is not sufficient on its own. The host runs a fixed number of source
lines and then presents, and presenting discards the drawing buffer -- so a run
of drawing verbs longer than one budget is **torn**, not merely transient, and
an `SSHAPE` at the end of it captures only what was issued since the present,
over whatever the frame before left behind.
Measured against the standalone frontend's 256: after synchronising to a jiffy
edge, 220 lines of drawing survive a capture and 250 do not.
There is no fix available that does not change what a host owns -- the budget is
the host's and so is the present -- so this is documentation, in the three
places it belongs. Chapter 13 beside the note it qualifies, with the numbers.
Chapter 6 where `SSHAPE` is introduced, which is where a program meets it.
Chapter 14 from the step loop's side, naming `AKBASIC_FRONTEND_STEPS_PER_FRAME`
and explaining the one thing a script can do about it: `settime()` is called once
a frame, so `TI#` changes on the first step of a batch and nowhere else, and
spinning until it changes is the only frame synchronisation this dialect has.
**Untested, deliberately.** Reproducing it needs the real frontend, a clock and
a timing window, and a test that reproduced it would be reproducing a race.
Recorded in TODO.md rather than left looking like an oversight.
TODO.md section 9 item 5, struck as a documentation outcome.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 00:23:36 -04:00
|
|
|
|
wrong for a game. The SDL frontend uses 256 (`AKBASIC_FRONTEND_STEPS_PER_FRAME`); the
|
|
|
|
|
|
stdio driver uses 1, because it wants to refresh the clock between steps.
|
|
|
|
|
|
|
|
|
|
|
|
**That budget is visible to a script, and drawing is where it shows.** The frontend
|
|
|
|
|
|
presents the frame when `run()` comes back, and presenting discards the drawing buffer —
|
|
|
|
|
|
so a sequence of drawing verbs longer than one budget is cut in half by the present in
|
|
|
|
|
|
the middle of it, and an `SSHAPE` afterwards captures only what was issued since. Not a
|
|
|
|
|
|
transient artefact: a torn capture, over whatever the frame before left behind.
|
|
|
|
|
|
|
|
|
|
|
|
A script cannot read the budget, but it can *see* it: `akbasic_runtime_settime()` is
|
|
|
|
|
|
called once per frame by the host, so `TI#` changes on the first step of a batch and
|
|
|
|
|
|
nowhere else. Spinning until it changes is the only frame synchronisation this dialect
|
|
|
|
|
|
has, and it is what
|
|
|
|
|
|
[Chapter 18](18-tutorial-breakout-artwork.md#step-4-find-the-frame-boundary) is built on.
|
|
|
|
|
|
A host that gives the interpreter a larger budget makes more drawing fit; one that never
|
|
|
|
|
|
calls `settime()` takes the synchronisation away entirely.
|
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
|
|
|
|
|
|
|
|
|
|
Time comes in from outside. `akbasic_runtime_settime(rt, ms)` is how the interpreter
|
|
|
|
|
|
learns what time it is; it reads no clock, because it owns no loop and must not block. A
|
|
|
|
|
|
host that never calls it leaves time frozen at zero, and every duration then expires on
|
|
|
|
|
|
the step after it starts — audible, but never a hang. That is the intended way for it to
|
|
|
|
|
|
fail.
|
|
|
|
|
|
|
|
|
|
|
|
## Four modes
|
|
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
|
akbasic_runtime_start(rt, mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
+-------------------+-------------------+
|
|
|
|
|
|
| |
|
|
|
|
|
|
v v
|
|
|
|
|
|
RUNSTREAM REPL <---------------+
|
|
|
|
|
|
read a line, file it, read a line: file it if it |
|
|
|
|
|
|
execute nothing has a number, run it if not |
|
|
|
|
|
|
| | |
|
|
|
|
|
|
| end of input RUN, CONT | |
|
|
|
|
|
|
+---------------> RUN <---------------+ |
|
|
|
|
|
|
| |
|
|
|
|
|
|
| END, STOP, a BASIC error, or |
|
|
|
|
|
|
| running off the end of the source |
|
|
|
|
|
|
v |
|
|
|
|
|
|
run_finished_mode ---------- == REPL ----------+
|
|
|
|
|
|
|
|
|
|
|
|
|
| == QUIT, or the QUIT verb, or
|
|
|
|
|
|
v end of input at the prompt
|
|
|
|
|
|
QUIT
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
| Mode | What one step does | How it leaves |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| `AKBASIC_MODE_REPL` | Read one line from the sink. With a line number, file it; without one, **run it now** | `QUIT` verb, or end of input |
|
|
|
|
|
|
| `AKBASIC_MODE_RUN` | Execute `source[nextline]` | Running off the end, `END`, `STOP`, or a BASIC error |
|
|
|
|
|
|
| `AKBASIC_MODE_RUNSTREAM` | Read one line from the sink and file it. Executes nothing | End of input, which switches to `RUN` |
|
|
|
|
|
|
| `AKBASIC_MODE_QUIT` | Nothing | — |
|
|
|
|
|
|
|
|
|
|
|
|
`run_finished_mode` decides where a finished program lands. `akbasic_runtime_start(rt,
|
|
|
|
|
|
AKBASIC_MODE_REPL)` sets it to `REPL`, so a program that ends drops back to a prompt;
|
|
|
|
|
|
anything else sets it to `QUIT`, so `basic program.bas` exits. It is one field, and it is
|
|
|
|
|
|
the whole difference between an interactive session and a script runner.
|
|
|
|
|
|
|
|
|
|
|
|
**`akbasic_runtime_set_mode()` is not just an assignment.** Entering `REPL` prints
|
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
|
|
|
|
`READY`. Entering `RUN` does four prescans of the whole program first:
|
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
|
|
|
|
|
|
|
|
|
|
- **Labels.** Every `LABEL` in the source is filed before anything executes, textually
|
|
|
|
|
|
rather than by parsing. Without it a label would exist only from the moment its `LABEL`
|
|
|
|
|
|
statement ran, so `GOTO` could reach backwards and never forwards — and an interrupt
|
|
|
|
|
|
handler, which by definition sits where normal flow does not fall, could not be named
|
|
|
|
|
|
by label at all.
|
|
|
|
|
|
- **`DATA` items.** `READ` walks a cursor along a list built before the program runs, so a
|
|
|
|
|
|
`DATA` line above its `READ` is found.
|
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
|
|
|
|
- **`TYPE` declarations.** A declaration has to be in effect wherever control goes, so
|
|
|
|
|
|
`DIM P@ AS RECT` cannot run before `RECT` exists even if a branch skipped the lines that
|
|
|
|
|
|
declared it.
|
|
|
|
|
|
- **Branch targets.** The only one that reads nothing in and merely refuses: a numeric
|
|
|
|
|
|
`GOTO`, `GOSUB`, `RUN`, `RESTORE`, `TRAP` or `COLLISION` target naming a line the program
|
|
|
|
|
|
did not number. It shares `RENUMBER`'s walk in `src/renumber.c` rather than repeating it.
|
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
|
|
|
|
|
|
|
|
|
|
Every path into a run — `akbasic_runtime_start()`, the `RUN` verb, `CONT`, and the end of
|
|
|
|
|
|
a `RUNSTREAM` load — goes through that one function, which is why the prescans live there
|
|
|
|
|
|
and not in any of the four callers.
|
|
|
|
|
|
|
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
|
|
|
|
All four run inside one `ATTEMPT`, because **a prescan failure is the program's mistake
|
|
|
|
|
|
and not the host's** and has to leave as a BASIC error line rather than a raised context.
|
|
|
|
|
|
Three of them report against whichever line the loader stopped on and put the real one in
|
|
|
|
|
|
the message text; the branch-target scan points `environment->lineno` at the line it is
|
|
|
|
|
|
walking so the `? N :` prefix is right. `TODO.md` §5 item 64 records the difference.
|
|
|
|
|
|
|
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
|
|
|
|
The REPL's split between *file it* and *run it now* is one boolean: the scanner sets
|
|
|
|
|
|
`hadlinenumber` when the line it just read began with a number. A line typed with a
|
|
|
|
|
|
number is program text; a line typed without one is direct mode and runs immediately.
|
|
|
|
|
|
|
|
|
|
|
|
## From a line of text to an effect
|
|
|
|
|
|
|
|
|
|
|
|
Nothing is compiled and nothing is cached. Every time a line executes it is scanned and
|
|
|
|
|
|
parsed again, from the source text, into per-line pools that are reset on the way in.
|
|
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
|
source[42] "IF A# = 5 THEN PRINT \"FIVE\""
|
|
|
|
|
|
|
|
|
|
|
|
|
| akbasic_scanner_scan() verb names via the dispatch table;
|
|
|
|
|
|
v no keyword maps of its own
|
|
|
|
|
|
environment->tokens[32] COMMAND(IF) IDENT_INT(A#) ASSIGNMENT LITERAL_INT(5)
|
|
|
|
|
|
| COMMAND(THEN) COMMAND(PRINT) LITERAL_STRING(FIVE)
|
|
|
|
|
|
|
|
|
|
|
|
|
| akbasic_parser_parse() one statement per call; the caller
|
|
|
|
|
|
v loops until the tokens are spent
|
|
|
|
|
|
environment->leaves[32] BRANCH
|
|
|
|
|
|
| / \
|
|
|
|
|
|
| (= A# 5) COMMAND PRINT
|
|
|
|
|
|
| |
|
|
|
|
|
|
| LITERAL_STRING "FIVE"
|
|
|
|
|
|
|
|
|
|
|
|
|
| akbasic_runtime_interpret() suppressed while this scope is
|
|
|
|
|
|
v skipping forward to a verb
|
|
|
|
|
|
akbasic_runtime_evaluate() switch on leaf type; a COMMAND leaf looks its
|
|
|
|
|
|
| handler up in the table and calls it
|
|
|
|
|
|
v
|
|
|
|
|
|
verb->exec() -> the sink, a device backend, or a variable
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
The statement loop lives in `process_line_run()` and `process_line_repl()`, not in the
|
|
|
|
|
|
parser: a line can hold several statements separated by colons, so the caller loops on
|
|
|
|
|
|
`akbasic_parser_is_at_end()`. **`akbasic_parser_parse()` may return `NULL` on success** —
|
|
|
|
|
|
that is a line that was nothing but separators — and a caller that does not skip it will
|
|
|
|
|
|
hand `NULL` to the interpreter.
|
|
|
|
|
|
|
|
|
|
|
|
`skiprestofline` is how a branch tells that loop to stop. BASIC 7.0 scopes everything
|
|
|
|
|
|
after `THEN` to the condition, but the parser takes only *one* statement per arm and the
|
|
|
|
|
|
rest arrive at the statement loop as ordinary top-level statements. So the branch raises a
|
|
|
|
|
|
flag and the loop obeys it. The rule is not "skip when false": the remainder belongs to
|
|
|
|
|
|
whichever arm was written last, so it is skipped exactly when *that* arm is the one not
|
|
|
|
|
|
taken. [Chapter 4](04-control-flow.md) has the truth table; the `AKBASIC_LEAF_BRANCH` case
|
|
|
|
|
|
in `akbasic_runtime_evaluate()` has the code.
|
|
|
|
|
|
|
|
|
|
|
|
Two details in that diagram catch people out:
|
|
|
|
|
|
|
|
|
|
|
|
**A lone `=` is scanned as `ASSIGNMENT`, not as equality.** The scanner cannot know
|
|
|
|
|
|
whether it is looking at a statement or a condition. The parser can: `akbasic_Parser`
|
|
|
|
|
|
carries a `comparing` flag, set around a condition and cleared afterwards, and while it is
|
|
|
|
|
|
set the relation rule accepts `ASSIGNMENT` as a seventh operator and rewrites it to
|
|
|
|
|
|
`EQUAL`. Outside a condition `=` has to stay an assignment or `FOR I# = 1 TO 5` never
|
|
|
|
|
|
initialises its counter.
|
|
|
|
|
|
|
|
|
|
|
|
**The token and leaf numbering is the Go reference's, on purpose.** `grammar.h` keeps the
|
|
|
|
|
|
original values so a debugging session against either implementation reads the same.
|
|
|
|
|
|
|
|
|
|
|
|
`akbasic_leaf_to_string()` renders a tree in prefix form — `(+ A# 42)`, `(group (+ A# 42))`
|
|
|
|
|
|
— which is the fastest way to see what the parser actually built. It is a plain function,
|
|
|
|
|
|
so you can call it from a debugger against any leaf pointer you have.
|
|
|
|
|
|
|
|
|
|
|
|
## The dispatch table
|
|
|
|
|
|
|
|
|
|
|
|
The Go reference resolved a verb by reflection: `MethodByName("Command" + NAME)`. C has
|
|
|
|
|
|
no reflection and none is being added. All three of its lookups — verbs, functions, and
|
|
|
|
|
|
verbs with their own parse path — collapse into one sorted table in `src/verbs.c`.
|
|
|
|
|
|
|
|
|
|
|
|
```c excerpt=include/akbasic/verbs.h
|
|
|
|
|
|
typedef struct
|
|
|
|
|
|
{
|
|
|
|
|
|
const char *name;
|
|
|
|
|
|
akbasic_TokenType tokentype;
|
|
|
|
|
|
int arity;
|
|
|
|
|
|
akbasic_ParseHandler parse;
|
|
|
|
|
|
akbasic_ExecHandler exec;
|
|
|
|
|
|
} akbasic_Verb;
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
One row per name, and both handlers are optional:
|
|
|
|
|
|
|
|
|
|
|
|
| Field | Meaning |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| `tokentype` | What the **scanner** gives this name. It has no keyword maps of its own |
|
|
|
|
|
|
| `arity` | Argument count for a function; `-1` where it does not apply |
|
|
|
|
|
|
| `parse == NULL` | The verb's rval parses as a plain expression |
|
|
|
|
|
|
| `parse != NULL` | The verb has its own syntax — `src/parser_commands.c` |
|
|
|
|
|
|
| `exec == NULL` | The token is consumed by another verb's parser and never evaluated alone: `THEN`, `ELSE`, `TO`, `STEP` |
|
|
|
|
|
|
|
|
|
|
|
|
`AKBASIC_TOK_COMMAND_IMMEDIATE` marks a verb the REPL may run against a line that *does*
|
|
|
|
|
|
carry a number — `RUN`, `LIST`, `NEW`, `DLOAD`. Everything else typed with a number is
|
|
|
|
|
|
filed as program text.
|
|
|
|
|
|
|
|
|
|
|
|
**The table is searched with `bsearch`, so it must stay sorted.** A mis-sorted row does
|
|
|
|
|
|
not fail to compile; it silently becomes an unfindable verb, and the symptom is
|
|
|
|
|
|
`Unknown command PRINT` a long way from the cause. `tests/verbs_table.c` asserts the
|
|
|
|
|
|
ordering, which is the only reason that is a safe thing to say.
|
|
|
|
|
|
|
|
|
|
|
|
## Nothing is allocated
|
|
|
|
|
|
|
|
|
|
|
|
Every object comes from a fixed pool inside the runtime. Exhausting one is a diagnosable
|
|
|
|
|
|
error naming the pool, not a crash and not a slow leak.
|
|
|
|
|
|
|
|
|
|
|
|
| Pool | Size | Lives on | Exhaustion says |
|
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
| `AKBASIC_MAX_SOURCE_LINES` | 9999 | runtime | `Line number N is outside 0..9998` |
|
|
|
|
|
|
| `AKBASIC_MAX_ENVIRONMENTS` | 32 | runtime | `Environment pool exhausted at line N (32 in use)` |
|
|
|
|
|
|
| `AKBASIC_MAX_VARIABLES` | 128 | runtime | `Maximum runtime variables reached` |
|
|
|
|
|
|
| `AKBASIC_MAX_FUNCTIONS` | 64 | runtime | `Maximum function definitions reached` |
|
|
|
|
|
|
| `AKBASIC_MAX_ARRAY_VALUES` | 4096 | runtime (`valuepool`) | `Array of N elements does not fit in the M remaining value slots` |
|
2026-08-02 00:32:20 -04:00
|
|
|
|
| — a scalar is not in that pool | 1 | **the variable** (`inlinevalue`) | — |
|
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
|
|
|
|
| `AKBASIC_MAX_TOKENS` | 32 | **environment** | `Line N has more than 32 tokens` |
|
|
|
|
|
|
| `AKBASIC_MAX_LEAVES` | 32 | **environment** | `No more leaves available` |
|
|
|
|
|
|
| `AKBASIC_MAX_VALUES` | 64 | **environment** | `Maximum values per line reached` |
|
|
|
|
|
|
|
|
|
|
|
|
The numbers are in `include/akbasic/types.h`, transcribed from the reference's `main.go`
|
|
|
|
|
|
plus three the Go version did not need because it called `make()`.
|
2026-08-02 00:32:20 -04:00
|
|
|
|
|
|
|
|
|
|
**The value pool is the one that needs a second sentence.** It is a bump allocator with
|
|
|
|
|
|
no free, so anything drawn from it is spent for the life of the run — and scope exit
|
|
|
|
|
|
returns a variable's *slot* without returning its storage. A scalar therefore does not
|
|
|
|
|
|
draw from it at all: `akbasic_variable_init()` points a one-element non-`@` variable at
|
|
|
|
|
|
its own `inlinevalue`, which is what makes a local, a `FOR` counter and a `DEF` parameter
|
|
|
|
|
|
free. Arrays and structures still spend, deliberately, because a pointer into a record is
|
|
|
|
|
|
allowed to outlive the scope that DIMmed it.
|
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
|
|
|
|
[Chapter 13](13-differences.md) states the same budget from a BASIC programmer's side.
|
|
|
|
|
|
|
|
|
|
|
|
The per-environment three are reset at the top of every line, which is what makes
|
|
|
|
|
|
"roughly 16 operations per line" a real limit and not a leak: a line that uses 30 leaves
|
|
|
|
|
|
is fine, and a line that needs 33 tokens is refused. Because they are per environment
|
|
|
|
|
|
rather than per runtime, a `FOR` body running inside a pushed scope gets its own 32.
|
|
|
|
|
|
|
|
|
|
|
|
`valuepool` is a bump allocator for array storage and it does not release. Nothing in
|
|
|
|
|
|
BASIC destroys a variable, so there is nothing to release *to* — but re-`DIM`ming an array
|
|
|
|
|
|
larger takes fresh slots and abandons the old ones, so a program that does that in a loop
|
|
|
|
|
|
will exhaust the pool. Bounded and diagnosable, which is the point.
|
|
|
|
|
|
|
|
|
|
|
|
## A scope is also the block state
|
|
|
|
|
|
|
|
|
|
|
|
`akbasic_Environment` does two jobs at once, and the second one is the surprising one. It
|
|
|
|
|
|
is a variable scope, chained to its parent — and it is the in-flight state of whatever
|
|
|
|
|
|
block structure is executing: the `FOR` bounds and step, the `DO`/`LOOP` condition, the
|
|
|
|
|
|
`GOSUB` return line, the `READ` cursor, and the line counters.
|
|
|
|
|
|
|
|
|
|
|
|
| What pushes a scope | What pops it |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| `FOR` — **during parsing**, not execution | the `NEXT` whose condition is met, or an `EXIT` |
|
|
|
|
|
|
| `GOSUB` | `RETURN` |
|
|
|
|
|
|
| A call to a multi-line user function | that function's `RETURN` |
|
|
|
|
|
|
| An interrupt firing | the handler's `RETURN` |
|
|
|
|
|
|
|
|
|
|
|
|
That `FOR` entry is not a typo. `akbasic_parse_for()` pushes the new environment while
|
|
|
|
|
|
parsing the line, parks `TO` and `STEP` in it as unevaluated leaves, and makes it active
|
|
|
|
|
|
only after parsing is finished — because the loop body is scanned against the *parent's*
|
|
|
|
|
|
token stream. If you are chasing a scope that appeared earlier than you expected, that is
|
|
|
|
|
|
why.
|
|
|
|
|
|
|
|
|
|
|
|
### `waitingForCommand`, and why loops work at all
|
|
|
|
|
|
|
|
|
|
|
|
A loop's condition is evaluated at the *bottom* of the structure, which leaves an obvious
|
|
|
|
|
|
problem: how does a zero-iteration `FOR` avoid running its body once? The answer is a
|
|
|
|
|
|
string on the environment.
|
|
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
|
10 FOR I# = 5 TO 1 FOR pushes a scope, evaluates the condition,
|
|
|
|
|
|
20 PRINT "NEVER" finds it already met, and calls
|
|
|
|
|
|
30 NEXT I# wait_for_command(env, "NEXT")
|
|
|
|
|
|
|
|
|
|
|
|
step: line 20 interpret() sees the scope is waiting, and the leaf is not
|
|
|
|
|
|
COMMAND "NEXT" -> evaluates nothing, returns static true
|
|
|
|
|
|
step: line 30 the leaf *is* COMMAND "NEXT" -> runs. NEXT stops the wait,
|
|
|
|
|
|
pops the scope, and hands nextline back to the parent
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
`akbasic_runtime_interpret()` is where that is enforced, and it is the first thing it
|
|
|
|
|
|
does. Five places arm a wait, and between them they are the whole of block structure:
|
|
|
|
|
|
|
|
|
|
|
|
| Verb | Waits for | Because |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| `FOR` | `NEXT` | the condition was already met — zero iterations |
|
|
|
|
|
|
| `DO WHILE` / `DO UNTIL` | `LOOP` | the same, at the top of a `DO` |
|
|
|
|
|
|
| `EXIT` | `NEXT` or `LOOP` | where the loop *ends* is not known until a `NEXT` has run once |
|
|
|
|
|
|
| `IF ... THEN BEGIN` | `BEND` | the arm not taken has to skip *lines*, and `skiprestofline` only reaches the end of this one |
|
|
|
|
|
|
| a multi-line `DEF` | `RETURN` | the definition must not execute its own body |
|
|
|
|
|
|
|
|
|
|
|
|
Three consequences follow, and all three are things people report as bugs:
|
|
|
|
|
|
|
|
|
|
|
|
- **Block skipping walks source *lines*.** A whole `FOR ... NEXT` written on one line does
|
|
|
|
|
|
not loop, because there is no next line for the wait to skip to.
|
|
|
|
|
|
- **A `FOR` counter does not survive its loop.** It lives in the loop's own scope. On a
|
|
|
|
|
|
C128 it keeps its final value.
|
|
|
|
|
|
- **A host must use `akbasic_runtime_global()`.** `akbasic_environment_get()` creates in
|
|
|
|
|
|
whatever scope is active, and a script suspended part-way through a bounded
|
|
|
|
|
|
`akbasic_runtime_run()` is usually inside a `FOR` or `GOSUB` body — so the script reads
|
|
|
|
|
|
the value correctly inside the loop and gets `0` immediately after it, with nothing
|
|
|
|
|
|
raised anywhere.
|
|
|
|
|
|
|
|
|
|
|
|
## Values
|
|
|
|
|
|
|
|
|
|
|
|
`akbasic_Value` carries its string **inline**, not behind a pointer, so a copy is a struct
|
|
|
|
|
|
assignment with no allocator, no refcount and no lifetime question. It costs 256 bytes per
|
|
|
|
|
|
value; that is the trade, and it is recorded in `TODO.md`.
|
|
|
|
|
|
|
|
|
|
|
|
Type comes from the identifier's suffix and nothing else: `A#` integer, `A%` float, `A$`
|
|
|
|
|
|
string, and a bare name with no suffix is a *label*. That is `akbasic_leaf_identifier_type()`,
|
|
|
|
|
|
and it is why `ER` and `EL` are spelled `ER#` and `EL#` here.
|
|
|
|
|
|
|
|
|
|
|
|
One field on the runtime changes how identifiers evaluate. `eval_clone_identifiers` is
|
|
|
|
|
|
normally true, and evaluating `A#` then yields a clone drawn from the per-line value pool.
|
|
|
|
|
|
`POKE` and `POINTER` need the address of the real storage, so they clear it around their
|
|
|
|
|
|
own evaluation. If you are debugging an assignment that wrote to the wrong place, that
|
|
|
|
|
|
flag is the first thing to check.
|
|
|
|
|
|
|
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
|
|
|
|
## Structures are laid out exactly as arrays are
|
|
|
|
|
|
|
|
|
|
|
|
The whole storage model falls out of one decision: **a `TYPE` is declared**, so an
|
|
|
|
|
|
instance has a known slot count before the program runs.
|
|
|
|
|
|
|
|
|
|
|
|
That means a structure needs no pool of its own. `DIM R@ AS RECT` calls the same
|
|
|
|
|
|
`akbasic_valuepool_take()` that `DIM A#(10)` calls, and the variable's `values` run *is*
|
|
|
|
|
|
the instance. A field access is offset arithmetic against an offset the type descriptor
|
|
|
|
|
|
already knows. A nested value field **flattens into its container's run** — a `SEGMENT`
|
|
|
|
|
|
holding two `COORD`s and a string is five slots, not three — which is why the nesting is
|
|
|
|
|
|
free rather than a second indirection.
|
|
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
|
DIM S@ AS SHAPE SHAPE: NAME$, ORIGIN@ AS COORD, AREA%
|
|
|
|
|
|
COORD: X#, Y#
|
|
|
|
|
|
|
|
|
|
|
|
variable S@
|
|
|
|
|
|
structtype ──► type table entry SHAPE (slotcount 4)
|
|
|
|
|
|
values ──────► ┌────────┬────────┬────────┬────────┐
|
|
|
|
|
|
│ NAME$ │ X# │ Y# │ AREA% │
|
|
|
|
|
|
└────────┴────────┴────────┴────────┘
|
|
|
|
|
|
off 0 off 1 off 2 off 3
|
|
|
|
|
|
└── ORIGIN@ is offset 1, two slots ──┘
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
`src/structtype.c` fills the table, and it does so **in three passes**, each for a case
|
|
|
|
|
|
the pass before cannot handle. Names first, so a field can refer to a type declared
|
|
|
|
|
|
further down. Then field lists, now able to resolve every reference. Then sizes, by
|
|
|
|
|
|
repeated resolution — a type whose fields are all sized can be sized, and repeating that
|
|
|
|
|
|
settles any legal ordering. **Whatever never resolves is a cycle of by-value containment**,
|
|
|
|
|
|
which is how "a `TYPE` cannot contain itself by value" is a diagnosis rather than an
|
|
|
|
|
|
assumption.
|
|
|
|
|
|
|
|
|
|
|
|
It is a *prescan*, run from `akbasic_runtime_set_mode()` beside the label and `DATA`
|
|
|
|
|
|
scans, for the reason all three are: a declaration has to be in effect wherever control
|
|
|
|
|
|
goes, including when a branch skips the lines that made it. The `TYPE` verb's whole job at
|
|
|
|
|
|
run time is to **jump past its own `END TYPE`**, because the field lines are declarations
|
|
|
|
|
|
and executing `W#` would evaluate a bare identifier and quietly create a global.
|
|
|
|
|
|
|
|
|
|
|
|
### Copy, and where it has to happen
|
|
|
|
|
|
|
|
|
|
|
|
A `STRUCT` value and a `POINTER` value carry the same thing: a type index and a base. What
|
|
|
|
|
|
differs is what *assignment* does with it.
|
|
|
|
|
|
|
|
|
|
|
|
**The copy cannot live in `akbasic_value_clone()`**, and this is the trap to know about.
|
|
|
|
|
|
Clone copies one slot, and one slot holds a *reference* to an instance rather than the
|
|
|
|
|
|
instance — so a structure going through clone would alias, which is precisely the
|
|
|
|
|
|
semantics the language does not have. `akbasic_environment_assign()` intercepts a
|
|
|
|
|
|
structure before that path and calls `akbasic_struct_copy()`, which walks the descriptor
|
|
|
|
|
|
and copies slot by slot. Deliberately not one `memcpy` of the run: a pointer field must
|
|
|
|
|
|
copy its reference where a value field must copy its slots, and only the descriptor knows
|
|
|
|
|
|
which is which.
|
|
|
|
|
|
|
|
|
|
|
|
Copy is therefore **deep through values and stops at pointers**, as it is for a C struct
|
|
|
|
|
|
holding a pointer. Since a `TYPE` cannot contain itself by value, copy depth is fixed by
|
|
|
|
|
|
the type graph before the program starts and no copy can recurse away. Only *rendering*
|
|
|
|
|
|
needs a runtime bound, because a pointer can make the graph cyclic — that is
|
|
|
|
|
|
`AKBASIC_MAX_STRUCT_DEPTH`, four, chosen so the bound bites before the 256-byte render
|
|
|
|
|
|
buffer does.
|
|
|
|
|
|
|
|
|
|
|
|
### A host structure is the same thing with its bytes somewhere else
|
|
|
|
|
|
|
|
|
|
|
|
`akbasic_host_register_type()` puts a host's C struct in the *same* table, so `PTR TO`,
|
|
|
|
|
|
copy-on-assign, `.` and `->` all work across the boundary with no second set of rules. The
|
|
|
|
|
|
only difference is where a field's bytes live, and that is carried by three members on the
|
|
|
|
|
|
field descriptor — `hostkind`, `hostoffset`, `hostwidth`.
|
|
|
|
|
|
|
|
|
|
|
|
A binding takes a **shadow run** of slots from the ordinary pool. A field read refreshes
|
|
|
|
|
|
its slot from host memory first; a field write converts back and stores. So the script
|
|
|
|
|
|
always sees current values and its writes always land, while everything else in the
|
|
|
|
|
|
interpreter goes on seeing one storage model. If you are debugging a host binding that
|
|
|
|
|
|
reads stale data, `akbasic_host_refresh()` is where to look.
|
|
|
|
|
|
|
|
|
|
|
|
**This is the one pointer the interpreter holds that it did not allocate.** Everything
|
|
|
|
|
|
else is pool-bounded; a binding whose instance has been freed is the only way to get a
|
|
|
|
|
|
wild pointer in here, which is what `akbasic_host_unbind()` is for.
|
|
|
|
|
|
|
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
|
|
|
|
## Errors come in two kinds, and the distinction is the point
|
|
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
|
a script's mistake the interpreter's mistake
|
|
|
|
|
|
----------------- -------------------------
|
|
|
|
|
|
PRINT 1/0, GOTO nowhere, pool exhausted, NULL argument,
|
|
|
|
|
|
type mismatch, a refused verb a sink that failed to write
|
|
|
|
|
|
| |
|
|
|
|
|
|
v v
|
|
|
|
|
|
report_and_reraise() propagates out through
|
|
|
|
|
|
writes "? 20 : RUNTIME ..." to the sink akbasic_runtime_step()
|
|
|
|
|
|
| |
|
|
|
|
|
|
v v
|
|
|
|
|
|
swallowed by process_line_run() handed to the host, which
|
|
|
|
|
|
or process_line_repl(); the run stops, decides what to do. The
|
|
|
|
|
|
the host is never told driver prints a stack trace
|
|
|
|
|
|
and exits 1
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
A script's mistakes are the script's problem and your program keeps running. That is goal
|
|
|
|
|
|
3, and it is easier to get wrong than it looks: the direct-mode branch of
|
|
|
|
|
|
`process_line_repl()` once used a bare `PASS`, so a `VERIFY` against a file that did not
|
|
|
|
|
|
match — an ordinary user answer — tore down the driver with a stack trace.
|
|
|
|
|
|
|
|
|
|
|
|
The BASIC-visible line is `? <line> : <CLASS> <message>`, where the class is one of `IO`,
|
|
|
|
|
|
`PARSE`, `SYNTAX` or `RUNTIME`. The message is expected to end in a newline and the sink
|
|
|
|
|
|
adds another, **so an error line is followed by a blank one**. That is the contract, not an
|
|
|
|
|
|
accident, and the golden corpus depends on it.
|
|
|
|
|
|
|
|
|
|
|
|
`errclass` being set is what ends a run: `step()` sees it and switches to
|
|
|
|
|
|
`run_finished_mode`. Returning to a prompt clears it; quitting does not.
|
|
|
|
|
|
|
|
|
|
|
|
**`akbasic_runtime_error()` is the single choke point** — the one place a BASIC-visible
|
|
|
|
|
|
error is reported *and* the one place a run is stopped. That is not an accident of
|
|
|
|
|
|
factoring, it is what makes `TRAP` implementable: an armed trap turns both off in one
|
|
|
|
|
|
place. Nothing is printed, `errclass` stays clear so the step loop carries on, and the
|
|
|
|
|
|
handler is entered at the next line boundary by the same machinery `COLLISION` uses. If
|
|
|
|
|
|
you ever need to intercept an error class, that function is where to stand.
|
|
|
|
|
|
|
|
|
|
|
|
Interpreter errors carry a status from the `akbasic` band, 512–767, declared as an `enum`
|
|
|
|
|
|
in `include/akbasic/error.h` and named in `akbasic_error_register()`. `MAINTENANCE.md`
|
|
|
|
|
|
carries the coordinated range map across the whole dependency stack, and it is required
|
|
|
|
|
|
reading before adding a code.
|
|
|
|
|
|
|
|
|
|
|
|
**One gap worth knowing before it surprises you.** The `ATTEMPT` blocks that convert a
|
|
|
|
|
|
script's mistake into an error line are wrapped around *parsing* and *interpretation*,
|
|
|
|
|
|
not around *scanning*. A line with more than 32 tokens therefore escapes as an
|
|
|
|
|
|
interpreter error — the driver prints a stack trace and exits 1, and an embedding host is
|
|
|
|
|
|
handed a context for what is really a script's mistake. It is the same shape as the
|
|
|
|
|
|
`VERIFY` defect above, on a path that fix did not cover. Filed in `TODO.md`; until it is
|
|
|
|
|
|
closed, a host that cannot tolerate that should refuse over-long lines itself.
|
|
|
|
|
|
|
|
|
|
|
|
## Devices, and how a capability is withheld
|
|
|
|
|
|
|
|
|
|
|
|
The sink and the four device backends are records of function pointers plus whatever
|
|
|
|
|
|
state you hang off `self`. That is the house pattern for anything that varies, and it is
|
|
|
|
|
|
what keeps SDL out of the core: the whole test suite runs on a machine with no SDL
|
|
|
|
|
|
installed at all.
|
|
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
|
akbasic_runtime_set_devices(rt, graphics, audio, input, sprites)
|
|
|
|
|
|
| | | |
|
|
|
|
|
|
any of them may be NULL, and NULL is not
|
|
|
|
|
|
"do nothing" -- it is "refuse by name":
|
|
|
|
|
|
|
|
|
|
|
|
SOUND with no audio backend ->
|
|
|
|
|
|
"? 10 : RUNTIME ERROR SOUND needs an audio
|
|
|
|
|
|
device and this runtime has none"
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
A backend record with a `NULL` *entry point* behaves the same way, one verb at a time —
|
|
|
|
|
|
`CHAR` against a stdio sink that cannot move a cursor refuses rather than printing in the
|
|
|
|
|
|
wrong place. Nothing is ever silently ignored; that is the rule the whole device layer is
|
|
|
|
|
|
built to keep.
|
|
|
|
|
|
|
|
|
|
|
|
`akbasic_sink_init_tee()` composes two sinks into one, which is how the SDL build puts
|
|
|
|
|
|
`PRINT` in a window *and* on stdout. The second write belongs out here rather than inside
|
|
|
|
|
|
the interpreter — the reference hardcoded it, and that is exactly what made its output
|
|
|
|
|
|
untestable without a display.
|
|
|
|
|
|
|
|
|
|
|
|
[Chapter 10](10-embedding.md) is the practical side of all of this, with a complete host
|
|
|
|
|
|
loop.
|
|
|
|
|
|
|
|
|
|
|
|
## Interrupts
|
|
|
|
|
|
|
|
|
|
|
|
`COLLISION` and `TRAP` arm a slot; a device backend, the collision service, or
|
|
|
|
|
|
`akbasic_runtime_error()` calls `akbasic_runtime_raise_interrupt()`, which only records
|
|
|
|
|
|
that it fired. The handler is entered later, by `step()`, between two source lines.
|
|
|
|
|
|
|
|
|
|
|
|
Raising is cheap and safe to call every frame whether or not anything is armed — an
|
|
|
|
|
|
unarmed source records nothing — so a host does not have to ask what the script has
|
|
|
|
|
|
subscribed to before reporting a collision.
|
|
|
|
|
|
|
|
|
|
|
|
Entering one is a `GOSUB` the program did not write: a scope is pushed, its return line is
|
|
|
|
|
|
the line that was about to run, and the handler's `RETURN` pops back to it — so a handler
|
|
|
|
|
|
must end in `RETURN`, exactly as on a C128.
|
|
|
|
|
|
|
|
|
|
|
|
`handlerenv` does two jobs. It is the "a handler is running" flag, which is what stops a
|
|
|
|
|
|
collision that persists across the handler from recursing until the environment pool is
|
|
|
|
|
|
gone. And it is the identity `RETURN` compares against, so the flag clears on the `RETURN`
|
|
|
|
|
|
that leaves *this* handler rather than on the first `RETURN` of any `GOSUB` the handler
|
|
|
|
|
|
itself makes. A depth counter would have got that wrong.
|
|
|
|
|
|
|
|
|
|
|
|
## Debugging it
|
|
|
|
|
|
|
|
|
|
|
|
### What the language gives you
|
|
|
|
|
|
|
|
|
|
|
|
`TRON` prints each line's number in brackets, inline and with no newline, which is what a
|
|
|
|
|
|
C128 does:
|
|
|
|
|
|
|
|
|
|
|
|
```basic
|
|
|
|
|
|
10 TRON
|
|
|
|
|
|
20 FOR I# = 1 TO 3
|
|
|
|
|
|
30 PRINT I#
|
|
|
|
|
|
40 NEXT I#
|
|
|
|
|
|
50 TROFF
|
|
|
|
|
|
60 PRINT "DONE"
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
```output
|
|
|
|
|
|
[20][30]1
|
|
|
|
|
|
[40][30]2
|
|
|
|
|
|
[40][30]3
|
|
|
|
|
|
[40][50]DONE
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Read that trace as evidence about the *loop*, not just about the lines: `[40][30]` says
|
|
|
|
|
|
`NEXT` sent control back to 30, and the missing `[20]` on later passes says the `FOR` line
|
|
|
|
|
|
is not re-entered. A trace that shows a line you expected to be skipped is a
|
|
|
|
|
|
`waitingForCommand` question; a trace that shows the right lines with the wrong output is
|
|
|
|
|
|
an evaluation question.
|
|
|
|
|
|
|
|
|
|
|
|
`HELP` re-displays the last error line, and that is the whole of what it does.
|
|
|
|
|
|
|
|
|
|
|
|
### Reading a stack trace
|
|
|
|
|
|
|
|
|
|
|
|
An interpreter-level error that nothing handled reaches the driver, which prints the
|
|
|
|
|
|
`akerror` trace on **stderr** and exits 1. Every frame is `file:function:line`:
|
|
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
|
src/scanner.c:add_token:87: 515 (Out Of Bounds) : Line 10 has more than 32 tokens
|
|
|
|
|
|
src/scanner.c:akbasic_scanner_scan:414
|
|
|
|
|
|
src/runtime.c:akbasic_runtime_process_line_runstream:788
|
|
|
|
|
|
src/runtime.c:akbasic_runtime_step:1302
|
|
|
|
|
|
src/runtime.c:akbasic_runtime_run:1367
|
|
|
|
|
|
src/main.c:drive:94
|
|
|
|
|
|
src/main.c:run_stdio:130
|
|
|
|
|
|
src/main.c:main:203
|
|
|
|
|
|
src/main.c:main:214: akbasic terminated on an unhandled error 515 (Out Of Bounds)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
The paths are `__FILE__` as your build spells it, so an out-of-tree build prints them
|
|
|
|
|
|
absolute. The top frame is where it was raised and the bottom is where it was reported.
|
|
|
|
|
|
The number is the status code — 512 and up is ours, and `include/akbasic/error.h` names
|
|
|
|
|
|
them. A code printing as `Unknown Error` means somebody added it to the `enum` and forgot
|
|
|
|
|
|
to name it in `akbasic_error_register()`.
|
|
|
|
|
|
|
|
|
|
|
|
Note what this trace is *not*: it is not on stdout, so a golden comparison never sees it,
|
|
|
|
|
|
and `? 10 : RUNTIME ERROR ...` is a different thing entirely — that one is the script's
|
|
|
|
|
|
error, on stdout, and the process exits 0.
|
|
|
|
|
|
|
|
|
|
|
|
### Under a debugger
|
|
|
|
|
|
|
|
|
|
|
|
Four breakpoints answer most questions:
|
|
|
|
|
|
|
|
|
|
|
|
| Break on | Answers |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| `akbasic_runtime_step` | Which mode, and what the step decided to do |
|
|
|
|
|
|
| `akbasic_parser_parse` | What the parser built — then render it |
|
|
|
|
|
|
| `akbasic_runtime_evaluate` | Every leaf, in evaluation order. Noisy; condition it on `expr->leaftype` |
|
|
|
|
|
|
| the verb's own `akbasic_cmd_*` | What arguments a verb actually received |
|
|
|
|
|
|
|
|
|
|
|
|
Useful expressions once you are stopped. `rt` is whatever your host calls the runtime; in
|
|
|
|
|
|
the standalone driver it is the file-static `RUNTIME`, so it is `p RUNTIME.mode` there:
|
|
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
|
p rt->mode 1 REPL, 2 RUN, 3 RUNSTREAM, 4 QUIT
|
|
|
|
|
|
p rt->environment->lineno the line executing
|
|
|
|
|
|
p rt->environment->nextline the line that will execute next
|
|
|
|
|
|
p rt->environment->waitingForCommand "" or the verb being skipped forward to
|
|
|
|
|
|
p rt->environment->parent NULL means this is the root scope
|
|
|
|
|
|
p rt->source[42].code the stored text of line 42
|
|
|
|
|
|
p rt->errclass non-zero means the run is ending
|
|
|
|
|
|
p *rt->environment->forNextVariable the FOR counter, when there is one
|
|
|
|
|
|
|
|
|
|
|
|
call akbasic_leaf_to_string(leaf, buf, sizeof buf) render a subtree: (+ A# 42)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Walking `parent` from `rt->environment` gives you the scope stack, and it is the fastest
|
|
|
|
|
|
way to see that something pushed a scope and never popped it.
|
|
|
|
|
|
|
|
|
|
|
|
### Narrowing it with the suite
|
|
|
|
|
|
|
|
|
|
|
|
```sh norun
|
|
|
|
|
|
ctest --test-dir build --output-on-failure -R for_next # one unit test
|
|
|
|
|
|
ctest --test-dir build --output-on-failure -R golden_ # the reference corpus
|
|
|
|
|
|
./build/basic tests/reference/language/functions.bas | diff - tests/reference/language/functions.txt
|
|
|
|
|
|
./tests/docs_examples.sh --root . --basic ./build/basic \
|
|
|
|
|
|
--cflags-file build/docs_cflags.txt docs/14-architecture.md
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
The unit tests build a runtime against memory buffers (`tests/harness.h`), so they can
|
|
|
|
|
|
assert on exact output without a terminal. `tests/mockdevice.h` gives the graphics, audio
|
|
|
|
|
|
and input verbs recording backends that log every call — which is how the device verbs are
|
|
|
|
|
|
tested at all, since they produce no stdout for a golden file to compare.
|
|
|
|
|
|
|
|
|
|
|
|
Two more builds, when the answer is not in the logic:
|
|
|
|
|
|
|
|
|
|
|
|
```sh norun
|
|
|
|
|
|
cmake -S . -B build-asan -DAKBASIC_SANITIZE=ON # ASan + UBSan
|
|
|
|
|
|
cmake -S . -B build-cov -DAKBASIC_COVERAGE=ON # gcovr, and the 90% gate
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
`MAINTENANCE.md` covers the mutation harness and the discipline that goes with a fix:
|
|
|
|
|
|
revert it, confirm the test fails, restore it.
|
|
|
|
|
|
|
|
|
|
|
|
### Symptom to cause
|
|
|
|
|
|
|
|
|
|
|
|
| Symptom | Look at |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| `Unknown command PRINT` | The verb table's sort order. `tests/verbs_table.c` |
|
|
|
|
|
|
| `peek() returned nil token!` | The line ended before the parser expected it to — a missing operand or an unbalanced paren |
|
|
|
|
|
|
| A line ran that should have been skipped | `waitingForCommand` on the active scope, and `skiprestofline` |
|
|
|
|
|
|
| A line was skipped that should have run | The same two, in the other direction |
|
|
|
|
|
|
| A variable reads `0` right after a loop | It lived in the loop's scope. Use `akbasic_runtime_global()` from a host |
|
|
|
|
|
|
| `Environment pool exhausted` | A scope pushed and never popped — usually a `GOSUB` with no `RETURN`, or recursion |
|
|
|
|
|
|
| `Line N has more than 32 tokens` | The per-environment token pool. Split the line |
|
|
|
|
|
|
| Every duration expires instantly | The host never called `akbasic_runtime_settime()` |
|
|
|
|
|
|
| A verb refuses with "needs a ... device" | That backend is `NULL`, or the entry point it wanted is |
|
|
|
|
|
|
| The driver exits 1 with a stack trace | An *interpreter* error, not a script error. Read the top frame |
|
|
|
|
|
|
|
|
|
|
|
|
## Changing it
|
|
|
|
|
|
|
|
|
|
|
|
### Adding a verb
|
|
|
|
|
|
|
|
|
|
|
|
1. **One row in `src/verbs.c`**, in sort order. Pick `AKBASIC_TOK_COMMAND`, or
|
|
|
|
|
|
`AKBASIC_TOK_COMMAND_IMMEDIATE` if the REPL should run it against a numbered line.
|
|
|
|
|
|
2. **A parse handler**, only if the syntax is not "the verb and one expression". Put it in
|
|
|
|
|
|
`src/parser_commands.c` and leave the table cell `NULL` otherwise.
|
|
|
|
|
|
3. **An exec handler** in the `src/runtime_*.c` file for its verb group, with the
|
|
|
|
|
|
signature from `include/akbasic/verbs.h` — `(runtime, expr, lval, rval, dest)` — and a
|
|
|
|
|
|
prototype in the *private* `src/verbs.h`, where the table gets its declarations.
|
|
|
|
|
|
4. **A unit test** in `tests/`, registered in `AKBASIC_TESTS` in `CMakeLists.txt`, built as
|
|
|
|
|
|
`akbasic_test_<name>`.
|
|
|
|
|
|
5. **A `.bas`/`.txt` pair** in `tests/language/`. Both kinds of test, because they answer
|
|
|
|
|
|
different questions: one pins the C contract, the other pins what a user sees.
|
|
|
|
|
|
6. **A row in [Chapter 11](11-verb-reference.md)**, and an example somewhere — every fenced
|
|
|
|
|
|
block in `docs/` is executed by `ctest`.
|
|
|
|
|
|
|
|
|
|
|
|
Adding a *function* is the same, with `AKBASIC_TOK_FUNCTION`, a real `arity`, and
|
|
|
|
|
|
[Chapter 12](12-function-reference.md).
|
|
|
|
|
|
|
|
|
|
|
|
### Adding a device capability
|
|
|
|
|
|
|
|
|
|
|
|
Add the entry point to the backend record in the relevant `include/akbasic/*.h`, implement
|
|
|
|
|
|
it in `src/*_akgl.c`, and — this is the part that is easy to skip — **make the verb refuse
|
|
|
|
|
|
by name when the pointer is `NULL`**, so an older host or a different backend gets an
|
|
|
|
|
|
error rather than silence.
|
|
|
|
|
|
|
|
|
|
|
|
If `libakgl` cannot supply what the verb needs, **do not work around it here**. File it in
|
|
|
|
|
|
`deps/libakgl/TODO.md`: what the BASIC verb requires, what the `akgl_*` entry point should
|
|
|
|
|
|
look like, and what tests would cover it. Four gaps have gone upstream that way and all
|
|
|
|
|
|
four landed.
|
|
|
|
|
|
|
|
|
|
|
|
### The rules a change has to keep
|
|
|
|
|
|
|
|
|
|
|
|
These are not style preferences. Each one is load-bearing for goal 3, and each one has a
|
|
|
|
|
|
test or a build failure behind it:
|
|
|
|
|
|
|
|
|
|
|
|
- **Nothing in the library terminates the process.** `FINISH_NORETURN` belongs only in a
|
|
|
|
|
|
`main()`.
|
|
|
|
|
|
- **Nothing calls `malloc`.** Add a pool, or a layer to one.
|
|
|
|
|
|
- **No file-scope mutable state.** It goes on `akbasic_Runtime`.
|
|
|
|
|
|
- **Nothing blocks.** A verb that waits sets state and returns; `step()` checks it next
|
|
|
|
|
|
time round. `SLEEP`, `WAIT`, `GETKEY` and `PLAY` are all built that way.
|
|
|
|
|
|
- **The interpreter owns no window, renderer or loop.**
|
|
|
|
|
|
|
|
|
|
|
|
## Where to read next
|
|
|
|
|
|
|
|
|
|
|
|
`MAINTENANCE.md` is the conventions: the three test lists and why two of them invert
|
|
|
|
|
|
"passed", the documentation-example harness, the error-code map, the build collisions, the
|
|
|
|
|
|
style rules. `TODO.md` is the defect list, with file, line and consequence — including the
|
|
|
|
|
|
ones inherited from the Go reference and the ones found writing these chapters. The
|
|
|
|
|
|
headers under `include/akbasic/` are the API, and `doxygen Doxyfile` renders them into
|
|
|
|
|
|
`build/docs/html`.
|
|
|
|
|
|
|
|
|
|
|
|
And `examples/embed.c` and `examples/hostvars.c` are two complete hosts, built and run by
|
|
|
|
|
|
every build, so neither can rot.
|