Interpreter Semantics - CRT Basic

Table of Contents

Back to main documentation

1. Execution engine

The Interpreter class executes the AST directly, tree-walking. It is the interim engine: the target architecture compiles the AST to a compact stack-based bytecode executed by a small VM, which is cheaper in tight pixel loops and gives a natural "execute N instructions" granularity. The tree-walker exists so that whole programs run — and can be verified — today. The lexer, parser and AST carry over unchanged; only this package gets replaced.

Because BASIC semantics are subtle and real programs depend on the subtleties, they are pinned down here explicitly.

2. The numeric model

literal / variable 0.1, x%, total# expression evaluation always double precision store into variable / array narrow to the slot's type float32 unless # / D-exp no precision loss mid-expression % int16, & int32 (half-even) ! float32, # double y = 0.1 + 0.2 → full double math, then ONE narrowing when assigned to y
  • Expression evaluation always runs in double precision. Precision is only lost when a value is stored: into a variable, an array element, or a numeric literal narrower than DOUBLE.
  • Storage narrows to the slot's type. SINGLE slots (the default) narrow to float32. INTEGER (= %=) and LONG (= &=) slots round half-to-even and raise Overflow when out of range. DOUBLE (= #=) slots keep everything.
  • Numeric literals narrow to float32 unless written with a # suffix or a D exponent, which mark them DOUBLE.
  • Graphics coordinates and colors convert fractional values by rounding half-to-even — not truncation.

In practice: a value computed as 0.1 + 0.2 keeps full double precision through the addition and narrows exactly once, on assignment.

3. Variable identity

Variable names are case-insensitive but suffix-sensitive:

  • x and x! are the same variable; x% is a different one.
  • A suffix-less name resolves its suffix from DEFINT and siblings (module-wide, by first letter), or from a DIM name AS type declaration, defaulting to SINGLE.
  • Unassigned numeric variables read as 0, strings as "".
  • True is -1, false is 0; AND / OR / NOT are bitwise.

4. Procedures and scoping

  • SUBs and FUNCTIONs register at load time and never execute inline; main code is everything else.
  • Plain-variable arguments pass by reference — the parameter shares the caller's storage cell, so writes through the parameter are visible to the caller. Expression arguments pass by value.
  • SUBs do not see the caller's other variables. DIM SHARED names resolve against the global scope from inside any SUB.
  • When a SUB/FUNCTION header uses bare parameter names, the matching DECLARE statement's suffixes decide the parameter types.
  • A FUNCTION returns whatever was last assigned to its own name inside the body (0 / "" when never assigned).
  • Arrays not yet DIM=med auto-dimension to =0 TO 10 per dimension on first indexed use; ERASE returns a slot to its never-DIMmed state.

5. Labels, GOTO and error handling

Each statement block executes under a program counter. A GOTO throws a control-flow signal carrying the label; the enclosing block catches it when it contains the label, and rethrows otherwise — which gives labels procedure-wide scope, exactly as the language defines, without any pre-pass.

ON ERROR GOTO registers a module-global handler label. A trapped runtime error transfers control to the handler, remembering the program counter of the failing statement. RESUME retries that statement, RESUME NEXT continues after it, RESUME label jumps. ON ERROR GOTO 0 disables trapping. (For an error raised inside a SUB, the current approximation retries the CALL statement.)

DATA constants are module-wide: they are collected in program order before execution begins, and READ walks that list. Running out raises Out of DATA.

6. RND is bit-exact

The random generator reproduces the reference algorithm exactly: a 24-bit linear congruential generator

seed = (seed * &HFD43FD + &HC39EC3) mod 2^24
RND  = seed / 2^24   (as SINGLE)
  • Without RANDOMIZE, the generator starts from the same fixed seed as the reference, so programs get the identical deterministic sequence.
  • RND(0) repeats the last value; RND(-n) reseeds first; RANDOMIZE n reseeds like RND(-n); bare RANDOMIZE seeds from the clock (TIMER).
  • Reseeding folds the SINGLE bit pattern of |n| into the 24-bit seed as (bits & &HFFFFFF) + (bits >>> 24) + &H80.

Programs that shuffle, spawn or wander "randomly" therefore produce the same playthrough every time — which is what makes scripted play-testing reproducible.

Numbers print at the expression's static type: SINGLE formatting (~7 significant digits, float32-narrowed) by default, 16 significant digits for DOUBLE expressions — with D±xx scientific notation outside the fixed range. Non-negative values get a leading space; every number gets a trailing space.

Known gap: some classic implementations of this dialect misprint a few DOUBLE values by 1–2 ulp in a bit-pattern-dependent way (STR$(592#) prints "591.9999999999999" there, while 321# prints exactly). Every constant-error hypothesis failed on same-decade value pairs, so this interpreter prints the mathematically correct 16 digits instead.

8. Statement budget and introspection

  • setMaxStatements(n) caps execution; the StepsLimitException fires when the budget is spent. This is how --steps=N samples endless game loops deterministically.
  • statementsExecuted() reports how far a run got.
  • variable(name) reads a variable from the global scope — agents assert on program state directly.
  • video() exposes the whole virtual VGA: raw page bytes, PNG dumps, cursor positions, palette entries.
  • Setting the system property crtbasic.linelog to a file path makes every LINE call append x1,y1,x2,y2,color after coordinate conversion — a debug aid for tracing draw calls.

9. File I/O model

OPEN path FOR INPUT AS #n opens a text file (read as CP437; relative paths resolve against the .bas file's directory). Each channel keeps a one-line lookahead so EOF(n) can answer without consuming input, plus a buffer of comma-split items so several INPUT # statements can share one line and one INPUT # can span lines. Numeric targets parse like VAL (empty or unparseable is 0); string targets keep raw text with surrounding quotes stripped. LINE INPUT # reads whole lines; CLOSE with no channel closes everything; closing an unopened channel is silently ignored.

Output modes (OPEN FOR OUTPUT, PRINT #) are not yet implemented.

10. Input consumption

The interpreter maps queued keys to classic byte sequences at consumption time: printable keys yield their character (Enter as CHR$(13)); extended keys yield CHR$(0) + scancode. INKEY$ returns a whole sequence in one call; INPUT$(n) doles bytes out across reads, holding the tail of a half-consumed extended key. Interactive mode blocks waiting for keys in INPUT$; headless mode never blocks.

Created: 2026-08-31 ma 01:26

Validate