SolaBasic — a compiled BASIC for SolVM
The language definition. Written before the compiler, and frozen when it starts, because there is no standard for this dialect and somebody has to hold the line that a standard would have held.
SolaBasic is BASIC in the shape QuickBASIC gave it — labels rather than line
numbers, SUB and FUNCTION, block IF and SELECT CASE — compiled to a
.sob file and run by bin/solvm. It is not QBasic and does not try to be.
It is a subset, and this document is the whole of it.
For writing SolaBasic rather than reading about it, see the reference manual, which describes what the compiler accepts today — statement by statement, with what it says when it refuses — or the cheatsheet, which is the same ground on one page for when you know what you want and not what it is called. This page is the definition and the reasoning; that one is the desk copy.
The name
Sola is Norwegian for the sun — the definite form of sol, which is how it comes to stand beside Solveig. In Latin sōla is the feminine of sōlus, the same root as the sōlum the README takes the whole design principle from. Basic is on the end because the dialect is BASIC, and because it belongs to this project rather than to Microsoft.
The obvious name was taken, and taken from close by. S-BASIC — Structured Basic, Topaz Programming, 1981, distributed with Kaypro’s CP/M machines — had optional line numbers, labels that were not numbers, and a two-pass compiler. It is CB80’s contemporary and competitor, which is to say it sits inside the same paragraph this language borrows its boundary from. SmallBASIC is in use as well. So the short name is spoken for twice over, and this is the longer one.
Why this document exists at all
basic.sol could point at ECMA-55 and let a published standard decide when it was finished. SolaBasic has no such standard, and the reason is worth writing down, because it is not for want of looking.
There is exactly one standardised structured BASIC: Full BASIC, ratified as
ECMA-116 (1986), ANSI X3.113-1987 and ISO/IEC 10279:1991. It has SUB,
FUNCTION, SELECT CASE, DO...LOOP, EXIT, and structured exception
handling, and ECMA publishes it for nothing exactly as it publishes ECMA-55.
Three things rule it out:
| Line numbers are still mandatory | Full BASIC adds structure without removing the line editor it was designed around. It fails the first requirement. |
| 176 keywords, 161 concepts | plus 38 mathematical and 14 string functions, plus five optional modules, one of them a graphics system. Against Minimal BASIC’s twenty statements, that is a different category of job rather than a larger one. |
| Nobody appears to have built a conforming implementation | and there is no Full BASIC counterpart to the NBS test programs. A standard nobody met and nobody tests is not an external authority; it is a long document. |
So the choice was a vendor dialect, and the trouble with a vendor dialect is that the subset boundary is drawn by whoever is writing the compiler, on the day they are writing it, which is the failure ECMA-55 protected the interpreter from.
The line is therefore borrowed rather than invented. It is CB80’s.
Where the line is drawn
CB80 — the CBASIC Compiler, Digital Research, 1982 — is the closest thing
this project has to an ancestor. CBASIC compiled to an intermediate .INT file
executed by a separate runtime called CRUN, which is this design in 1977. CB80
added alphanumeric labels to it, along with nested IF, variable type
declarations, CALL with parameters, and multiple-line functions with local
variables.
That list is a boundary drawn by people building a compiled, structured, label-based BASIC with no machine underneath it — no screen, no memory map, no interrupt table. It is the boundary SolaBasic uses:
Everything QBasic has that CB80 also had is language. Everything QBasic adds beyond it is either the PC or convenience, and neither is here.
Where SolaBasic departs from that rule it says so, in Where this is not QBasic and What is not here.
Why compile it at all
Because GOTO is not expressible in Solum.
Solum has no control-flow syntax; a loop is a message send. A transpiler would have to compile every statement into a block, keep them in an array, and dispatch on a label variable — a full send per BASIC statement, which is what basic.sol already pays as a tree-walker. It would be a slower interpreter wearing a compiler’s name.
Emitting bytecode, GOTO is OP_JUMP and OP_LOOP. This is the rare case
where dropping a level buys a construct rather than a constant factor, and it is
the reason this program exists.
The verifier cooperates. Its rule is the JVM’s — the paths into a point must
agree on stack height (design.md, verify_stack_heights).
SolaBasic statements compile at depth 0 with a POP at each boundary, so every
label is a depth-0 merge point by construction and an arbitrary jump between
statements verifies without any analysis at all.
Program structure
A SolaBasic program is a sequence of lines. A line holds one statement, or
several separated by :, or nothing.
There are no line numbers. A line may carry a label:
Again: PRINT "round and round"
GOTO Again
A label is an identifier followed by :, at the start of a line. A number is
also a label, and this is CB80’s rule taken verbatim: the compiler treats a
label as a string of characters rather than a numeric quantity, so labels need
not be ordered, need not be present, need not be unique in any numeric sense,
and mean nothing except as the target of a jump. 100 and 100.0 are two
different labels. Old listings therefore pass through unaltered, and nothing in
the compiler ever sorts them.
QuickBASIC agrees, which was not known when this was written and is now measured: 4.5 compiles a listing whose numeric labels descend, and prints what SolaBasic prints. So the rule borrowed from CB80 is not a divergence from QBasic, and the list below needs no entry for it.
A program is one file. There is no CHAIN, no COMMON, and no linker.
Module level and procedure level
Statements outside any SUB or FUNCTION are module level and run in
order, top to bottom, from the first such statement. SUB and FUNCTION
definitions are not executed where they stand.
GOTO may not cross between module level and a procedure, or between two
procedures. Each is a separate chunk and a jump is an offset within one.
Lexical structure
| Case | Keywords and identifiers are case-insensitive. PRINT, Print and print are one word; Total and TOTAL are one variable. |
| Identifiers | A letter, then letters, digits and ., up to 40 characters, optionally ending in a type suffix. |
| Comments | REM to end of line, or ' to end of line. ' may follow a statement; REM may follow one after a :. |
| Continuation | None. A statement ends at the end of its line. |
| Whitespace | Required between a keyword and what follows it. See Where this is not QBasic. |
| Statement separator | : joins statements on one line. |
Literals
| Integer | 42, -7, &HFF (hex), &O17 (octal) |
| Double | 3.14, 1.5E-3, 2D6 |
| String | "between double quotes". There is no escape; a string may not contain ". Use CHR$(34). |
Types
Three, and no more.
| Type | Suffix | AS name |
Backed by |
|---|---|---|---|
| Integer | % |
INTEGER, LONG |
SolVM’s tagged 64-bit integer |
| Double | # |
DOUBLE |
SolVM’s f64 |
| String | $ |
STRING |
SolString, dynamic length |
LONG and & are accepted as synonyms for INTEGER. SINGLE is not in the
language and ! is not a suffix — see the divergences.
A name’s type is fixed by, in order: its suffix; an AS clause on its DIM;
the DEFtype range covering its first letter; otherwise DOUBLE.
DEFINT A-N
DIM Count AS INTEGER
Total# = 0
Name$ = "Solveig"
Conversion and arithmetic
/always answers a Double, whatever its operands.\is integer division andMODis integer remainder; both convert their operands to Integer first.- Assigning a Double to an Integer rounds to nearest, halves away from zero.
- An Integer operation that leaves the 64-bit range is an error, not a
wraparound. SolVM traps, and SolaBasic reports it as
overflow. +on two Strings concatenates. Any other mixing of String and number is a compile error — SolaBasic never converts between them silently.VALandSTR$are how you cross.
Declarations and scope
DIM name AS type |
declares a scalar |
DIM name(bounds) AS type |
declares an array |
DIM SHARED ... |
at module level, makes the name visible inside every procedure |
CONST name = expression |
a named constant, folded at compile time |
SHARED name |
inside a procedure, names a module-level variable to use |
STATIC name |
inside a procedure, a local that survives between calls |
DEFINT/DEFLNG/DEFDBL/DEFSTR letter-letter |
sets the default type for a range of initial letters |
OPTION BASE 0 | 1 |
the default lower bound for arrays. Once, before any DIM. |
A variable not declared anywhere springs into being on first use, with the type
its suffix or the DEFtype ranges give it, and the value 0 or "".
A procedure’s variables are local to it unless named by SHARED. Module
level and procedure level do not otherwise see each other.
Arrays
DIM Grid(1 TO 8, 1 TO 8) AS INTEGER
DIM Names$(100)
Bounds are constant expressions. DIM a(10) runs from OPTION BASE to 10
inclusive. Up to eight dimensions. Arrays are not resizable; there is no
REDIM.
Expressions
Highest binding first. Every level is left-associative except ^, which is
right-associative.
^ |
exponentiation, always Double |
- |
unary minus |
* / |
multiply, divide |
\ |
integer division |
MOD |
integer remainder |
+ - |
add and subtract; + also concatenates Strings |
= <> < > <= >= |
comparison, answering -1 for true and 0 for false |
NOT |
bitwise complement |
AND |
bitwise and |
OR XOR |
bitwise or, exclusive or |
There is no boolean type. A condition is true when it is non-zero, which is why
the comparisons answer -1: NOT (a = b) then works out.
EQV and IMP are not here.
Statements
Assignment
LET x = 1
x = 1
SWAP a, b
LET is optional and means nothing.
Conditionals
IF x > 0 THEN PRINT "positive"
IF x > 0 THEN PRINT "positive" ELSE PRINT "not"
IF x > 0 THEN
PRINT "positive"
ELSEIF x = 0 THEN
PRINT "zero"
ELSE
PRINT "negative"
END IF
not
zero
SELECT CASE grade$
CASE "A", "B"
PRINT "pass"
CASE IS >= "C"
PRINT "marginal"
CASE ELSE
PRINT "fail"
END SELECT
fail
CASE takes a list of values, ranges written low TO high, or IS followed by
a comparison operator.
Loops
FOR i = 1 TO 10 STEP 2
PRINT i
NEXT i
DO WHILE more
...
LOOP
DO
...
LOOP UNTIL done
WHILE more
...
WEND
NEXT may name its variable or not, and may close several at once
(NEXT j, i). DO/LOOP takes WHILE or UNTIL at either end, or neither,
in which case only EXIT DO leaves it.
EXIT FOR and EXIT DO leave the innermost enclosing loop of that kind.
Jumps
GOTO Cleanup
Within one procedure, or within module level. That is the whole of it: there is
no GOSUB, no ON n GOTO, and no RETURN. See
What is not here.
Procedures
SUB Greet (name$)
PRINT "Hello, "; name$
END SUB
FUNCTION Area# (r#)
Area# = 3.14159265358979 * r# ^ 2
END FUNCTION
CALL Greet("world")
Greet "world"
PRINT Area#(2)
Hello, world
Hello, world
12.56637061435916
A FUNCTION answers by assigning to its own name. EXIT SUB and
EXIT FUNCTION leave early.
Parameters are passed by reference, as in QBasic: assigning to a parameter
assigns to the caller’s variable. Wrapping an argument in parentheses passes it
by value instead — CALL Greet((name$)) — which is QBasic’s own idiom for it.
An array is passed by writing a() at the call site and a() in the parameter
list. Arrays are always by reference.
SUB and FUNCTION may recurse, subject to the frame limit — see
What this costs.
DECLARE is accepted and ignored. SolaBasic resolves every procedure in a pass
over the whole file before it compiles anything, so nothing needs declaring
ahead of its use.
Input and output
PRINT "answer:", x; y
PRINT TAB(20); "indented"
PRINT USING "###.##"; total
INPUT "Name"; name$
LINE INPUT line$
PRINT separates with , to the next 14-column zone and with ; not at all. A
trailing ; or , suppresses the newline. TAB(n) and SPC(n) are allowed in
the list.
A number prints with a leading space when positive — the place where a minus sign would go — and a trailing space always. This is QBasic’s rule and Minimal BASIC’s before it, and it is why BASIC output looks airy.
PRINT USING supports #, ., ,, +, -, $$, **, ^^^^ for numbers
and &, !, \ \ for strings.
Files
OPEN "data.txt" FOR INPUT AS #1
DO UNTIL EOF(1)
LINE INPUT #1, line$
LOOP
CLOSE #1
Sequential only: FOR INPUT, FOR OUTPUT, FOR APPEND. PRINT #, INPUT #,
LINE INPUT #, WRITE #, EOF, CLOSE. There is no random access, no
FIELD, no GET/PUT.
Ending
END stops the program. STOP does the same and says where.
Built-in functions
Mathematical. ABS ATN COS EXP FIX INT LOG RND SGN SIN
SQR TAN, and RANDOMIZE as a statement.
String. ASC CHR$ INSTR LCASE$ LEFT$ LEN LTRIM$ MID$
RIGHT$ RTRIM$ SPACE$ STR$ STRING$ UCASE$ VAL.
MID$ is a function only. It is not an assignment target.
Twenty-seven in all, against Minimal BASIC’s eleven and Full BASIC’s fifty-two.
What is not here
Never — the PC
QBasic’s largest surface is not language, it is a machine. None of it is here and none of it is planned:
SCREEN, PSET, LINE, CIRCLE, PAINT, DRAW, PALETTE, GET/PUT for
graphics, PLAY, SOUND, BEEP, PEEK, POKE, DEF SEG, VARPTR,
CALL ABSOLUTE, CALL INTERRUPT, INP, OUT, WAIT, SHELL, ON KEY,
ON TIMER, KEY, LOCATE, COLOR, CLS, INKEY$, CHAIN, COMMON, RUN.
Never — the vestiges
| why | |
|---|---|
GOSUB / RETURN / ON n GOSUB |
SUB is the mechanism. RETURN also needs a computed jump, which SolVM does not have: the return address is dynamic and OP_JUMP takes a literal offset, so each RETURN would compile to a chain of comparisons over a return-id stack. Cutting it saves that entirely. |
ON n GOTO |
the same computed-jump problem, in the form the tree-walker solved with an array. SELECT CASE says it. |
DATA / READ / RESTORE |
a listing carrying its own input is a line-numbered idea. Files are here instead. |
| line numbers | labels are here instead, and a leading number is one. |
DEF FN |
FUNCTION says it. |
SINGLE |
see the divergences. |
Not yet
Named so that adding one is a decision rather than a drift. Each says what would make it worth doing:
| when | |
|---|---|
ON ERROR GOTO / RESUME / ERR |
CB80 had it, so the cut line says it belongs. It waits because Solum already has the unwinding half — onError and ensure — and the design should be settled against those rather than guessed at. The first SolaBasic program that needs to survive a bad file. |
TYPE … END TYPE |
records. The first program wanting more than parallel arrays. |
REDIM and dynamic arrays |
the first program that cannot size an array at compile time. |
OPTION EXPLICIT |
when a misspelled variable has cost somebody an afternoon. |
MID$ as a statement |
when something needs to patch a string in place and LEFT$ + RIGHT$ is the workaround being written twice. |
| Random-access files | the first program with a record format. |
Where this is not QBasic
The list basic.sol keeps, for the same reason: a gap written down is a gap,
and a gap discovered is a bug.
1. There is no SINGLE. QBasic’s default numeric type is a 32-bit float
printed to seven significant digits. SolVM has i64 and f64 and nothing
between, so emulating it means rounding on every store and would still print
differently. SolaBasic’s default numeric type is DOUBLE, ! is not a
suffix, and AS SINGLE is refused rather than silently widened. A ported
program will print more digits than it used to. This is the largest single
divergence and the one most likely to surprise.
2. INTEGER is 64 bits. QBasic’s is 16 and LONG is 32. Measured: a
QuickBASIC 4.5 program compiled with BC.EXE wraps at 32767 rather than
stopping — BC compiles without overflow checking unless /D asks for it,
where the QB.EXE environment would raise Overflow. Either way, SolaBasic has one integer type, and LONG is a synonym for
it. A program relying on 32767 + 1 failing will not fail here — it will be
right, which is worse.
3. Spaces between tokens are required. FORI=1TO10 is not FOR I = 1 TO 10.
This is inherited deliberately from
basic.sol, which explains at length why: a tokeniser
that ignores spaces cannot work left to right on characters alone, and has to
know where it is in the grammar. It is a different scanner, not a missing
branch. Nobody writes it; it is still a gap.
4. A string may not contain a double quote. QBasic has the same restriction,
and CHR$(34) is the same answer. Recorded because it looks like an oversight.
5. A Double prints to the shortest text that reads back as the same number,
where QuickBASIC prints sixteen significant digits. Measured, against
QuickBASIC 4.5: 1# / 3# is .3333333333333334 there and .3333333333333333
here, and 1# / 7# is .1428571428571429 against .14285714285714285. So they
agree whenever the shortest round-trip is sixteen digits or fewer and rounds the
same way, and part company on a seventeenth digit or on the last one.
Exponential form agrees exactly. This is the entry that
said it was not settled; it is.
6. VAL is strict. BASIC’s reads a number off the front of a string and
answers nought for junk; this one wants the whole string to be a number. Reading
a number out of the front of text wants a scanner, and there is no library in
the file the compiler writes to hold one.
7. An array name means one array in the whole listing. QBasic lets two
procedures each DIM a Temp of their own; here the second is dimensioned
twice. And an array parameter is one-dimensional — a bigger one’s strides would
have to travel with it.
8. A file is written with line feeds, where QBasic writes a carriage return and a line feed. Reading takes a carriage return off, so a file written by either is readable here; one written here is not in DOS’s convention.
9. Procedures are resolved before compilation. QBasic requires DECLARE for
a procedure used before it is defined, and QB’s editor writes them for you.
SolaBasic takes a pass first, so DECLARE is accepted and does nothing.
What this costs
Recursion depth is SolVM’s, not BASIC’s. A SUB compiles to a Solum block
and a SolaBasic call is a Solum frame, so a SolaBasic program’s call depth is
bounded by 3.5 — about
254 levels. ideas.md predicted exactly this when it argued for a
Pascal interpreter: “a lexically nested language spends frames in proportion to
the interpreted program’s call depth, so 3.5 would be met head on rather than
dodged.” SolaBasic meets it head on. A line-numbered BASIC never could, which
is what made basic.sol fit.
By-reference parameters need boxing. Solum sends values, so a variable
passed by reference to a SUB that assigns to it must live in a cell rather
than a slot. Which variables those are is decided statically, in the same pass
that resolves procedures. This is the most expensive item in the language and
the one most likely to be underestimated. It is here anyway, because passing
by value instead would leave SWAP-shaped programs running and answering
differently — and a wrong answer is the one outcome this project does not
accept.
A jump reaches 64KB. Every offset is a u16, so no jump can span more than
65,535 bytes of code within one chunk. That is a few thousand statements per
procedure — generous, and a hard wall for one long module-level program.
Arithmetic stays a message send. SolVM has no arithmetic instruction; a + b
compiles to one OP_SEND, not an add. It is “a much faster interpreter”
rather than “compiled”, and the document should say so before the benchmark
does — but the benchmark has now spoken and it is 45 times the tree-walker,
not the order of magnitude first written here. The same counting loop, 200,000
iterations of two statements, is 1.54s under
basic.sol and 0.034s compiled, both including VM start.
How done is decided
There is no standard and no conformance suite, so the authority has to be manufactured. Three mechanisms, in descending order of how much they are worth:
1. A real QBasic is the oracle, and programs/sola/oracle.sh is how it is asked. The corpus is in two halves, and the split is the whole design:
oracle/agree/ |
must produce the same bytes under both. A difference is news — a defect, or a divergence nobody wrote down. |
oracle/differ/ |
must not. Each exercises a divergence recorded above and says at its head what each language should do. One that suddenly agrees is also news: the divergence has gone and the list still claims it. |
So the divergence list stops being prose and becomes something that can fail. This is the nearest thing to somebody else’s test, and the only mechanism here that can find something nobody thought of.
Every program in agree/ says its types outright — DEFINT, AS INTEGER, a
suffix — because QBasic’s default numeric type is SINGLE and SolaBasic’s is a
Double. A bare name is not the same variable in the two languages, and a file
testing PRINT must not be testing that instead.
The harness needs an oracle it does not carry. It takes any command that
runs a .bas through SOLA_ORACLE, and finds qb64 or fbc if either is
installed. For the article:
SOLA_QB_DIR=/path/to/qb45 ./programs/sola/oracle.sh
That directory wants BC.EXE, LINK.EXE and BCOM45.LIB from QuickBASIC
4.5. DOSBox is found on the PATH or inside /Applications/dosbox.app, which
is where Homebrew’s cask puts it and is why it is not on the PATH at all.
BC.EXE and not QB.EXE: the QuickBASIC environment writes to the screen,
which a script cannot read, where a compiled .EXE redirects into a file the
host picks up off the mounted drive. That is why this wants QuickBASIC 4.5 and
not the QBasic 1.1 that came with MS-DOS, which has no compiler in it. DOS ends
its lines with CR LF and the harness strips the CR before comparing, so a
difference it reports is a difference in what was printed.
2. A recorded transcript per feature, compared byte for byte on every build, in the manner of programs/basic/. Every statement in this document and every function in it has one, and the check is that the document and the transcripts cover the same list.
3. This document is frozen when the compiler starts. It may still change — a specification written before an implementation is always partly wrong — but every change is recorded below, with its date and its reason. The goal may move; it may not move silently. That is the whole of what ECMA-55 gave the interpreter, and it is the part that can be had without a standard.
Changes to this document
2026-08-26 — stage 3 was built first, and the claim it tests holds.
programs/sola.sol compiles labels and GOTO to
OP_JUMP and OP_LOOP. Before it was written, a chunk was hand-assembled with
a backward jump to an arbitrary earlier offset, a forward jump over dead code,
and a conditional between them: it verified and ran. Both ways of getting it
wrong were checked too, because a test that cannot fail proves nothing — a
jump into the middle of an instruction and a jump to a point at a different
stack depth are each refused at load, exit 65, as a message rather than a
crash. The depth-0 discipline is load-bearing rather than tidy, and nothing in
the design section above needed changing.
2026-08-26 — a third program, and it found nothing.
A word-frequency count: read a text, split it on anything that is not a letter,
tally the words in parallel arrays, sort by count and then alphabetically, and
lay out the table. It was written to work the string functions hard, those being
where the hand-emitted clamping lives and the likeliest place for an edge to be
wrong — MID$ a character at a time down a line, UCASE$, string comparison,
concatenation in a loop, and two arrays of different types handed to one
procedure.
It matched QuickBASIC byte for byte on the first run, and asked for nothing.
That is the first real program here to find neither a defect nor a missing
feature, and it is worth recording as a result rather than a quiet success: the
string half of the language is answering the way it should, and is now held to
that by somebody else. Twenty agree/ programs.
2026-08-26 — a second program, and it asked for more. Conway’s Life on a grid, which is the canonical BASIC program and the only thing here that works a two-dimensional array hard. It wanted two things and found a third.
An array parameter may now have any number of dimensions. This was on the not yet list with the reason its strides would have to travel with it — and they need not. The bounds are read off the call sites, every array handed to one parameter having to be the same shape, and a listing that hands it two being refused rather than answering the wrong element. A descriptor travelling with the array would cost every subscript in the language a lookup to buy a case this refuses out loud.
An array element could not be assigned on a one-line IF.
IF n = 3 THEN nxt%(r, c) = 1 is as ordinary as IF n = 3 THEN x = 1, and the
list of what may go there had simply never had 'arrayset added to it. Nor the
file statements. It has now.
And QuickBASIC wants the type spelled on an array parameter — g%() and not
g() — where SolaBasic will take it from a DEF. That is SolaBasic being the
more permissive of the two, so a listing written here may not compile there; it
is in the reference manual where somebody writing one will look.
Nineteen agree/ programs.
2026-08-26 — a real program, and what it asked for. Not a feature but a program: a sales report that reads records out of a file into parallel arrays, totals them, and lays out a table. Written to find out what a real one would want, which has been the most productive thing on this list — four of the defects found so far came out of writing the runtime rather than out of testing the compiler.
It wanted INPUT into an array element, which was on the not yet list
with the trigger the first program that asks. INPUT #1, nm$(n), qty(n),
price#(n) is how records go into parallel arrays, and the trigger fired the
moment a real listing was written. It is in.
And it found a defect on the way. Those subscripts were never being typed,
so the compiler had nothing to compare against and coerced an integer subscript
as though it were a Double — integer does not understand 'rounded', from a
statement that looked perfectly ordinary. Every walker that visits expressions
now visits those too.
The report matches QuickBASIC byte for byte, and is in the corpus. Eighteen agree.
What it did not want was ON ERROR. That entry’s trigger is the first
program that needs to survive a bad file, and this one writes the file it
reads, so it never does. The trigger stays unfired and the entry stays where it
is, which is the point of writing them down.
2026-08-26 — the : the definition had promised, and two bad errors.
Three things a reader hits at once, none of them a new feature.
: between statements was in this document from the start and was never
built. Lexical structure says it joins statements on one
line; the compiler refused it. That is the one thing the frozen-document
discipline exists to catch, and it sat there through eight stages. It works now,
and a one-line IF takes everything after THEN to the end of the line —
measured against QuickBASIC rather than guessed, because both readings are
plausible.
A missing file said the wrong thing in the wrong place. OPEN on something
that is not there gave the machine’s own cannot read against a line number
inside the runtime, reported as though it were a line of the user’s listing —
which that listing did not have. It says File not found, which is
QuickBASIC’s message, and a chunk now carries the file it was compiled from, so
a failure inside the runtime says the SolaBasic runtime and the trace goes on
to the user’s own line.
2026-08-26 — files, and the list is finished.
OPEN, CLOSE, PRINT #, WRITE #, INPUT #, LINE INPUT # and EOF,
sequential only — and they matched QuickBASIC byte for byte on the first
comparison, which no other stage managed. Sixteen agree/ programs now.
There is no streaming underneath, the machine reading and writing whole files, so a channel open for reading holds the file and a position in it, and one open for writing holds what has been written until it is closed. Stopping the program closes what is still open, because otherwise nothing would have been written at all.
Two things came out of writing it. INPUT # was not taking the quotes off a
field that WRITE # had put them on, so a round trip through a file gave back
"Hans" rather than Hans — and fixing it properly meant making the field
splitter quote-aware, so that a comma inside quotes stops separating and text
with one in it survives.
And one divergence is new: a file is written with line feeds where QBasic writes a carriage return and a line feed. A carriage return is taken off what is read, so a file written by either is readable here; one written here is not in DOS’s convention. It is in the list.
2026-08-26 — PRINT USING, measured before it was written.
The fiddliest formatting in BASIC, and the first feature here built the other
way round: twenty-one formats were run through QuickBASIC 4.5 first, and the
formatter was written to reproduce what came back. Every one matched on the
first comparison but one — the exponent letter, where PRINT USING said E and
plain PRINT already said D, so the oracle caught this compiler disagreeing
with itself.
The formatter is written in SolaBasic, beside PRINT’s and INPUT’s, and
building it there is what turned up
three defects in what stages 4 and 5 had already
shipped — DIM SHARED on a plain variable doing nothing, a procedure
zero-initialising the module’s shared variables, and a FUNCTION of no
arguments called without brackets being read as a variable. Writing a real
program in the language keeps being the thing that finds them.
2026-08-26 — the corpus grew to cover what it had been missing, and found
nothing. GOTO was not tested against QuickBASIC at all, which was a hole in
the middle of the design: interleaved jumps, a jump out of a block, a label
before NEXT, out-of-order numeric labels, the by-reference chain, and the
numeric functions all went in. All fourteen agree/ programs match, and no
new defect turned up.
That is worth recording as a result rather than a non-event. Three defects came out of the first fourteen programs, and the five written to close the biggest gap in the coverage came out clean — so the areas the design rests on are answering the way they should, and are now held to it by somebody else.
One thing was settled on the way. Program structure says a number at the start of a line is a label and not a line number, taken from CB80, and that it lets old listings through unaltered. QuickBASIC does the same: it compiles a listing whose numeric labels descend. The rule is not a divergence and the list needs no entry for it.
2026-08-26 — INPUT, and the oracle earned its keep twice more.
INPUT and LINE INPUT are in, and match QuickBASIC byte for byte. Two of the
three things worth recording came out of the comparison rather than out of
writing the feature.
A function of no arguments was being read as a variable. RND on its own
is a call; the parser made it a name, so r = RND quietly read an
uninitialised variable and answered nought — and the test written for it
passed, because it checked that the answer was at least nought and less than
one. Found when SOLAREAD$ did the same thing and made the runtime loop for
ever.
QuickBASIC echoes an answer it read from a file, so that a redirected
session reads the way the interactive one looked. SolaBasic did not, and
basic.sol had recorded that as a limitation of its own. It does now, when the
output is not a terminal — there is no way to ask whether input is one, and
output is the same question every time it matters.
And the end of input has to be told from a blank line. Both are the empty string to a program; the machine answers nil for one and not the other, so the runtime is handed a NUL, which no typed line can contain, and stops with Input past end of file rather than asking again for ever.
2026-08-26 — the oracle ran, and it was worth building.
QuickBASIC 4.5 under DOSBox, through
oracle.sh. All eight agree/ programs match
byte for byte and all five divergences are still there. Three things came out
of the first run, and not one of them was reachable from anything already here.
A real defect. PRINT (1 < 2) printed truD. A comparison used as a number
is -1, and this document has said so since it was written — but PRINT was
emitting the value without saying what type it wanted, so the machine’s boolean
went out as text, and the runtime’s exponent swap turned true into truD.
Eleven recorded transcripts were green, and one of them recorded truD as
correct, because a transcript records what a program does rather than what it
should do and re-recording after a change bakes the change in. That is the whole
argument for this stage, demonstrated on the first attempt.
Two entries in the divergence list were wrong. INTEGER overflow was
predicted to stop the program and does not — BC.EXE compiles without overflow
checking, so QuickBASIC wraps to -32768. And the digit count, the one thing
this document said was not settled, is settled: QuickBASIC prints sixteen
significant digits and SolaBasic prints the shortest that reads back the same.
And two literal forms were missing. 1# and 1D20 are how QBasic writes a
Double, and were needed to ask the digits question properly at all — found by a
corpus file refusing to compile rather than by anything looking for them.
2026-08-26 — the oracle harness is built, and its verdict is not in.
programs/sola/oracle.sh compares SolaBasic against
a real QuickBASIC over a corpus in two halves: agree/, which must match, and
differ/, which must not and says why at the head of each file. That turns
this document’s divergence list from prose into something that can fail — a
program in differ/ that starts agreeing means the divergence has gone and the
list is now wrong.
No verdict yet. The machine this was written on has no QuickBASIC, no
DOSBox, no qb64 and no fbc, and installing one is not this script’s business
— the repository claims no dependencies beyond a C11 compiler and make, and
the harness keeps that claim by saying what it needs instead of fetching it. So
what is recorded here is the mechanism and the corpus; the answers arrive when
somebody runs it.
Both paths are exercised, so the harness itself is not taken on trust: run
with SolaBasic standing in as its own oracle, every agree/ matched and every
differ/ was reported as having lost its divergence, which is precisely what
that arrangement should produce.
2026-08-26 — stage 5, and an array is already a reference.
DIM with constant bounds and up to eight dimensions, OPTION BASE, CONST,
DIM SHARED, and arrays passed to procedures.
By-reference for an array is free, which is the finding — and it is the
opposite of what a scalar cost. A Solum array is a reference, so Sort(n(), 6)
hands the array over and the callee’s atPut writes the caller’s storage
because it is the same array. No box, no analysis, nothing to keep alive.
Every subscript of a multi-dimensional array is checked, and a
one-dimensional one is not. One out of range would otherwise land on a
different element rather than off the end — a(1, 9) in an eight-by-eight is
index 9, which is a(2, 1) — and answering the wrong element quietly is the one
thing this must not do. A one-dimensional array needs no check, because there is
nowhere for a bad subscript to go except outside the array and the machine
refuses that itself.
Two things are narrower than QBasic and are recorded as divergences. An
array name means one array in the whole listing, where QBasic lets two
procedures each DIM a Temp of their own — bounds are settled while compiling
and looked up by name, so the name has to be the whole of the question. And an
array parameter is one-dimensional, because the strides of a bigger one would
have to travel with it and there is no descriptor to carry them.
2026-08-26 — stage 1: types first, then everything else falls out.
The three types, the whole operator table and all twenty-seven supplied
functions are in programs/sola.sol. Only PRINT’s
formatting is left of this stage, and that is stage 6’s business.
Types have to be settled before a byte is emitted, which is the finding. A conversion is an instruction acting on the top of the stack, so widening an Integer must happen after it is pushed and before the value beside it — by which time it is far too late to work out that it was needed. So the tree is typed in a pass of its own and emitting is a second walk that already knows where the conversions go. Everything else in the stage is a table.
There is no boolean type, and this document was right to say so. A
comparison answers -1 or 0 used as a number, which is why NOT, AND and
OR are bit operations and still read correctly. Internally a comparison
answers the machine’s boolean, because that is what a conditional jump wants —
turning one into -1 costs a jump, so the jump is emitted only where the value
really is used as a number.
Two operators follow QBasic against the machine. SolVM’s integer divide and
remainder are floored, so -7 \ 2 would be -4 and -7 MOD 2 would be 1.
QBasic says -3 and -1, and this says QBasic — exactly, in integers, by
adding one to the floored quotient when there is a remainder and the signs
differ. An earlier version went through the float divide and was wrong above
2^53; a truncating divide on integer
is what would make it one send instead of twelve instructions.
And VAL is strict, which is a new divergence rather than one this document
predicted. BASIC’s VAL reads a number off the front of a string and answers
nought for junk; that wants a scanner, and the file this compiler writes has no
library in it to hold one. It is in
Where this is not QBasic now.
2026-08-26 — stage 4, the expensive one, and by reference is a box.
SUB, FUNCTION, locals, SHARED, STATIC and by-reference parameters are in
programs/sola.sol. A procedure is a block bound to a
global and a call is value, which fits closely: a block has its own frame,
takes arguments in slots 1..n, and answers its last expression.
It never captures its home frame, so 3.1 does not bite — every name a procedure uses is its own slot or a global, so there is nothing to reach out for. 3.5 does, exactly as What this costs predicted before any of this was written: recursion stops around 254 levels, and the trace names the BASIC procedure and the BASIC line.
By reference cost far less than billed. The document called it the most
expensive item in the language, and the reason it was not is the representation:
a variable ever passed by reference is kept in a one-element array, always,
so the call hands the array itself over and the callee’s atPut reaches the
caller’s storage. No wrapping at the call site, no copying back, no temporary to
keep alive across the call, and nothing to get wrong when the call is recursive.
What it costs is one send on each read and write of such a variable, paid only by
variables that are actually passed that way.
The analysis is a fixed point and that part was as billed: a parameter is by reference when its procedure assigns to it or hands it on to something that does, and the second half chains through as many procedures as the listing has.
And two things BASIC requires turned out to need doing rather than assuming.
A variable this compiler never stores into is an undefined name to the machine
rather than a nought, so every name a scope mentions is now given its nought
before the scope’s first line — PRINT Z prints 0, as it must. And a STATIC
cannot be a frame slot, because a frame is new every call; it is a private
global instead, initialised once at module level.
2026-08-26 — stage 2 needed nothing the back end did not already have.
IF, SELECT CASE, FOR, DO, WHILE and EXIT are in
programs/sola.sol, and the whole of them is one stack of
open blocks over the hole-and-fill the GOTO work had already built: a forward
jump emitted before its target exists, patched when the closing line turns up
instead of when a label does. Nothing was added to the emitter, which is the
finding — the structured half of the language is the unstructured half with a
stack on top, and doing stage 3 first is what made that visible rather than
lucky.
Two things this settled that the document had left open. The blocks are a
stack and the statements stay flat, rather than a parser that builds a tree:
BASIC’s blocks are an opening line and a closing line, half the errors worth
reporting are the two not matching, and a stack has the mismatch in its hand
where a tree would refuse to parse and have less to say. And EXIT FOR leaves
the innermost FOR rather than the innermost block, so it searches down that
stack — four lines, against a tree walk.
Where FOR is not exact is now written down, because it was decided here.
A step written as a literal fixes the loop’s direction at compile time, which is
nearly every loop. A step that is an expression does not, so the test becomes
(limit - counter) * step >= 0 — right for either sign, and forever on a step
of nought, as BASIC is. The one case it gets wrong is a product that underflows
to -0.0, which compares as >= 0 and buys one extra iteration. Unreachable
from a literal step.
2026-08-26 — the speed estimate was too modest, and is now measured.
What this costs said compiling should be worth “roughly an
order of magnitude” over the tree-walker. It is 45 times: the same 200,000-
iteration loop is 1.54s under basic.sol and 0.034s compiled. The sentence has
been replaced with the measurement. The claim it qualifies — that this is a much
faster interpreter rather than compiled code, because arithmetic is still a
send — is unchanged and still the honest description.
Stages
The language above is the finish line. It is reached: every stage below is done, and each is held against a real QuickBASIC 4.5 rather than only against transcripts this compiler recorded of itself. What is left over is the list of things this document marked not yet from the start.
The order it was reached in:
| 0 | Done — programs/sola.sol. A .sob out of a SolaBasic program, running, with nothing of the compiler present. |
| 1 | Done. The three types, the whole operator table, all twenty-seven supplied functions — and PRINT’s rules, brought forward out of stage 6 because stage 7 cannot compare anything until output matches. |
| 2 | Done. IF in both shapes, SELECT CASE, FOR/NEXT, DO/LOOP, WHILE/WEND, EXIT FOR and EXIT DO, all compiled to jumps. |
| 3 | Done, and first, as this table said it should be. GOTO and labels, forwards and backwards, to any label in the program. What it found is below. |
| 4 | Done. SUB, FUNCTION, CALL, locals, SHARED, STATIC, EXIT SUB/EXIT FUNCTION, and by-reference parameters. |
| 5 | Done. DIM with constant bounds and up to eight dimensions, OPTION BASE, CONST, DIM SHARED, and arrays passed to procedures. |
| 6 | Done. PRINT’s rules, PRINT USING, INPUT, LINE INPUT, and sequential files — OPEN, CLOSE, PRINT #, WRITE #, INPUT #, LINE INPUT #, EOF. |
| 7 | Done, and the verdict is in. oracle.sh against QuickBASIC 4.5 under DOSBox: all fourteen agree/ programs match byte for byte, and all five divergences are still there. It has found three real defects and corrected two entries in the list below. |
Stage 3 is the one to reach early even though the ordering does not demand it,
because it is the claim the whole design rests on. If arbitrary GOTO between
statements does not verify as predicted, that is worth knowing in week one
rather than week six.