AntBASIC — Language Specification

New to BASIC? Start with the AntBASIC introduction — a gentle tour. This page is the complete reference.

A line-numbered, tokenised BASIC living inside AntOS. The console boots into it — banner, free-memory count, Ready. — but the engine is exposed as an ordinary AntOS library, so the classic commands (RUN, LIST, RENUMBER, SAVE) are Luau scripts on top of it, not baked-in magic. It is the platform's retro heart, done the AntOS way: BASIC as a service, not the top of the stack.

Status: design. Nothing here is built yet. This page fixes the model — dialect, dispatch, the tokenised store, contexts, storage, versioning, and the boot banner — so the basic library and its Luau binding can be written against a settled shape.

Back to AntOS · the Luau hub.


Not the FPGA's BASIC

AntBASIC is the AntOS console's own built-in native BASIC interpreter, running on DeMon (the CM5, under Linux) — the supervisor, not the FPGA. It is a separate thing from whatever language a personality on the FPGA soft core provides (the eZX, for instance, at its own layer with its own dialect). Same machine, different layers. Wherever this page says "BASIC" it means AntBASIC unless it says otherwise.

Fast enough that assembly is a choice. Because AntBASIC runs on the CM5 — thousands of times faster than the 8-bit micros BASIC grew up on — even a tokenised, interpreted program here runs faster than hand-optimised assembly did in the 1980s. The two inline assemblers exist because working in native code is fun and occasionally necessary, not because BASIC is too slow.


The boot banner

After the AntOS banner, the console prints AntBASIC's greeting and drops to a ready prompt:

AntOS 1.4.2  ·  DeMon ready
─────────────────────────────
AntBASIC v1.00.00   3.00 MB free

Ready.
█

The second line is AntBASIC v<major>.<minor>.<patch> then the free space in the workspace arena (see Workspace), formatted adaptively:

Free amount Shown as
≥ 1 GB 2.50 GB free
≥ 1 MB 3.00 MB free
≥ 1 KB 640.00 KB free
< 1 KB 768 bytes free (comma-grouped)

Then a blank line, Ready., and the cursor. The console composes this from basic.version() and basic.free() — it is not special-cased; a script could print the same line.


The dialect

A BBC / Microsoft-style BASIC: line numbers, GOTO/GOSUB (also GO TO/GO SUB), FOR…NEXT, IF…THEN, PRINT/?, INPUT, DIM, DATA/READ/RESTORE, string and numeric variables. Two deliberate choices shape the feel:

Case-insensitive keywords and variables. Everything folds to tokens on entry, so Print, PRINT and print are one keyword, and Count, COUNT and count are one variable. LIST detokenises to a canonical case (keywords upper, variables as first seen).

? is PRINT, but stays ?. ? is its own token that aliases PRINT — ? "hello" prints hello, ? 3+4 prints 7 — but it is not folded into the PRINT token, so LIST shows ? where you typed ? and PRINT where you typed PRINT (the Commodore/BBC behaviour). Handily, ? is already the shortest spelling of PRINT, so the crunch renderer below leaves it untouched.

Two ways to write every base. Both the C-flavoured and the retro-flavoured literal prefixes are accepted:

Base Prefixes Example
Hexadecimal 0x… or $… 0xFF, $FF
Binary 0b… or %… 0b1010, %1010
Octal &… &777

⚠️ $ and % are overloaded — they are also the BBC/MS type-suffix sigils (A$ = string, N% = integer). The tokeniser disambiguates by position:

  • a sigil following a name is a type suffix — score%, name$
  • a sigil introducing digits is a literal prefix — %1010, $FF

So A%1010 is A% (an integer variable) followed by 1010, not A followed by a binary literal — insert a space or operator if you mean the literal. This rule is stated here because it is the one place the grammar is genuinely ambiguous to a human even though it is unambiguous to the parser.


Dispatch — how one prompt serves two languages

The Ready. prompt is AntOS's command line: by default it looks up and runs a named script, exactly as a shell does. A few leading characters redirect a line before that lookup:

  1. A leading digit → a BASIC program line. 10 PRINT "HI" tokenises and stores line 10 (basic.insert); a bare line number deletes it (basic.remove). A line joins the program only by carrying a number.
  2. A leading : → a BASIC statement, run now in the live context (basic.eval). :PRINT a prints the current value of a, :a = 5 sets it, :PROCredraw calls a procedure the program defined — the context persists across lines and across RUN, so this is how you inspect or poke at a program after it runs. : is BASIC's own statement separator, so the rest of the line reads as BASIC and :a = 1 : b = 2 runs two statements, exactly as it would in a program.
  3. A leading ; → inline Luau, evaluated in the console's own long-lived Luau VM, REPL-style: ;x = 5 then ;print(x) → 5 (globals persist between lines, locals don't). This is the only way to run Luau source at the prompt. ; is Lua's own statement separator, so, symmetrically, the rest of the line reads as Luau and ;x = 5; print(x) runs both statements.
  4. Anything else → a command: a script looked up on the path (D:/scripts), where run, list, renumber, save, csave, load, new, delete, auto and cont live as Luau scripts over the basic library, alongside every other AntOS command. (The console also reads a leading ? as its natural-language helper and a bare drive like A: as change directory — see the console.)

So each language keeps its lane, and the two escapes are chosen to be each language's own statement separator — : for BASIC, ; for Lua — so the rest of the line already reads the way that language writes it: a number edits the program, : runs a BASIC statement, ; runs Luau, and a bare word runs a command. A bare PRINT 2+2 is none of those as BASIC — it looks up a print command — so, unlike every classic BASIC, there is no automatic immediate mode; BASIC runs only inside a RUNning program or behind an explicit :. Even RUN and LIST are ordinary scripts (basic.run(), basic.lines()) you can read and replace, which is the whole point of exposing BASIC as a library.


The tokenised program store

BASIC is tokenised on entry, the traditional model. When a program line is accepted, keywords collapse to compact token IDs — a single byte for the common ones, a prefixed extended byte for the larger sets — and the line's tokens go into the line data. Kept separately is a line table — a compact index of { line number, offset, length }, sorted by line number, pointing into that data:

line data:   ...tokens for one line... | ...tokens for the next... | ...
line table:  { line#, offset, len }  x N   (sorted by line number)

RUN walks the token stream directly (no re-parsing); LIST detokenises back to source; a GOTO finds its target by searching the table (below). This is compact, fast to execute, and makes edit-by-retyping-a-line natural. The main space is one such global program — the program at the Ready. prompt.

Luau sees the program as text by default — basic.lines(), basic.get(n) hand back detokenised source, so tooling like list/renumber works on strings. The raw token bytes are available on request via basic.tokens(line) for tools that need them (a tracer, a syntax highlighter, an on-device editor).

The line table

Finding a line is a separate, faster job from storing it. A computed target (GOTO expr) resolves by binary search of the sorted table — O(log n) — then jumps straight to the offset, instead of the classic linked-list scan from the top of the program. A literal or CONST target is faster still: the RUN pre-pass resolves it to its line-table index once, so the runtime jump is a direct indexed fetch — no search at all. Either way beats walking thousands of lines from the top.

The table is kept current as you edit — basic.insert, delete and renumber update it — and it indexes every line, including the empty placeholder lines a protected save leaves behind, so their numbers stay valid jump targets. It is also what lets the RUN pre-pass confirm a literal jump target exists before any line runs.


Constants and the RUN pre-pass

Constants — CONST

CONST names a value fixed for the whole program (a modern convenience — classic BBC BASIC has none):

10 CONST maxlives% = 3
20 CONST title$ = "ANT INVADERS"
30 CONST g = 9.81

A constant is typed by suffix like any variable, but cannot be assigned to afterwards — an attempt is an error. Several may be comma-separated (CONST w% = 320, h% = 240), and a constant's expression may use literals and earlier constants — but not variables, since constants are resolved before any variable exists.

The pre-pass

RUN doesn't jump straight to line one. It first makes a quick pre-pass over the program — the initial pass after RUN, before execution — which:

  1. collects the program's variables, assigning each a slot — so runtime access is a slot index, not a name lookup, and a protected save can strip the name table (the slots are known without it).
  2. evaluates every CONST (in line order) into a constant table. Because this happens before execution, a constant is usable anywhere, including on lines before its own declaration — forward references just work, unlike a runtime assignment.
  3. resolves and validates constant jump targets against the line table, in a single lookup. A target that is a literal line number or a CONST (already evaluated in step 2) is looked up once: the lookup confirms the line exists — a missing one is reported now, before anything runs — and yields its line-table index, which is cached into the code so the runtime jump is a direct indexed fetch, not a search. Only genuinely computed targets (GOTO expr on variables) stay a runtime binary-search.
  4. catches assign-to-CONST and duplicate constants — surfaced up front.
  5. assembles the inline [ ] / { } blocks into memory — the two assembler passes run here, not mid-execution. [ ] ARM64 lands in DeMon's executable arena; a { } block is assembled and its binary sent to the correct FPGA memory location (narrow → HyperRAM, wide → DDR3 / SRAM) on the active soft core, staged before the first line runs, and any assembly error is reported in the pre-pass, before execution — not as a stall-and-fail the first time a CALL reaches the block. Because assembly happens now, a block's addresses and operands resolve against literals, CONSTs (from step 2), and labels — values fixed before the program runs — rather than variables a running program would compute; a runtime CALL / USR then just invokes the ready code. (A block marked DEFER opts out, assembling at run time instead.)

So a GOTO/GOSUB to a CONST-named line costs nothing extra at run time — it resolves to a direct index just like a literal. These resolved indices are a per-run cache: LIST still shows the original line numbers, and editing the program simply re-resolves them on the next RUN.

Only then does execution begin at the first line. The pre-pass is one fast walk of an already-indexed program, and it turns a class of "discovered halfway through the run" faults into "reported the instant you type RUN."

Two payoffs beyond tidiness: an expression of only constants can be folded once at the pre-pass instead of every time its line runs; and because a constant is immutable and known before execution, it is the right thing to size arrays with — DIM board%(w% * h%) where w% and h% are CONST.

The syntax pre-flight

Because every line was checked as it was entered and its verdict stored with it, the pre-pass can do one more thing almost for free: scan the stored verdicts and report the lines that won't parse — before execution begins. This is a flag read over the line table, not a re-parse; the knowledge already exists, so the pre-flight is a table walk, not a compile. (Classic BASIC couldn't afford this — it would have had to parse the whole program up front — so it let bad lines ambush you mid-run. AntBASIC already holds the verdicts, so the pre-flight is nearly free.)

It stays retro-honest: a line that won't parse is not forced fatal, because it may sit in code the run never reaches. The pre-flight is a heads-up, not a veto — a flagged line still throws its real error, with reason and column, if and when execution actually reaches it. You lose nothing of "a broken line in dead code is harmless"; you just stop being ambushed by it halfway through a run.

When the program is clean the pre-flight is silent — no output, execution simply begins. It speaks only in the exceptional case:

> RUN
3 lines won't parse:
  230  unterminated string
  450  expected TO
  620  unknown keyword PRUNT
Run anyway? (Y/N)

Where it earns its keep most is a program you didn't type — a LOADed .bas or a paste, whose bad lines were flagged on entry but which you never watched go red. Your own typing you fixed as you went; a loaded program's errors are exactly the ones that would otherwise surface as a mysterious mid-run stop.

The behaviour is an AntOS setting, strict / warn / off:

Mode At RUN
warn (default) list the error lines and prompt — run anyway, or stop and fix
strict refuse to run until the flagged lines are fixed — the teaching setting; no broken program runs at all
off say nothing; a bad line surfaces only if execution reaches it — the classic feel

A build is always fatal, whatever the setting. basic.build and a CSAVE-to-executable can't prompt and must not ship a broken image, so there any unparseable line fails the build and is listed — warn / off are interactive conveniences only, and a headless spawn fails by default for the same reason.


Errors caught at entry

Because AntBASIC tokenises on entry, it parses the whole line the instant you press Enter — so a malformed line is caught then and there, on the line you just typed, with a reason and a caret at the offending column, and the cursor dropped onto that character ready to fix. No storing a broken line to fail later with a bare ?SYNTAX ERROR at RUN, far from the mistake in both space and time. (Sinclair BASIC's flashing ? marker was the same instinct; this is that idea with a reason attached and the cursor placed for you.)

What is checkable at entry is a spectrum, and the line is worth being honest about:

Tier Caught Examples
Lexical (entry) the tokeniser can't form tokens malformed keyword, bad numeric literal (0xGG), unterminated string, illegal character, a $/% the sigil rule can't resolve, mismatched () / [ ] / { } within the line
Line-local parse (entry) a single statement's shape is wrong FOR without =, PRINT with a dangling operator, a DEF PROC header with a broken parameter list
Cross-line / runtime (at RUN) needs the whole program or runtime state NEXT without FOR, GOTO 250 to a missing line, UNTIL without REPEAT, an undefined PROC/FN, a type mismatch

The first two tiers are line-local, so the tokeniser resolves them cold; the third depends on more than the line, so those stay runtime errors — the line itself is well-formed. AntBASIC validates each line as you enter it and rejects a malformed one on the spot; it does not try to be a whole-program compiler at entry.

Reject on entry, lenient on bulk load. An interactively-typed line that won't tokenise is refused — the cursor is already there, so you fix it now (the strict, classic feel). But a LOAD of a text .bas file, or a paste, can carry many lines, and one bad line shouldn't abort the whole import: bulk entry tokenises every valid line and reports the bad ones with their line numbers and columns, rather than refusing the lot. (A tokenised CSAVE image never hits this — it was already valid when saved.)

The check itself is a library primitive, basic.check, so the console's entry handler, an editor, a linter, and a paste-importer all share one parse path — the same one that gives LIST its structure. And because each line's verdict is stored with it, RUN can pre-flight the whole program — reporting any lines that won't parse before execution starts — as a flag read, not a re-parse.


Syntax colouring

Colour is one more projection of the token stream. The tokeniser already knows the class of every span — keyword, variable, string, number, comment, operator, label — so colouring a listing is a lookup from class to colour, not a separate highlighter. And because basic.check already parses each line as you type for error-at-entry, colour-as-you-type is that same parse rendered: the two features share one pass.

Red means "parsing stopped making sense here." Live, the line colours normally up to the point it parses — yellow keyword, green string, cyan number — and turns red from the exact column the parser can go no further, which is the very caret position basic.check returns. Red is not a token class; it is the boundary where tokenising breaks, so the live red tail and the on-Enter error caret are the same fact shown two ways, and can never disagree.

Incomplete is not invalid. Mid-typing a line is usually unfinished, not broken — PRINT "hel is an open string, FOR I= isn't done. Those are pending (neutral/dimmed), not red; only a genuine impossibility (PRINT %%) goes red. This keeps the tail from flashing red while you type a long string. The distinction is a real outcome of the parser — basic.check reports incomplete vs invalid as different results, and only invalid colours red.

Enter, or Escape

When the caret is on a line the parser rejects, the two keys mean two things:

  • Enter — the line is refused; AntBASIC prints the reason and a caret at the column, reprints the line, and drops the cursor on the offending character ready to fix (the reject-on-entry behaviour).
  • Escape — the line is stored anyway, marked broken, so you can move on and come back — the interactive twin of the lenient bulk-LOAD rule (keyed-in and loaded broken lines behave identically).

A stored-but-broken line advertises itself: its line number lists in red, so LIST shows at a glance which lines still need fixing. It stays a syntax error until re-entered valid; RUN reaching it errors as usual.

It's a theme, and it's optional

Token-class → colour is a palette, a Settings value with a sensible default, not baked in. Colouring must degrade gracefully: on the mono or limited console it simply turns off, and the listing is perfectly readable without it (the glyph set rules still hold). The engine produces the spans; the terminal applies whatever attributes the theme and hardware allow.

The whole thing rests on one primitive, basic.spans: given a line, it returns { col, len, class } spans — including a trailing error span when the line is invalid — and that one call feeds both LIST colouring (stored lines) and the live editor (the line being typed).


Abbreviations — the table is the shorthand

Following Atari BASIC, a keyword can be entered abbreviated as a prefix followed by a dot, and the order of the token table decides which keyword a prefix resolves to: a prefix matches the first keyword, in table order, that begins with it. Nothing but the table drives this — there is no separate abbreviation list.

10 p."hello"        tokenises to    10 PRINT "hello"
20 g.10             tokenises to    20 GOTO 10

p. → PRINT because PRINT sits above POKE/PLOT in the table; g. → GOTO because GOTO sits above GOSUB/GET. Put REM at the very top and a bare-dot comment works:

5.this is a comment  tokenises to   5 REM this is a comment

Rules:

  • The dot is mandatory — it is the "expand this" marker. p. is an abbreviation; p alone is a variable. This is what keeps abbreviations from ever colliding with variable names, and what makes the bare-dot REM case unambiguous.
  • A longer prefix narrows — pr. skips anything earlier and lands on PRINT even if POKE were first; po. reaches POKE.
  • Abbreviation is input-only. It is resolved to the full token the instant the line tokenises, so it never survives into the store. LIST always detokenises to the canonical keyword — 10 p."hi" lists as 10 PRINT "hi".

One ordered table thus does three jobs at once: it assigns the token bytes, it fixes abbreviation precedence, and (reversed) it gives LIST its canonical spelling.

Listing it crunched

The same ordering runs the abbreviation resolver backwards to answer “what is the shortest this line could have been typed as?” For each keyword the detokeniser emits the shortest dot-prefix that still resolves to it — PRINT → p. if PRINT is the first P, POKE → po. if PRINT sits above it — and drops every optional space (FOR I=1TO10, PROCdraw). It is a deterministic minimisation, one pass over the tokens, not a guess. Squeezing a program to fit memory or a type-in line was a real 1980s craft; this automates it.

It is purely a rendering — a third detokenise policy alongside canonical LIST and auto-indent, selected by a style on basic.detokenise / basic.lines and surfaced as a list flag (view it) with a crunch script (write a minimal .bas). The stored tokens never change — abbreviations are input-only and vanish at tokenise-time, so a crunched line re-tokenises to the exact same program. Two rules keep it honest:

  • It never changes the program's shape — abbreviations and whitespace only; no dropped LET, no :-merging, no restructuring.
  • “Smallest” is against this token table. A crunched text listing is minimal (and correct) only for the current abbreviation ordering. That is safe because ordering is append-only — existing prefixes never change meaning — but the truly compact form for storage is the tokenised CSAVE image, not crunched text. Read the crunched listing as a hint of what you could have typed, as much as a compact save path.

The same dot, for commands

The prefix-and-dot idea is really about resolving a prefix against an ordered namespace — and the script folder is just another one. So the abbreviation grammar extends to AntOS commands: at the console d. runs dir if dir is the first d… command, co. narrows to copy over cls, and the same muscle memory works whether you are abbreviating a BASIC keyword or a Luau script. One idea, two namespaces.

Resolution follows the dispatch order — the keyword table first, then the script folder. A prefix a keyword claims resolves to the keyword (p. → PRINT, never a p… script); a prefix the language doesn't use falls through to commands (d. → dir, where no keyword claims d). The full command name always works regardless.

In the script folder, "first" is:

  • alphabetical by default — predictable, and stable as scripts come and go; and
  • overridable by an optional abbrev / priority field in the script's help header, so a common command can claim its prefix (let dir win d. over dump) — or a dangerous one can be denied a short prefix.

Because D:/scripts lives in DBFS, an indexed catalogue, resolving "first d… command" is an ordered fs.query on the name column — not a directory crawl — so it stays instant across thousands of scripts.

Two safeguards, because command abbreviations have sharper edges than keyword ones (p.→PRINT is harmless; d.→del, one keystroke from a destructive command, is not):

  • Destructive commands are protected. A command flagged destructive in its header still prompts its usual confirmation when reached by abbreviation, and can be kept off bare single-letter prefixes via the priority field.
  • The expansion is echoed. The console shows what it ran — d. → dir — or d.: no command, so a mistaken expansion never runs something surprising in silence.

Help and syntax discovery

The syntax entry is a grammar, not a string. Each keyword's entry is a small tree of matcher nodes — grammar-as-data — co-located with the keyword's definition in the basic core by one macro. The readable template (FOR var = start TO end [STEP n] …) is rendered from that tree, never hand-written:

KEYWORD(FOR,
        SEQ(VAR, LIT("="), EXPR, KW(TO), EXPR, OPT(KW(STEP), EXPR)),
        "counting loop") { … }

One macro registers the keyword in the token table, wires its implementation, and files its grammar. A keyword therefore cannot exist without a checkable grammar, and every view below is derived from that one tree — they cannot drift, because there is no second copy.

Two tiers — BASIC's natural shape. A statement grammar is a sequence of matchers — SEQ / OPT / ALT / KW / LIT / VAR / LINE# / LIST(sep) — describing statement shape. It bottoms out in EXPR, which is deliberately not a matcher production: expressions are precedence-climbed by one shared, hand-tuned parser every statement reuses, because operator precedence, indirection, and function calls are painful and slow as pure grammar rules. So the declarative grammars stay small, and the one genuinely hard part is solved once underneath them.

Entry-checking walks the grammar. basic.check runs a line's matcher tree, and its three-way status falls straight out of the walk:

  • input ends while a matcher still expects more → incomplete (still typing — render pending, not red);
  • a token no matcher can accept → invalid, at that column;
  • the tree consumes the whole line → ok.

Because each node knows what it wanted, the failed node is the error: the unmatched KW(TO) produces expected TO and the caret column, with no separate error table. The same walk emits colouring spans (a token matched by KW → keyword class; a span parsed as EXPR → its own sub-spans), so check, error text, and colouring are one pass over one grammar.

HELP renders the grammar back. The HELP command walks the same tree to print the readable form, over both keywords and the command help headers:

  • HELP FOR → FOR var = start TO end [STEP n] … · counting loop · abbrev f. — generated from the SEQ(…), so it cannot disagree with what the checker accepts;
  • HELP (bare) → the keyword set and commands in token-table order; HELP p. and help print resolve by the tokeniser's prefix logic.

The escape hatch — honestly. A few statements are genuinely irregular — PRINT's , / ; / TAB() layout, DATA's raw-to-end-of-line, IF … THEN <line-number | statement>. These opt out for a custom parse function plus a hand-written template, which is the same reason hand-written BASIC parsers exist. The regular majority — most of the language — declare a grammar and get check, errors, help, and colouring for free; the awkward handful bypass it cleanly, so the matcher vocabulary never has to be contorted to cover every quirk.

The basic core holds one grammar per keyword; basic.check, the error text, basic.spans, and HELP are all readers of it.

The matcher vocabulary

The node set a statement grammar is built from. Each node does three jobs from one definition — match (consume tokens or fail), render (produce its slice of the template), explain (name what it expected on failure):

Node Matches Renders On mismatch
SEQ(a, b, …) each child in order children, space-joined fails at the first child that fails
OPT(x) x, or nothing [ x ] never by absence; a started x that then fails does
ALT(a, b, …) the first child that applies a \| b \| … "expected one of …" if none apply
KW(tok) the keyword token tok the keyword — TO, STEP "expected TO"
LIT("=") that exact operator / punctuation the literal — =, , "expected ="
VAR a variable lvalue (name + % / $ suffix) var "expected a variable"
NAME a bare identifier (a PROC / FN name) name "expected a name"
EXPR an expression, via the shared precedence parser expr the expression parser's own error + column
LINE# a line-number reference line "expected a line number"
LIST(item, sep) one or more item separated by sep item [sep item]… as item
REP(item) zero or more item item… — (empty is allowed)
CUSTOM(fn, tmpl) whatever fn accepts — the escape hatch above tmpl, hand-written whatever fn reports

Any matcher may carry a display label used only when rendering — EXPR("start") renders start, not expr; it never affects matching. That is how SEQ(VAR, LIT("="), EXPR("start"), KW(TO), EXPR("end"), OPT(KW(STEP), EXPR("n"))) prints as FOR var = start TO end [STEP n] while checking the real thing.

CUSTOM is the one node that breaks the "one definition, three jobs" rule — it hand-writes its template because its fn is opaque to the renderer. It exists only for the irregular statements; a grammar built from the other nodes gets its template and its errors for free.

Prediction and completion

The grammar answers a fourth question: what may come next? Autocomplete from a flat keyword list can only offer every keyword; a grammar walk offers only what is valid at the cursor — the categorical difference. The mechanism reuses the checker: walk the line's matchers up to the cursor, then ask the next unsatisfied node what it would accept. That is the very node that would produce expected TO on a mismatch — completion and error-reporting are one question in two moods ("what did I want?" → error; "what do I want?" → suggestion).

Each node type has an obvious offer:

At the cursor Node Offer
a mandatory token KW(TO) / LIT("=") the token itself — often a single completion, just insert it
a branch point ALT(a, b, …) a menu of the branches' first tokens — IF … THEN → line number or statement
an operand EXPR / VAR names in scope — the pre-pass's variable/slot table, CONSTs, and PROC / FN names
start of line — the keyword set, in token-table order

Two grades, one source:

  • Predict — when the next node is a single token (a mandatory KW / LIT), just complete it. FOR i = 1 + the key → inserts TO; no menu.
  • Choose — when it is an ALT, or a name from a set, pop a selection to arrow through. This is the special-key case you described.

The trigger keys are an AntOS setting (as the pre-flight mode is): the defaults are Tab to accept or cycle a prediction and ^Space to force the menu, both rebindable.

It composes with the rest of the language surface:

  • Abbreviations — the menu can show PRINT but insert the short p. (or the reverse), the abbreviation table reused as-is.
  • Paused programs — because the offer for EXPR / VAR reads the live context's slot table, the debugger's command line completes that program's names, not just globals.
  • One more library reader — basic.spans / basic.check already walk the tree; completion just returns the pending node's offer instead of a verdict (basic.suggest).

One honest limit. Mid-expression is the weak spot: after PRINT a + the grammar knows "an operand follows," which yields names in scope and function names, but not a ranked guess — that wants type and usage heuristics the grammar doesn't carry. So prediction is exact at statement structure (keywords, TO / THEN / STEP, the ALT menus, names in scope) and best-effort inside raw expressions — completing like a modern editor where the grammar is precise, and degrading gracefully to "names in scope" where it isn't. Still from the same tree that checks, colours, and helps, with no separate completion database.


Contexts — parallel BASIC

There is one global main space (the console's program), which keeps the Ready. model honest — there is the program. But a BASIC program can start another that runs in its own context — its own program store and variables — on its own worker thread, truly in parallel:

10 REM start a background job, keep going
20 SPAWN "worker.bas"
30 PRINT "worker launched, carrying on"

A spawned context is just another isolated AntOS task (the same worker-lane model the Luau runtime uses for non-UI scripts), so contexts do not share variables and cannot corrupt each other's state. Every inspection verb (lines, vars, run, state, get_var, …) takes an optional context handle, defaulting to main — so list, a debugger, or a monitor works against the console program or a spawned one, unchanged. See basic.spawn.

A line-numbered BASIC that can fork parallel programs is exactly the kind of weird the platform is for.


Structured statements

Control flow follows BBC BASIC, so a BBC BASIC book — or the muscle memory that comes with one — carries straight over. Four nestable block constructs join the FOR … NEXT loop AntBASIC already has, and they pair with the PROC/FN procedures below.

REPEAT … UNTIL

Bottom-tested loop; the body always runs at least once.

10 REPEAT
20   n = n - 1
30   PRINT n
40 UNTIL n = 0

WHILE … ENDWHILE

Top-tested loop; the body may run zero times. BBC BASIC's spelling — ENDWHILE, not the Microsoft WEND (though WEND is accepted on entry as a synonym and lists back as ENDWHILE).

10 WHILE n > 0
20   n = n - 1
30 ENDWHILE

FOR … IN — iterate a collection

Alongside the classic counting FOR x = a TO b STEP c, AntBASIC adds a for-each form that walks the elements of a collection — a modern convenience beyond BBC BASIC, in the same spirit as BREAK:

10 FOR score IN results()
20   total = total + score
30 NEXT

FOR x IN y runs once per element of y — LEN(y) iterations (a string's length, an array's element count) — with x set to each element value in turn, in index order. The collection y can be:

  • an array — FOR n IN a(), FOR nm$ IN names$() — every element, low index to high;
  • a string — FOR c$ IN word$ — each character in turn.

The loop variable's type must match the elements: a numeric variable for a numeric array, a string variable for a string array or a string's characters. x holds a copy of each element, so assigning to x does not change the collection. An empty collection runs the body zero times (like WHILE). It closes with the same NEXT (optionally NEXT x), nests and indents like any loop, and BREAK exits it. IN joins the token table as a keyword; FOR then reads as the for-each form when IN follows the variable, and the counting form when = does.

IF … THEN … ELSE, and block IF … ENDIF

Two forms, disambiguated exactly as BBC BASIC does it — by whether anything follows THEN on the line:

  • Single-line — statements after THEN (and an optional ELSE), all on one line:
    10 IF x > 0 THEN PRINT "positive" ELSE PRINT "not"
  • Block — THEN is the last token on the line, so the block runs to ENDIF, with an optional ELSE:
    10 IF x > 0 THEN
    20   PRINT "positive"
    30   total = total + x
    40 ELSE
    50   PRINT "not"
    60 ENDIF

That single rule — is THEN the end of the line? — is the whole distinction, and it is one the tokeniser settles at entry.

A bare line number after THEN (or ELSE) is an implicit GOTO, as in countless type-in listings: IF x THEN 100 means IF x THEN GOTO 100, and IF x THEN 100 ELSE 200 branches either way. Accepted on entry; it lists back with the explicit GOTO.

CASE … OF … WHEN … OTHERWISE … ENDCASE

BBC BASIC's multi-way branch. The expression is evaluated once; each WHEN takes one or more comma-separated values; OTHERWISE catches the rest. There is no fall-through — a matched WHEN runs and the CASE ends, so no break is needed.

10 CASE key$ OF
20   WHEN "y", "Y" : PRINT "yes"
30   WHEN "n", "N" : PRINT "no"
40   OTHERWISE     : PRINT "don't know"
50 ENDCASE

How they behave

All four nest freely and mix with FOR … NEXT, PROC/FN, and GOTO/GOSUB. Keywords fold case-insensitively like everything else; ENDWHILE / ENDIF / ENDCASE are single keywords (no space), matching BBC BASIC. They join the v1 keyword set and the token table as ordinary tokens.

Each construct is an opener paired with a closer, some with mid-block markers — which is precisely the structure a listing indenter walks:

Opens a block Closes it Mid-block
REPEAT UNTIL cond —
WHILE cond ENDWHILE —
IF cond THEN (block form) ENDIF ELSE
CASE expr OF ENDCASE WHEN …, OTHERWISE
FOR … NEXT —

A mismatch — UNTIL without REPEAT, ENDWHILE without WHILE, a missing ENDIF — is cross-line, so per errors caught at entry it surfaces at RUN, not on the line you type: each line is individually well-formed, and only the whole program reveals the imbalance.

Auto-indented listing (LISTO)

This opener/closer set is exactly what the "indented" listing style needs: every opener raises the indent depth, every closer lowers it, and ELSE / WHEN / OTHERWISE dedent just their own line. A BBC BASIC book calls this LISTO — the LIST option that indents structured loops — and AntBASIC exposes the same idea through list (over basic.lines("indented")). Because the depth is computed from the block stack, the indented form is a pure rendering of the tokens — never stored, and still shown best-effort for a half-finished program mid-edit.

GOTO, GOSUB, and computed jumps

The older unstructured jumps are fully supported — BBC BASIC leans on PROC / REPEAT / WHILE in preference, but the jumps are always there — with two BBC-isms worth stating:

  • GO TO / GO SUB are accepted as readily as GOTO / GOSUB. The space is optional on entry; both spellings fold to the same keyword, and LIST normalises to the one-word form — the same way case folds to canonical. (So a listing typed from an old book with GO TO lists back as GOTO.)
  • The destination may be an expression, not just a literal line number. GOTO 1000 + 10*level% or GOSUB base% evaluates at run time and jumps to the resulting line — the compact alternative to ON n GOTO …. A computed line that doesn't exist is a runtime ?LINE NOT FOUND; it can't be caught at entry, because the target isn't known until the expression runs.

ON … GOTO / ON … GOSUB — the indexed jump, again BBC-style. An expression selects the nth line number from a list (1-based): ON x GOTO 100, 200, 300 jumps to 200 when x is 2. ON … GOSUB does the same as a call. An out-of-range index (0, negative, or past the end) falls to an optional trailing ELSE (BBC's OTHERWISE is accepted too); with no ELSE, an out-of-range index is a runtime error.

10 ON choice% GOSUB 1000, 2000, 3000 ELSE PRINT "no such option"

Here the list entries are literal line numbers, so renumber does rewrite each one — unlike a computed GOTO expr, whose target it must leave alone.

One consequence for renumber with the computed form above: it can rewrite only literal line-number jumps. A computed GOTO/GOSUB carries an expression, not a line number, so renumber leaves it untouched (and flags it) — it can't know where the jump will land. Keep computed jumps in mind when you renumber.

RESTORE

RESTORE resets the DATA / READ pointer to the first DATA statement, so a program can re-read its data. RESTORE <line> resets it to the first DATA at or after a given line (the BBC form), and that line may be an expression — RESTORE base% + set%*10 — evaluated at run time, exactly like a computed GOTO.

The same renumber distinction applies: a literal RESTORE 500 is rewritten when you renumber, while a computed RESTORE <expr> is left alone (and flagged), since its target isn't known until it runs.

Early exit — BREAK and POP

Two ways to leave a structure early, for when the structured forms don't fit:

  • BREAK exits the innermost enclosing loop (FOR, REPEAT, or WHILE), continuing after its closer. It acts only on loops opened in the current program level or PROC/FN — it cannot break a caller's loop across a PROC boundary. An optional count breaks several levels: BREAK 2 exits the two innermost loops, and the count may be an expression (BREAK n%). BREAK with no enclosing loop — or a count past the loops open in the current routine — is a runtime error.
  • POP discards the top return address on the GOSUB stack (the Applesoft classic), turning "we're in a subroutine" into "we're not" — so a subroutine can GOTO out without the pending RETURN address dangling. It touches only the GOSUB stack, not PROC/FN frames. An optional count pops several: POP 3 discards three return addresses (an expression is fine — POP n%). POP on an empty stack, or a count past its depth, is a runtime error.

Both are keywords in the token table. Like the loop-imbalance errors, a BREAK / POP with nothing (or too little) to act on is knowable only at run time, so it surfaces at RUN, not on entry.


Error handling — ON ERROR, END, and STOP

Ending: END and STOP

END ends the program cleanly and returns to Ready. with no message. STOP halts with STOP at line nnnn, sets ERL, and is trappable like any other error — the debugging counterpart to END's silent finish.

Trapping errors: ON ERROR

BBC BASIC's error trap — a book will reach for it on the first page of error handling.

  • ON ERROR <statements> installs a handler: on a runtime error, control jumps to those statements instead of stopping. Usually a jump to a handler routine — ON ERROR GOTO 9000 — or an inline report:
    10 ON ERROR PRINT "Error "; ERR; " at line "; ERL : END
  • ON ERROR OFF restores the default (print the message, stop).
  • ON ERROR LOCAL <statements> scopes the handler to the current PROC/FN, restored on exit — the structured form, so a procedure traps its own errors without disturbing the caller's handler.

Three read-only values describe the last error: ERR (its number), ERL (the line it occurred on), and REPORT (prints its message text; REPORT$ returns that text as a string).

On a trapped error the loop / GOSUB / PROC stacks are unwound and the handler runs at top level (BBC behaviour), so a handler cannot NEXT or RETURN back into where the error struck — it recovers, then re-enters the program deliberately. ON ERROR LOCAL is the exception that keeps handling within the current procedure.

Per context. Each context — the main space and every spawn — has its own handler and its own ERR / ERL. An untrapped error faults only its own context — the same isolation as a bad LUA() call or a native-code fault — so a worker context erroring cannot take the console down. And because a LUA() failure surfaces here as an ordinary trappable BASIC error, ON ERROR catches Luau errors too.

Tracing and debugging

Stopping a runaway program

The Escape key stops a running program — Escape at line nnnn, back to Ready. at the line it reached — and, like BBC BASIC, it is a trappable condition, so an ON ERROR handler can catch it and tidy up.

Breaking out of machine code is the interesting case, and the two assemblers differ by where the code runs:

  • A { } routine runs on the FPGA soft core — a different chip from DeMon. DeMon stays responsive whatever the core is doing, so a runaway { } loop is always breakable: DeMon halts the core and takes control back.
  • A [ ] routine runs on DeMon, inside the calling context (a worker thread under AntOS). A runaway [ ] loop hangs only that context, not the machine — the same fault-isolation that stops a bad CALL taking DeMon down — and Escape, coming from the console (a different context), interrupts or resets it.

So a stuck loop, BASIC or assembly, is recoverable rather than a power-cycle.

Trace — TRACE

TRACE ON prints each line number in brackets as it runs — [100][110][120]… — the BBC BASIC trace; TRACE OFF stops it. TRACE <line> traces only until execution passes a given line (BBC's form, for narrowing to a region). TRACE STEP goes further and single-steps, pausing after each line so you can watch execution one line at a time — the on-ramp to the debugger. Trace output can go to the console or a separate debug channel, so it needn't scribble over the program's own display.

The debugger

Because the program is a tokenised store with a line table and slot-allocated variables, a source-level debugger is a Luau tool over the basic library — tooling, like everything else. It drives a context and offers:

  • breakpoints at a line (resolved through the line table);
  • single-step by line or statement;
  • inspect and change variables (read and write the slot table live);
  • the call stack — the GOSUB / PROC / loop frames;
  • watch expressions, evaluated against the paused context.

Running the target in a context is what makes this safe: the debugger sits in the console context and steps the target, so a breakpoint — or a runaway loop — pauses the target without freezing the tool. For the assembly inside a program, the soft cores carry their own hardware debug (single-step, breakpoints, register access — see the eZX and EE CPU docs), and the { } cross-processor boundary means DeMon can drive that debug while the core is stopped.


Procedures, functions, and native code

PROC and FN

Structured subroutines, BBC-style — the recommended path over bare GOSUB:

  • DEF PROC name(params) … ENDPROC, called PROC name(args) — a named procedure with parameters.
  • DEF FN name(params) = expr (or a multi-line body ending = value), called FN name(args) inside an expression.
  • LOCAL var localises variables inside either — see Variable scope below.

Names fold case-insensitively like everything else, and a space is allowed — PROC draw and PROCdraw are the same procedure, FN square and FNsquare the same function. These are append-only keywords with a call/return frame.

Returning multiple values and structures

BBC BASIC's FN returns a single value and PROC returns none. AntBASIC widens both, leaving the classic forms untouched.

RETURN parameters — the BBC way to hand results back from a PROC. A parameter prefixed RETURN is passed by reference: the procedure writes to it and the caller's own variable changes (so the argument must be a variable, not an expression).

10 DEF PROCdivmod(a%, b%, RETURN q%, RETURN r%)
20   q% = a% DIV b% : r% = a% MOD b%
30 ENDPROC
40 PROCdivmod(17, 5, quo%, rem%)      : REM quo% = 3, rem% = 2

Multiple values from FN — a modern extension beyond BBC: an FN may return several values with = a, b, …, and the call unpacks them into a matching list of variables.

10 DEF FNminmax(a, b)
20   IF a < b THEN = a, b
30 = b, a
...
100 lo, hi = FNminmax(p, q)

The number of variables on the left must match what the FN returns, or it is an error.

Returning a structure — because a structure is a copyable laid-out block, an FN can return a whole one, and structures pass in as parameters too:

10 DEF FNmidpoint(p{}, q{})
20   LOCAL m{} = point{}
30   m.x% = (p.x% + q.x%) DIV 2
40   m.y% = (p.y% + q.y%) DIV 2
50 = m{}
...
100 mid{} = FNmidpoint(a{}, b{})

A structure argument passes by value (a copy, since parameters are LOCAL) unless declared RETURN v{}, which passes it by reference so the procedure edits the caller's structure in place — the tidy way to transform a big record without copying it back out.

Default parameter values — a parameter may declare a default, used when the call omits that argument:

10 DEF PROCbox(w%, h%, colour% = 7)
20   ...
30 ENDPROC
40 PROCbox(20, 10)          : REM colour% defaults to 7
50 PROCbox(20, 10, 2)       : REM colour% = 2

Defaults are evaluated when the argument is missing, and only trailing parameters can sensibly carry them (once one is defaulted, the rest of the call's positional arguments are gone).

Built-in functions share all of this. The built-in function library, when added, follows the same calling conventions as PROC/FN — multiple return values, structure arguments and returns, RETURN-parameter outputs, and default parameters. So w%, h% = FNscreensize or a built-in that fills a structure reads exactly like a user procedure; there is no separate rule set for built-ins to remember.

Variable scope

By default every variable is global — BBC BASIC's model, one namespace, collected into slots by the RUN pre-pass.

LOCAL var[, var…], inside a PROC/FN, makes those names local for the duration of the call: on entry it saves their current values and gives them fresh, zeroed ones; on ENDPROC / = it restores them. A local does not create a separate variable — it borrows the (global) name, save-and-restore, exactly as BBC BASIC does. Parameters are automatically LOCAL: DEF PROCbox(w%, h%) makes w% and h% local, receiving the passed values.

10 DEF PROCscale(f)
20   LOCAL i%
30   FOR i% = 0 TO n% : a(i%) = a(i%) * f : NEXT
40 ENDPROC

Two BBC characteristics worth stating plainly:

  • Scope is dynamic, not lexical. Because LOCAL is save/restore of a shared name, a procedure called from within another sees the caller's localised value, not the global — the classic BBC behaviour. Keep names distinct where that would surprise you.
  • LOCAL extends to arrays (LOCAL a()), and the later BBC forms LOCAL ERROR and LOCAL DATA scope the error handler and the DATA pointer to the procedure as well.

For a value that persists between calls but stays invisible outside — C's static local — PRIVATE follows BBC BASIC for Windows: declared like LOCAL, but its value is remembered from one call to the next.

Inline assemblers — ARM64 and RISC-V

Like BBC BASIC, AntBASIC has a built-in assembler — in fact two, because the Ant64 has two kinds of processor:

Block Assembles Runs on Detail
[ … ] ARM64 (A64) natively on DeMon's CM5 — CALL/USR jump straight to it antbasic_asm
{ … } RISC-V — enhanced RV64 on the FireStorm EE, enhanced RV32 on the eZX the FPGA soft core — the code is placed in the RAM the FPGA can access and runs there antbasic_asm_riscv

Both share one model, so learning either is learning both; the essentials ({ } mirrors [ ], e.g. { mul a0, a0, a0 }):

  • [ … ] delimits assembly — multi-line, or a single line: [ MUL X0, X0, X0 ].
  • Assembly accumulates. Each block appends after the last instruction assembled in that execution area; the assembly cursor (P%) advances on its own. You only set P% / O% / ALIGN when a directive overrides — no per-line bookkeeping, unlike classic BBC BASIC.
  • Labels and variables are one namespace. A .label is simply a BASIC variable the assembler sets to the current address; a BASIC variable used as an operand supplies its current value. So CALL square works because square is a numeric variable holding the routine's address — the BASIC variable space is the symbol table.
  • CALL / USR transfer into assembled code and back, passing values through registers.
10 DIM code% 256
20 [
30   .square            \ .square becomes a BASIC variable = this address
40   MUL X0, X0, X0     \ X0 = X0 * X0
50   RET
60 ]
70 REM 'square' is now an ordinary variable holding the routine address
80 CALL square

The { … } RISC-V assembler targets the machine's own processor rather than the supervisor's — an enhanced RV64 core on the full machine (the FireStorm EE) or an enhanced RV32 core under the eZX image, both sharing the same custom extension families. Independently of that, each instruction is 32-bit (narrow) or 36-bit (wide) depending on where the code sits in FPGA memory. See antbasic_asm_riscv for that two-axis model and how assembled code reaches the FPGA core.

Two ways [ ] is not the 1980s, both engine-handled: assembled ARM64 lands in executable memory (Linux is W^X, so a plain DIM buffer cannot be jumped to), and a bad CALL faults only its own context, not DeMon. ({ } code runs on a different processor entirely, with its own isolation — see its page.) See antbasic_asm for the ARM64 model, directives, calling convention, and subset.

Runtime assembly — DEFER and ASM()

Assembling in the pre-pass is the default because it catches assembly errors before execution and adds no runtime stall. But sometimes a block needs a value only a running program has — a computed address, a size from input. Two escape hatches assemble at run time instead.

DEFER — a directive inside a block marks it deferred: the pre-pass skips it, and it assembles the moment execution first reaches it. That lifts the pre-pass constraint — a deferred block can use runtime variables as operands — at the cost you'd expect: a stall when first hit, and any assembly error surfaces then (as a trappable runtime error), not up front. A deferred block in a loop re-assembles each time through, so the usual shape is assemble once, CALL many times.

10 base% = FNfindfreepage      : REM only known at run time
20 [ DEFER
30   OPT base%                 \ cursor from a runtime variable
40   .entry
50   ...
60 ]
70 CALL entry

ASM(source$ [, type] [, address]) goes further — it assembles a string at run time and returns the number of bytes assembled, the assembler's answer to LUA(). The source is ordinary assembly text, built however you like; type picks the target — "arm" (ARM64, the default, on DeMon), "ee" (RV64) or "ezx" (RV32) for the FPGA cores; address is where to assemble to (absolute), defaulting to the current P%, which it then advances. Labels and BASIC variables share one namespace exactly as in a block, so a string can define .entry (setting a BASIC variable) and read BASIC variables as operands.

10 n = ASM("mul a0, a0, a0 : ret", "arm", buffer%)
20 PRINT n; " bytes"
30 CALL buffer%

A bad source raises a trappable BASIC error carrying the assembler's message and column, so ON ERROR catches a failed ASM() like any other. For an FPGA type, the assembled bytes are sent to the correct FPGA memory location just as a { } block's are. This is full-capability runtime code generation — treat an untrusted ASM() string exactly as you would untrusted machine code.


Beginner on-ramps

AntBASIC keeps the BBC core but borrows the ideas that made LOGO and PILOT great first languages — LOGO's do-it-N-times and name-your-own-actions, PILOT's trivial interaction — as always-present features. Because each is a grammar entry, a beginner can discover them: HELP REPEAT shows the syntax, completion offers them as you type. (LOGO's most visual idea, the turtle, isn't a set of keywords at all — it's the motion-sequence system below, reached through DRAW.)

REPEAT n ( … ) — LOGO's count-repeat

REPEAT 4 (PRINT "hi") — "do this four times" with no loop variable, plainer to a novice than FOR i = 1 TO 4 … NEXT. It overloads REPEAT, which already carries the BBC REPEAT … UNTIL post-test loop; the grammar tells them apart with an ALT — a count then a ( … ) body is the count form, a body that runs to UNTIL is the BBC form. Rounded brackets keep [ … ] unambiguously the assembler — no shared meaning at any position, no context-sensitivity. (BLOCK in the matcher vocabulary is exactly "( statements )".)

Procedures become commands — from LOGO

LOGO's magic is that defining square then lets you just type SQUARE. AntBASIC has PROC but you call PROCsquare; letting a bare square resolve to a user PROCsquare through the console's command lane gives that "your procedures extend the language" feel.

The entry check needs care here — it's why this is a design point, not a freebie. A bare square cannot be validated at the keystroke the way a keyword can, because whether a matching PROC exists is a whole-program fact, not a line-local one. So it lands in the existing cross-line / runtime tier: the line is accepted at entry as a well-formed command invocation (an identifier, optional arguments), and its existence is resolved later — the RUN pre-flight reports "no such command sqaure" before execution, exactly as it flags an undefined PROC or a GOTO to a missing line. The cost is honest: a mistyped command name is caught at RUN, not as you type it (a keyword typo is caught at the keystroke) — but it is caught before anything runs, and needs no new machinery. Resolution order when a name could be several — user PROC, then D:/scripts command, then keyword — is the rule to pin down.

Match-and-respond — from PILOT

PILOT made a quiz trivial. Brought in BASIC-flavoured, not as PILOT's terse T: / A: / M: lane:

10 ASK "Capital of France? " TO answer$
20 WHEN answer$ HAS "paris" : PRINT "Correct!"
30 OTHERWISE                : PRINT "Not quite."

ASK is a friendly INPUT (prompt, then read to a variable); WHEN … HAS … hides the substring search a beginner would otherwise write with INSTR. Ask, match, respond — PILOT's whole teaching loop in three readable lines, each keyword arriving with its own HELP and completion.


Motion sequences

The turtle is data, not keywords: a path is a string of movement verbs ("FD 100 RT 90 …") applied to a target. BASIC's DRAW and MOVE are the friendly front door onto the standalone motion subsystem — the path language, targets, background execution, and the OS-library → sprites → EE roadmap live there, so this stays a thin surface that can't drift from the engine.

10 sq$ = "REPEAT 4 (FD 100 RT 90)"    \ a path is an ordinary string, built with BASIC
20 DRAW sq$                            \ turtle draws it — parsed here, at DRAW
30 MOVE sprite%, sq$, LOOP             \ the SAME string, now looping sprite motion, in the background
  • DRAW path$ — play a path on the turtle (pen, on the drawing layer).
  • MOVE target, path$ [, LOOP] — play it as motion on a sprite (no pen); LOOP repeats until stopped.
  • STOP cancels a target's sequence; MOVING(target) / DRAWING report whether one is still running.
  • Re-issuing to the same target replaces the running sequence (per-target single) — DRAW / MOVE fire and return, and the sequence runs in the background until it completes, loops, or is replaced.

REPEAT n ( … ) works inside a path as well as at the statement level, and a malformed path is reported with a column and reason by the same checking machinery. The full path grammar, the compile-once op list, and the executor roadmap are in motion (Luau: require("motion")).


Animation verbs — the grammar entries

The animation family reaches BASIC through a handful of front-door keywords, and — like everything else in the language — each is a real grammar entry, so it checks on entry, colours, answers HELP, and completes. The single-line verbs:

KEYWORD(DRAW,  SEQ(EXPR("path$")),                                                    "play a path on the turtle")
KEYWORD(MOVE,  SEQ(EXPR("target"), LIT(","), EXPR("path$"), OPT(LIT(","), KW(LOOP))), "play a path as sprite motion")
KEYWORD(TWEEN, SEQ(EXPR("target"), LIT(","), EXPR("prop$"), LIT(","), EXPR("to"),
                   OPT(LIT(","), EXPR("secs"))),                                       "animate a property over time")
KEYWORD(STOP,  OPT(EXPR("target")),                                    "cancel a target's sequence (or, bare, halt the program)")

The functions — used inside expressions, not as statements — let a program test rather than subscribe:

  • TOUCHING(a, b) → TRUE when two collision targets overlap now;
  • MOVING(target) / DRAWING → TRUE while a sequence is still running.

ON COLLISION extends the existing ON family (exactly as ON ERROR does): ON COLLISION a, b : <stmt> registers <stmt> as the collision enter handler — one more branch of ON's ALT.

SCENE … ENDSCENE is the timeline front door — a block whose DRAW / TWEEN / WAIT steps are sequenced, with THEN / WITH marking order — a structured statement like FOR … NEXT, opening a block that closes at ENDSCENE.

Three names clash with classic BBC keywords — resolved by grammar, not renaming

  • DRAW / MOVE. BBC BASIC's DRAW x, y / MOVE x, y are line-graphics primitives; AntBASIC's graphics are the motion path system, so these are repurposed to take a path string, not coordinates (a path's own MOVETO x, y covers the old pen-up MOVE). If BBC line-compatibility is ever wanted, the ALT can branch on argument shape — DRAW <str$> (path) vs DRAW <n>, <n> (line) — but path is the default.
  • STOP. BBC STOP halts the program; the motion cancel overloads it by argument — bare STOP still halts, STOP target cancels that target's sequence. The OPT(EXPR) in its entry is the overload, carried by the grammar rather than a second keyword.

Because each is a genuine grammar entry, HELP DRAW prints its form and D + the completion key offers it — the whole animation surface is as discoverable as the core language, and a mistyped TWEE is caught the same way a mistyped PRUNT is.


Calling Luau — the LUA() bridge

The whole platform runs on Luau, so "call Luau from BASIC" is just handing a string to the interpreter that is already there. It is the mirror of the basic library (Luau reaching into BASIC); this is BASIC reaching out to Luau — the modern heir to the C64's SYS, BBC BASIC's CALL, and Spectrum's USR, except the thing on the other side is a full scripting language with the entire AntOS library surface.

One primitive does it. LUA(expr$) evaluates a Luau string and hands the result back:

10 X = LUA("return 2 + 2")            : REM X = 4
20 PRINT LUA("return os.date()")
30 LUA("fs.copy('a.txt','b.txt')")    : REM statement form — value ignored

Luau numbers, strings and booleans marshal to the matching BASIC types; nil / no return gives an empty/zero result. As a function it composes in expressions; as a bare statement its value is discarded.

The design points, all consistent with the rest of AntBASIC:

  • A persistent Luau environment per BASIC context, created lazily on the first LUA(). So LUA("count = 0") then later LUA("count += 1") accumulates state, and a spawned BASIC context gets its own isolated Luau env, mirroring the context model exactly.
  • Values pass explicitly, through the argument and the return — no automatic variable bridging. BASIC's A%/A$ sigils and Luau's typed values do not map cleanly, and implicit shared state gets confusing fast. If the Luau side needs BASIC's variables, it reaches back through require("basic") and reads the calling context — the seam that is already documented, so no new magic.
  • Compiled chunks are cached by string — a hot loop calling LUA("return sin(x)") compiles once and reuses; only the first call pays compilation.
  • A Luau error surfaces as a normal BASIC runtime error carrying the Luau message, catchable like any other BASIC error, and it faults only its own context — it does not take the machine down.

LUA() is a full capability, not a sandbox toy. It hands BASIC the entire Luau surface — fs, net, os, everything a script can do — which is exactly the point on a machine where nothing is fixed and commands are scripts. But it means a one-line LUA("…") typed in from a magazine or a stranger can do anything a script can. It runs under the same permissions a script would, and is worth the same care: treat an untrusted LUA() line exactly as you would an untrusted command.

This is also the cheap path to the hardware-verb layer: BASIC SOUND / PLOT / SPRITE can start as thin wrappers over LUA("audio. …") / LUA("gfx. …"), prototyped in Luau and promoted to real keywords once they earn it. LUA() is the general bridge; dedicated verbs are the sugar.


Data types

AntBASIC's scalar types are the BBC / Microsoft set, with the type carried by a suffix sigil on the name (or, for a literal, inferred):

Type Suffix Size Notes
Integer % 32-bit signed −2^31 … 2^31−1. The default for whole-number work; matches the ! word-indirection width and an FPGA register
Long integer %% 64-bit signed for addresses, file sizes, large counts — the CM is a 64-bit machine, so a %% holds a pointer
Float (none) 64-bit IEEE 754 double the default numeric type — a bare x is a float; ~15–16 significant digits
String $ variable length 8-bit-clean bytes, not capped at the classic 255; UTF-8 is stored and passed through as bytes

TRUE and FALSE are the integer constants −1 and 0 (the BBC convention, so NOT and the bitwise operators compose cleanly), and a condition treats any non-zero value as true.

A bare numeric literal is an integer if it is written with no decimal point or exponent and fits, and a float otherwise; 0x / $ / 0b / % / & literals are always integers. A name's suffix fixes its type for its whole life — count% is always integer, count always float, and they are different variables.

Coercion is quiet between numbers, explicit across strings. Integer and float mix freely in an expression (the integer promotes to float); assigning a float to an integer variable rounds to nearest (INT truncates if that is what you want). Numbers and strings never convert implicitly — STR$ and VAL do it on purpose — so "12" + 3 is an error, not a silent 15 or "123".

The aggregate types — arrays, structures and the collections below — are built from these scalars, or from each other.


Operators

The operator set is BBC BASIC's, extended with shifts. Precedence, highest first; within a row, left-to-right, except ^, which is right-associative:

Operators Meaning
( ) FN… a(i) .field ? ! $ \| grouping, calls, indexing, indirection
unary - + NOT negate; bitwise complement
^ power (right-associative), float result
* / DIV MOD multiply; float divide; integer divide; remainder
+ - add / subtract (and string + is concatenation)
<< >> >>> shift left; arithmetic (sign-filling) right; logical (zero-filling) right
= <> < > <= >= comparison
AND bitwise / logical AND
OR EOR bitwise / logical OR, exclusive-OR

Arithmetic. / is always float division (7 / 2 → 3.5); DIV and MOD are the integer pair (7 DIV 2 → 3, 7 MOD 2 → 1), converting float operands to integer first. MOD takes the sign of the dividend, DIV truncates toward zero, and ^ yields a float.

Comparison returns TRUE (−1) or FALSE (0). Strings compare byte by byte — "apple" < "banana" — with no case-insensitive operator (fold with a function when you need one).

Bitwise, and logical — the classic BBC design, worth stating plainly. AND, OR, EOR, NOT are bitwise operators. They double as the logical connectives because a comparison yields all-ones (−1) for true and all-zeros (0) for false — so IF x > 0 AND y > 0 reads exactly as written, while flags% AND &04 masks a bit. The one thing to know: they are not short-circuit — both sides always evaluate — so a guard like IF p% <> 0 AND ?p% = 1 still reads ?p% when p% is zero. When the second test isn't safe to run, split it into a nested IF.

Shifts move an integer's bits: x << 3 multiplies by 8; x >> 1 is an arithmetic, sign-preserving halving; x >>> 1 fills from the top with zero, for when the value is unsigned. Integer-only.

Strings take + (concatenation) and the comparison operators; slicing, searching and repeating are functions (LEFT$, INSTR, STRING$, …), not operators.


Indirection

Reading and writing raw memory at an address — AntBASIC's PEEK / POKE, but as operators rather than keywords, typed by which symbol you use:

Operator Width Reads / writes
? 1 byte an 8-bit value
! 4 bytes a 32-bit integer
\| 8 bytes a 64-bit float (a double)
$ varies a string, terminated in memory

Each has a monadic form — the value at an address — and a dyadic form — the value at base + offset (offset in bytes):

10 ?screen% = 32               : REM poke one byte
20 x% = !ptr%                  : REM peek a 32-bit word
30 sprite%!4 = ypos%           : REM 32-bit field at sprite%+4
40 $name% = "PLAYER 1"         : REM write a terminated string
50 c% = block%?i%              : REM byte at block%+i%

The symbol in an expression reads; the same symbol as the target of an assignment writes — so ?a peeks a byte and ?a = pokes one. There are no separate PEEK / POKE verbs; these operators are them.

How this fits with ? and $ already being taken. Both are overloaded, and the rule is the one used everywhere else in the grammar — position decides:

  • ? is PRINT at the start of a statement, indirection inside an expression. ? x prints x; x = ?a peeks. The only clash is a byte-poke at statement start: ?a = 5 has a top-level =, so it is an assignment — a poke — while a statement meant to print a comparison takes parentheses, ? (a = 5), or the PRINT spelling. The dyadic poke base?off = v never clashes, and !, $, | never clash at all, since only ? doubles as PRINT.
  • $ is a string suffix after a name, a hex prefix before digits, and string-indirection before an address. name$ is the suffix; $FF is hex (the dialect rule — $ introducing hex digits); $buffer% and $(base% + 4) are indirection (a variable, or a parenthesised address). Indirection on a literal address therefore parenthesises — $(&2000) — which keeps a bare $FF unambiguously hex.

For anything wider than a word, or with named fields, don't build a ladder of ! / ? — overlay a structure on the address and read the hardware by field name. The indirection operators are the quick, ad-hoc form; structures are the readable one.


Arrays

DIM dimensions an array; () is the array bracket, where {} makes a structure. Arrays are zero-based and inclusive — DIM a(10) gives eleven elements, a(0) through a(10), the BBC convention — and carry their element type in the name's suffix:

10 DIM scores(20)             : REM 21 floats
20 DIM names$(50)             : REM 51 strings
30 DIM grid%(width, height)   : REM 2-D integer array

Elements read and write as a(i) / grid%(x, y), and DIM(a) returns the top index — DIM(grid%, 2) the top index of the n-th dimension — so you never have to remember the size you passed.

Arrays are values. A variable owns its array outright; b() = a() copies the whole thing, element for element, the same whole-object copy structures get. LOCAL a() gives a procedure its own array (saved and restored around the call), and an element can itself be a structure.

REDIM — resize

REDIM re-dimensions an existing array. By default it starts fresh, cleared as if freshly DIMmed:

10 DIM buf%(100)
20 REDIM buf%(200)              : REM 201 elements, all zero

REDIM PRESERVE keeps the existing contents across the resize, growing or shrinking around them — padding new cells with the type's zero (0, 0.0, "") and discarding any tail:

10 REDIM PRESERVE buf%(300)     : REM keep buf%(0…200), extend to 300

On a multi-dimensional array PRESERVE may change only the last dimension — the same rule as VB, and for the same reason: any other reshape would scatter every element to a new position.

SORT — order in place

SORT orders an array ascending, in place:

10 SORT scores()                 : REM ascending
20 SORT scores() DESCENDING
30 SORT people{} BY age%         : REM array of structures, by a field
40 SORT names$() USING FNcompare : REM your own comparator

Numbers sort numerically, strings by byte value; BY field sorts an array of structures on one field, and USING FNcmp hands each pair to a two-argument function returning −1 / 0 / +1 so you can order however you like. The sort is stable.


Collections

Lists, dictionaries, stacks, and queues. Arrays are fixed shapes you index by number; beyond them AntBASIC has four dynamic collections that grow and shrink as the program runs — the containers a classic BASIC made you build by hand out of arrays and bookkeeping. Each is declared with DIM, a name whose suffix gives the element type, and the collection keyword:

10 DIM inventory$ LIST          : REM ordered sequence of strings
20 DIM ages% DICT               : REM string key → integer value
30 DIM undo% STACK              : REM last-in, first-out
40 DIM jobs$ QUEUE              : REM first-in, first-out

The element type can be a structure or another collection with an OF clause — DIM enemies LIST OF sprite{}, DIM byRoom DICT OF LIST.

Unlike arrays and structures, collections are references, not values (see Memory), so handing one to a PROC shares it rather than copying it.

The operations

Adding and removing use a small shared verb set; the collection's type decides what each verb means:

LIST STACK QUEUE DICT
add PUSH l, v (end) · INSERT l, i, v PUSH s, v PUSH q, v (back) d(key) = v
remove + return POP(l) (end) · REMOVE l, i POP(s) (top) POP(q) (front) REMOVE d, key
look, don't remove l(i) PEEK(s) PEEK(q) d(key)
test EXISTS(d, key)

PUSH and POP are the same two words everywhere — the type is what makes a stack LIFO and a queue FIFO, so intent comes from the declaration, not from which verb you reached for. A LIST is the general one: push/pop at the end, INSERT/REMOVE at any index, and l(i) to read or write in place — cheap at the ends and at a held position, linear to reach a random index, because it is a linked sequence rather than a re-indexed array. (When you want O(1) random access on growable storage, that is what REDIM PRESERVE on an array is for.)

A DICT is indexed by key the way an array is by number — ages%("alice") = 30, n% = ages%("alice") — with EXISTS to test a key and REMOVE to delete one. Keys are strings by default; declare DICT KEY% for integer keys.

Counting, clearing, iterating

COUNT(c) is the number of elements or entries in any collection. CLEAR c empties it but keeps the variable; FREE c releases it entirely.

FOR … IN (see Structured statements) walks any of them:

10 FOR item$ IN inventory$      : PRINT item$ : NEXT
20 FOR name$ IN ages%           : REM a DICT yields its keys
30 FOR name$, age% IN ages%     : REM …or key and value together
40   PRINT name$; " is "; age%
50 NEXT

Iterating a DICT gives keys; the two-variable form gives key and value at once, using the same multiple-value binding that FN returns use. A STACK or QUEUE iterates in pop order without removing anything.


Structures

Beyond scalars and arrays, AntBASIC has structures — named-field records — following the DIM name{…} design from R.T. Russell's BBC BASIC (BBC BASIC for Windows / SDL), the most widely used modern BBC BASIC. The brace form deliberately mirrors the DIM name(…) used for arrays: () makes an array, {} makes a structure.

Declaring and using

10 DIM point{x%, y%, label$(20)}
20 point.x% = 10
30 point.y% = 20
40 point.label$ = "origin"
50 PRINT point.x%, point.label$

Fields are ordinary typed names — % integer, unsuffixed float, $ string — reached with dot notation. Because a structure is a fixed memory block (below), string fields are fixed-length: label$(20) reserves room for 20 characters, and a longer assignment truncates.

Whole-structure copy

Two structures of the same layout assign as a unit — the whole block copies:

10 DIM a{x%, y%}, b{x%, y%}
20 a.x% = 3 : a.y% = 4
30 b{} = a{}        : REM b now holds a copy of a's block

Empty braces — b{} — name the structure as a whole, the way a() names a whole array.

Nesting and arrays of structures

A field can itself be a structure, and you can DIM an array of them:

10 DIM point{x%, y%}
20 DIM line{a{} = point{}, b{} = point{}}   : REM a struct of two structs
30 line.a.x% = 0 : line.b.x% = 100

40 DIM people{(100) name$(20), age%}        : REM 101 structures
50 people{(3)}.age% = 42
60 people{(3)}.name$ = "Ada"

A nested field takes another structure's layout with field{} = template{}; an array of structures puts (size) inside the braces and indexes with name{(i)}.

Structures are laid-out blocks

This is the part that matters on this machine. A structure is not a bag of named variables — it is a contiguous block of memory with its fields at fixed, defined offsets:

  • fields sit in declaration order, each a defined size — integer, float, an n-byte fixed string, and sized-integer fields (byte, 16-bit, 32-bit) where a layout demands them;
  • the layout is packed and little-endian (matching the ARM and RISC-V targets), so it is predictable — no surprise padding — and two structures declared alike share the same layout byte-for-byte, which is what makes whole-structure copy and nesting well-defined;
  • a structure has a base address and interoperates with BASIC's indirection (?, !, $, |) like any memory block.

Because the layout is defined rather than opaque, a structure can be laid over a hardware register block: declare the fields to match a sprite attribute, a copper entry, or a chipset control block, point the structure at that address, and read and write the hardware by field name:

10 DIM sprite{x%, y%, frame%, flags%}    : REM matches a hardware sprite attribute

That is the natural on-ramp to the hardware-verb bridge: rather than every SPRITE / PLOT being a bespoke keyword, a struct overlaid on the chipset lets BASIC poke registers directly by named field, and the sugar keywords can follow. (The overlay-at-address mechanism and the chipset field maps are design-stage — noted here so structures are built to support them.)

Notes

DIM name{…}, the .field accessor, name{} (whole struct) and name{(i)} (array element) are the pieces the tokeniser handles; field names live with the structure, not in the global variable table. Structures are workspace objects like arrays — they count against basic.free() and clear on NEW.


Memory, ownership, and lifetime

The natural next question — where does all this live, and when does it go away? — has a deliberate answer: AntBASIC has no garbage collector and never pauses to collect. On a machine that runs games and real-time code, an interpreter that stops for a random few milliseconds to sweep the heap is exactly the wrong behaviour, so reclamation here is deterministic — you can always say when a given object is freed.

Scalars never allocate. Integers, longs and floats live in fixed slots assigned by the RUN pre-pass; a % variable is just a machine word in the variable space, with nothing to manage.

Everything variable-sized lives on the workspace heap — strings, arrays, structures, collections — and is reclaimed in one of two ways, depending on whether it is a value or a reference.

Values are owned, and freed the moment their owner lets go. Arrays, structures and strings are values: a variable owns one outright, and assignment copies (whole-array and whole-struct copy; strings are copy-on-write, so a$ = b$ shares until one side changes). Because ownership is exclusive there is nothing to trace — the storage is freed the instant the owner is overwritten, REDIMmed, or its scope (PROC, FN, context) ends. No counting, no cycles, no collector.

Collections are shared, and freed by reference count. The dynamic collections (LIST / DICT / STACK / QUEUE) would be far too costly to copy on every PROC call, so they are references: passing one shares it, and each carries a reference count. When the last name referring to a collection goes away — reassigned, or its scope exits — the count reaches zero and the collection is freed right then, still with no pause and no sweep.

FREE — let go now, and the one escape hatch. FREE x releases an aggregate immediately rather than waiting for its owner to fall out of scope — worth doing for a large temporary inside a long-running loop, so its space returns to the arena at once. It is also the answer to the single thing reference counting cannot reclaim on its own: a cycle — a collection that refers to itself, directly or through another. Cycles are rare (BASIC data is overwhelmingly trees and sequences, not arbitrary graphs), so rather than pay for a cycle-collector no one's frame budget wants, AntBASIC leaves the rare cycle to an explicit FREE.

A context's whole world frees at once. Because every object above comes from the running context's arena, ending a basic.spawn context does not walk and free each object — it drops the entire arena in a single operation, the fastest cleanup there is. NEW does the same to the main space.

So the model is compact: slots for scalars, owned values for aggregates, reference counts for collections, FREE for the impatient and the cyclic — over one bounded, reportable arena. Predictable timing was the whole point.


Storage — SAVE, CSAVE, LOAD

Two on-disk forms, and a LOAD that doesn't make you care which you have:

Verb Writes Form
SAVE path detokenised text human-readable .bas source — editable, diffable, portable
CSAVE path the tokenised image the program store, saved verbatim — compact and fast (a nod to cassette-era CSAVE)
CSAVE path.bax a protected tokenised image the .bax extension is the request — tokens minus the name table and REM text; runs identically, but cannot be LISTed (see below)
CSAVE path, PROTECT the same, extension forced writes protected and names the file .bax, so the name always matches the content
LOAD path — auto-detects: a tokenised image loads verbatim; text is tokenised on the way in

Because DBFS is structured, LOAD's detection is a catalogue lookup, not a guess — a tokenised CSAVE is tagged as such at write time, so LOAD reads the catalogue kind/tag first and only falls back to a magic-byte sniff for files that arrived from elsewhere. Both forms round-trip through fs/io, both are auto-catalogued on D:, and both LIST identically once loaded. Text is the default SAVE for a reason — see versioning next.

Protected (executable-only) images

A protected save writes a stripped tokenised image — the program minus everything that exists only for editing. It is the modern form of the old commercial “crunched / protected BASIC”: the program runs identically, but sheds what it needs only to be read and changed — so it can be RUN but not LISTed.

Consistent with the rest of storage, the extension is the source of truth: protected images are .bax (“BASIC executable”), and saving to that extension is the request. Two spellings reach it, and they cannot disagree:

  • CSAVE "game.bax" — the extension drives it; no keyword needed.
  • CSAVE "game", PROTECT — the keyword forces protected and the file is named .bax automatically.
  • CSAVE "game.bas", PROTECT — a conflict (protected content, un-protected name); PROTECT wins, the file is written .bax with a notice, rather than creating a mislabelled file.

So a file is .bax if and only if it is protected — the name can never lie about the content.

Stripped: the variable-name table (at runtime the engine binds variables by slot, so the human-readable names are needed only to LIST and to resolve fresh references while editing), all REM text, and trailing padding.

Kept — and this is the correctness catch: the token stream, and every line number. A GOTO / GOSUB / THEN / RESTORE may target a REM line, so a stripped REM is not deleted — it becomes an empty placeholder line at the same number, a no-op that still exists as a jump target. Dropping it outright would give ?LINE NOT FOUND at run time.

The result runs the same and is smaller, but is one-way: with no name table there is nothing meaningful to detokenise back to, so LIST declines — Protected program, cannot list — rather than emit garbage. All you can do is RUN it. The names and comments cannot be recovered, so it is a distribution artifact — always keep the un-stripped SAVE / CSAVE source.

It is still a tokenised image, not a crunched text listing — the two are different things: crunch writes minimal source, this writes stripped tokens. So it stays in the CSAVE family and behaves like one:

  • Detection is the missing name table. A tokenised image with no variable-name table is a protected file; LOAD recognises it as such (and DBFS tags it), loads it, and runs it.
  • Protected-ness is preserved across load → save. Load a protected file and CSAVE it again and it stays protected — there are no names to write back, so it round-trips stripped.
  • It rides the same token-version upgrade path. A protected image still carries its token-table version, so loading an old one re-tokenises to the current numbers via the retained old table, exactly like any tokenised image; a subsequent CSAVE writes the current version — still protected. Distribution builds self-heal too.

It is not encryption. Stripping names and comments obscures intent and resists casual editing, but the logic is fully present and re-derivable — protected / executable-only, not secure. Debugging a stripped program is deliberately hard (no names, no comments), which is the point for distribution and the reason to keep your source.

Saving a program that includes

A .bax is a distribution artifact, so it has to stand alone — which makes the answer to "what happens to my includes" depend on how each module was named:

  • Fixed-path includes are linked in. Every include "file.bas" the pre-pass resolved is bundled into the image — its tokenised code and line table written in under its namespace — so the saved .bax carries its modules and runs on a machine that has never seen the module files. That is the whole point of an executable: one file, no loose dependencies.
  • A protected .bax strips the modules too, and that is fine. The pre-pass already resolved every maths.FNsin to a slot under its namespace, so the module names are needed no more than the main program's are — a protected save strips names across the whole bundle, main and modules alike, and the resolved calls still land. The namespaces survive as the resolution that already happened, not as text in the file.
  • A computed-path include cannot be linked in. plugin = include name$ picks its file at run time, so there is nothing to bundle: it stays a runtime dependency — the module file must be present when the program reaches that line, or it faults there. That is the deliberate trade of a plugin, and the reason a self-contained executable sticks to fixed-path includes.

A text SAVE does the opposite on purpose: it writes only your program's own source, include lines and all, and leaves the modules as their own editable files. That is the form you keep and edit; the bundled .bax is the form you ship.


Libraries

Libraries and modules — include. A program grows past one file the moment you have a routine worth reusing. AntBASIC pulls another BASIC program in with include, and the important choice is whether you give it a name:

10 maths = include "maths.bas"     : REM a namespaced module
20 include "utils.bas"             : REM a flat import
30 PRINT maths.PI                  : REM reach in with the name
40 y = maths.FNsin(x)
50 PROCsort(list$())               : REM utils' names, unqualified

A named include is a module in its own namespace. maths = include "maths.bas" loads the file and hangs everything it defines — variables, PROCs, FNs, CONSTs, even its labels — under the name maths, reached with the same . that indexes a structure: maths.PI, maths.FNsin(x), maths.PROCplot(x, y). Nothing it defines can collide with your program or another module, because every name lives behind its own prefix — so the "mind your names" discipline a flat library needs simply isn't a thing. Two includes of the same file under different names are two independent instances, each with its own variables:

10 a = include "counter.bas"
20 b = include "counter.bas"       : REM a and b count separately

The module's own code runs where you include it. An include is not only a declaration — reaching it runs the file's top-level lines, in place, as the module's initialisation: its DEF lines register the procedures and are stepped over as always, and anything else sets the module up. Put the includes at the top and modules initialise at start-up; include one deeper in and it initialises there. Inside the file, references to its own names are qualified for you, so it is self-contained and never has to know what it was named — and its labels and internal GOTOs stay internal, since you reach a module by call, never by jumping across the boundary.

The . reaches a module or a struct, never ambiguously. A name is one or the other, so blah.count is a namespaced variable when blah is a module and a field when blah is a structure — decided by what blah is, with no new operator to learn.

The flat form trades the prefix for the old risk. include "utils.bas" with no name drops the file's names into your program unqualified — convenient for a utility library you lean on — but they now share your program's space, so a clash is a pre-pass error and a prefix (PROCstr_…) is back to being your job. Prefer a named module whenever there's any doubt.

Resolved by the RUN pre-pass when the path is fixed. A literal-path include is read before the program runs: the pre-pass loads the file (tokenising a .bas, or taking a protected .bax as-is), registers its (qualified) names so maths.FNsin resolves before line 10, dedups a name used twice, and refuses a circular include rather than looping. A computed path — plugin = include name$ — can't be known that early, so it loads when the statement runs: the dynamic form, for plugins chosen at run time.

Ship source or sealed, and bundle on build. A module can be an ordinary .bas or a protected .bax — you include either the same way and call through the namespace identically, so it ships without its source — and when you save it as a .bax executable the fixed-path modules are bundled in, so the artefact carries its dependencies.

Under the hood this is a basic.include(path, name?) call the pre-pass (or the running program) makes — the same engine primitive the tooling uses, nothing interpreter-only.


Building native executables

AntBASIC doubles as a cross-development tool: assembly areas can be written out as standalone executables for any of the three processors, or as raw binaries. The save is an in-block SAVE "file" directive — not a console verb — so, because assembly accumulates per area, one source listing can emit many artifacts, each area serialising itself where it sits. The output format follows the extension:

Extension Output Runs on Built from
.arm / ELF a Linux ELF (AArch64) DeMon's CM5, under Linux an [ ] ARM64 area
.ee an EE executable (header + image) the FireStorm EE soft core (RV64) a { } EE area
.ezx an eZX executable (header + image) the eZX soft core (RV32) a { } eZX area
.bin a raw image, no header anywhere the bytes are wanted — cartridge ROMs, chipset upload any area

There is no BASIC interpreter in an executable output — the assembled code is the artifact; a .ee/.ezx/.arm build of a program that mixes in BASIC statements is an error (use a BASIC program to drive assembly — loops that lay down tables, computed addresses — not as runtime in a built executable). A whole-program build script also exists, over the basic.build primitive, for the common one-area case; the in-block SAVE directive is what unlocks many-outputs-per-source.

Addresses are absolute, so — for now — no relocation is needed:

  • .ee / .ezx carry a small AntOS header (magic, target core, narrow/wide, absolute load address, entry point, length) followed by the image. The AntOS loader places the image at its absolute FPGA memory location and starts the core at the entry point. Because the target's addresses are absolute, there is no relocation table.
  • ARM emits a Linux ELF. The simplest form is a fixed-load-address ET_EXEC — mapped at its link address, needing no runtime relocation either, which matches the absolute-address model. (Position-independent output with a relocation table is a possible later option; not needed now.)

These are build outputs, not programs — LOAD does not read them back, so keep the SAVE/CSAVE source if you want to edit and rebuild.


Token-table versioning and self-healing upgrades

The token table maps keywords to token bytes — and, per Abbreviations, its order also fixes which keyword each p.-style prefix resolves to. The plan is not to change it unless absolutely necessary, and there is one firm law: new keywords append at the end (or into reserved gaps) — never reshuffle the existing order. Reordering would silently change what an abbreviation means in source text (p. could flip from PRINT to POKE), which no version stamp can catch, because abbreviations live in .bas text, not in the tokenised image. Append-only keeps every existing abbreviation stable forever.

Additions cost nothing while there is room. The ID space is partitioned into per-type ranges — statements, functions, operators, and so on — each sized with generous headroom, so a new keyword or built-in takes the next spare ID in its own range. That is still an append (existing IDs, and the abbreviation order they fix, never move), so nothing changes for any existing image or abbreviation and no version bump is needed. A bump becomes necessary only when a range is genuinely exhausted — or the layout must otherwise change — which, with the ranges sized sensibly, should be rare. Until then, new keywords simply fill the spare slots — which doesn't bump the token-table version at all. Only an exhausted range or a layout change does that (rare); forward-compatibility for everything in between is carried by each program's own header (below), so there is no running minor-version counter to keep.

When a bump is unavoidable, tokenised images still never break, because AntBASIC keeps old tables loadable and quietly migrates them:

  1. Every tokenised image is stamped with the token-table version that wrote it, and its header records the largest token ID it uses in each type.
  2. Old token tables are retained in the system, keyed by version, so any old image still detokenises correctly.
  3. LOAD reads the image's stamped version, detokenises against that table, and brings the program into the current in-memory form.
  4. SAVE/CSAVE always write the current version — so loading an old program and saving it silently upgrades it. Migration by use; no tool required.
  5. DBFS catalogues the token-table version as metadata on every tokenised save.

Point 5 is the modern payoff the classic machines never had: because the version is an indexed catalogue column, finding stale images is a fs.query, not a file crawl —

-- find every out-of-date tokenised BASIC image on the machine (instant, indexed)
local stale = fs.query{ kind = "basic-image", token_version = { lt = basic.token_version() } }

— and a batch upgrade is a short script that walks the hits, basic.loads and re-csaves each. So basupgrade.lua is, once again, just a script over the library. Text SAVE sidesteps versioning entirely, which is why it is the default.

Loading a newer program on an older machine

The retained-table migration above handles an old image on a newer machine. The reverse — a program CSAVEd on a newer AntOS, carried to one that is slightly behind — is handled without a version counter at all. Within one layout, an older machine understands every token the newer one does except the keywords added since, which sit in slots its table leaves empty. So rather than scan the whole program on load, the answer is precomputed in the header: a CSAVE records, for each token type, the largest token ID the program actually uses. On load, AntOS compares those against the largest it knows in each type:

  • every per-type maximum is within reach → the program loads and runs normally, however new the machine that saved it;
  • any maximum overshoots → the program uses a token this machine lacks, so AntOS declines with a plain message — this program needs a newer AntOS; please update — instead of loading it to fail mid-run.

The producer computes the maxima once, as it writes the image; the consumer checks just those few numbers — no full-program scan, no minor-version bookkeeping. (A differing token-table version is the harder case: the layout itself differs, so an older machine can't assume any token's meaning and asks for an update outright; a newer machine still loads the older version through its retained tables, as above.)


Workspace and bytes-free

AntBASIC owns a workspace arena holding the tokenised program plus its variables. basic.free() reports the free space in that arena — not system RAM. This keeps the retro meaning (a bounded BASIC world you can fill up and get Out of memory in) while the adaptive GB/MB/KB/bytes units let the arena be generously sized on a machine with real memory. The boot banner shows the whole arena free; it shrinks as you type a program and grows on NEW.


Tooling is Luau scripts

Because the engine is the basic library, the entire classic command set falls out as small scripts in D:/scripts:

-- run.lua
local basic = require("basic")
basic.run()
-- list.lua   (list [start] [end])
local basic = require("basic")
local a, done = os.arguments{ name = "list",
    { "+Start", type = "number" }, { "+End", type = "number" } }
if done then return end
for _, l in basic.lines() do
    if (not a.start or l.line >= a.start) and (not a.end or l.line <= a.end) then
        print(`{l.line} {l.text}`)
    end
end

renumber, delete, auto, save, csave, load, upgrade, even a trace or xref, are all just-another-script — add or replace them without touching the engine. That is the whole idea: BASIC is a service; the BASIC experience is Luau on top of it.


Editor settings

AntBASIC's editor behaviour — the parts the grammar and the entry check drive — is tuned by a small group of AntOS settings, so a classroom, a purist, and a modern-feel user each get the console they want from one engine:

Setting Values Default Controls
Pre-flight strict · warn · off warn the RUN syntax pre-flight — refuse, prompt, or stay quiet
Completion keys key bindings Tab accept / cycle · ^Space menu the prediction & completion triggers, rebindable
Colouring theme (class → colour), or off on, default theme syntax colouring — the class→colour map; off cleanly on a mono console

They share a spirit: at its most conservative end each one degrades to the classic experience — off pre-flight is 1980s "find out at RUN," a mono console is a plain listing, unbound completion keys are just typing. The affordances layer over a console that still works fully without them; none is load-bearing. (strict pre-flight is the one that goes the other way — stricter than classic — for teaching.)

And because every one of these behaviours is a reader of the same grammar, a setting only chooses how loud its reader is; it never re-implements anything. Turning colouring off doesn't change what basic.check accepts, only whether spans are painted; off pre-flight doesn't change the stored verdicts, only whether they're announced.


Where to go next

Important: The Ant64 family of home computers are at early design/prototype stage, everything you see here is subject to change.