Parsers and evaluators in BBC BASIC II: who reads text, and how the language reaches them
Scope. This article maps the BBC BASIC II text-reading machinery — the tokeniser, the statement dispatcher, the expression evaluator and its factor-level readers, the lvalue parser, and the two string→value front doors
VALandEVAL. Every routine address is specific to this version and was read from the disassembly (which reassembles byte-identically to the ROM). It is a road-map: the deep dives on tokenising, control-flow stacks, floating point and indirection lvalues cover the individual pieces.
A recurring source of confusion about BBC BASIC — why does VAL("&FF") give 0 but EVAL("&FF") give 255? why is TOP not TO+P here but is there? why can an indirection be a procedure parameter? — dissolves once you see that BASIC II is not one parser. It is several distinct text-reading machines, each reached from the language in different places. Knowing which machine runs where is the whole game.
The machines at a glance
| Layer | Routine(s) | Reads → produces | Reached from BASIC by |
|---|---|---|---|
| Tokeniser (crunch) | tokenise_line (&8951) |
source text → token bytes | every line as typed/edited; EVAL re-runs it on a string |
| Statement dispatch | next_statement (&8BA3) / dispatch_token (&8BB1) |
a token → a handler | RUN, immediate mode, each :-separated statement |
| Expression evaluator | eval_expr (&9B1D) → the precedence ladder |
token stream → a typed value | anywhere an expression is wanted (PRINT, =, IF, arguments…) |
| Factor readers (level 1) | eval_factor (&ADEC) and its sub-readers |
one atom → a value | reached only through the evaluator |
| Lvalue parser | parse_lvalue (&9582) / parse_var_ref (&95C9) |
text → an assignable location + type | LET, FOR, LOCAL, PROC/FN parameters |
| String→value front doors | fn_val (&AC2F) (VAL), fn_eval (&ABE9) (EVAL) |
a string → a value | the VAL and EVAL functions |
The rest of this article walks the three layers that most often surprise people: the evaluator ladder, the factor-level readers (where the number/hex/string/variable splits live), and the two front doors.
The expression evaluator: a precedence ladder
eval_expr (&9B1D) is the single entry point. It copies the program pointer PtrA into the working pointer PtrB and enters the lowest-precedence level. Each level evaluates a higher-precedence operand, then loops consuming its own operators — a classic recursive-descent precedence climb:
| Level | Routine | Operators |
|---|---|---|
| 7 (lowest) | eval_or_eor (&9B29) |
OR, EOR |
| 6 | eval_and (&9B72) |
AND |
| 5 | eval_relational (&9B9C) |
< <= = >= > <> |
| 4 | eval_add_sub (&9C42) |
+ - (and string concatenation) |
| 3 | eval_mul_div (&9DD1) |
* / DIV MOD |
| 1 | eval_factor (&ADEC) |
the atom |
eval_factor (&ADEC) is "level 1" — the highest-precedence rung and the one that actually reads things. It handles unary -, + and NOT; a parenthesised sub-expression; the ?, !, $ and | indirection operators; string literals; and the built-in functions. Unlike the higher levels it does not read the trailing operator — its caller does. Everything below is a sub-reader dispatched from here.
The factor readers — where the splits live
This is the layer that explains the surface quirks. eval_factor looks at the first character of the atom and dispatches:
- Decimal / floating-point numbers →
parse_number(&A07B). Reads digits, a.and anEexponent, choosing integer or real (a point or exponent forces real). It is decimal only — there is no&branch in it, and a non-digit first character yields 0. - Hex constants →
factor_hex(&AE6D), dispatched on a leading&. Folds0-9and uppercaseA-Finto a 32-bit cell as a bit pattern, with no overflow check (a 9th digit drops the top nibble;&FFFFFFFF= −1) and the sole error Bad HEX when no hex digit follows. Lowercasea-fends the scan. - String literals (
"...") →read_string_literal(&ADAD). - Variables, array elements and indirection as values → the scanner shared with the lvalue path (
parse_var_ref(&95C9)). - Built-in functions → the per-token
fn_*handlers, reached through the action-address table (action_table_lo(&836D)) by token value. This is howSIN,LEN,VAL,EVAL, and theTO-token'sfn_to(&AEDC) (which readsTOP) are dispatched.
The decisive fact: the decimal reader and the hex reader are different routines, and & is reachable only through eval_factor. parse_number never sees a &. That single split is the root of the VAL/EVAL difference below.
The lvalue parser: one routine, four exposures
Assignment targets are read by a separate machine, parse_lvalue (&9582) (via parse_var_ref (&95C9)). It returns a storage address plus a type byte, and — crucially — it is shared verbatim by four language constructs: the left of LET, the FOR control variable, each LOCAL, and each PROC/FN formal parameter. Because they all call the same routine they accept the same grammar, which is why a ?/!/$ indirection or an array element is a legal FOR variable, LOCAL, and formal parameter — see indirection lvalues as parameters.
Two string→value front doors: VAL and EVAL
Both turn a string into a value, but through completely different machines:
VAL(fn_val(&AC2F)) runs the decimal reader on the raw string:ascii_to_number(&AC34) handles an optional leading sign, then defers toparse_number(&A07B). No tokenising, no operators, no&. It reads a number from the start of the string and stops at the first non-numeric character.EVAL(fn_eval(&ABE9)) tokenises the string and runs the full evaluator on it: it copies the argument to the stack, appends aCR, crunches it in place (in mid-statement state, soPTR/TIMEetc. tokenise as functions), and callseval_expr(&9B1D). So it accepts everything an expression can contain —&hex,Eexponents, operators, parentheses, functions and in-scope variables.
That is the entire reason for the classic contrast:
| Input | VAL(x$) |
EVAL(x$) |
Why |
|---|---|---|---|
"1E3" |
1000 | 1000 | both reach parse_number, which reads the E exponent |
"&FF" |
0 | 255 | VAL's parse_number has no & branch; EVAL tokenises and reaches factor_hex |
"12AB" |
12 | (depends) | VAL stops at the first non-digit; EVAL evaluates 12 then sees AB as a variable/operator context |
"2+3" |
2 | 5 | VAL reads one number; EVAL evaluates the whole expression |
VAL is "read a literal"; EVAL is "be the interpreter". A compiler matching the ROM must model them as two readers, not one with a flag.
Surface consequences, and where they're documented
Most of BASIC II's "vexing parse" folklore is just the machine boundaries showing through:
VALvsEVALon hex — the two-readers split above.TOPis not a token — the crunch emitsTO+'P'; the pseudo-variable is resolved at factor time byfn_to(&AEDC), only when theTOtoken is in operand position immediately followed by a bareP. See tokeniser §1.7.- Indirection as a parameter —
parse_lvaluebeing shared by thePROC/FNbinder. See indirection lvalues. &is uppercase-only and wraps at 32 bits — a property offactor_hex, not of "numbers" in general.
Appendix: routine cross-reference
| Concern | Routine |
|---|---|
| Tokenise a line | tokenise_line (&8951) &8951 |
| Dispatch a statement token | next_statement (&8BA3) &8BA3, dispatch_token (&8BB1) &8BB1 |
| Evaluate an expression | eval_expr (&9B1D) &9B1D |
| Precedence levels 7→3 | eval_or_eor (&9B29), eval_and (&9B72), eval_relational (&9B9C), eval_add_sub (&9C42), eval_mul_div (&9DD1) |
| Evaluate a factor (level 1) | eval_factor (&ADEC) &ADEC |
| Decimal/float literal reader | parse_number (&A07B) &A07B |
Hex (&) literal reader |
factor_hex (&AE6D) &AE6D |
| String literal reader | read_string_literal (&ADAD) &ADAD |
| Variable / indirection scan | parse_var_ref (&95C9) &95C9 |
| Assignment-target (lvalue) parse | parse_lvalue (&9582) &9582 |
VAL |
fn_val (&AC2F) &AC2F → ascii_to_number (&AC34) &AC34 |
EVAL |
fn_eval (&ABE9) &ABE9 |
