Files
akbasic/docs/14-architecture.md
Andrew Kesterson 6bac929901 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

41 KiB
Raw Blame History

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 contractdoxygen 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, and src/runtime_*.c
Why is it built like this? this chapter
What are the conventions for changing it? MAINTENANCE.md
What is currently wrong with it? 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.

  +---------------------------+     +---------------------------+
  |  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.

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.

  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; the stdio driver uses 1, because it wants to refresh the clock between steps.

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

                    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 two 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.

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.

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.

   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 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.

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 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-DIMming 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
FORduring 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.

   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 COORDs and a string is five slots, not three — which is why the nesting is free rather than a second indirection.

    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

   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, 512767, 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.

   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 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:

10 TRON
20 FOR I# = 1 TO 3
30   PRINT I#
40 NEXT I#
50 TROFF
60 PRINT "DONE"
[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:

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:

  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

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:

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, 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.

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.

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.