"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>
828 lines
44 KiB
Markdown
828 lines
44 KiB
Markdown
# 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.
|
||
|
||
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.
|
||
|
||
## 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
|
||
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.
|
||
|
||
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
|
||
`READY`. Entering `RUN` does four prescans of the whole program first:
|
||
|
||
- **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.
|
||
- **`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.
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
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` |
|
||
| `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()`.
|
||
[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.
|
||
|
||
## 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.
|
||
|
||
## 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.
|