The expected outputs, checked against the real 5.21 binary¶
basic/dev/tests_with_results/ holds pairs: NAME.bas and NAME.txt.
utils/run_tests.py runs each program through the interpreter and compares the
output with the .txt exactly.
Those .txt files were originally captured from our own output, which makes
them a record of what we did print rather than of what MBASIC prints. That is a
trap: fix a formatting bug and the tests break, because they were pinning the
bug. Eighteen of them had drifted that way.
They are now taken from the real binary. utils/crosscheck_tests.py runs every
test twice - once through com/mbasic.com under cpmemu, once through us - and
reports whether the two agree:
python3 utils/crosscheck_tests.py
34 of the 39 tests match the real binary character for character. The five
that do not are listed below, each for a reason, and their .txt files hold
our output.
What the cross-check found¶
Six areas, all confirmed against the binary and against the reconstructed 5.21
assembler in /home/wohl/src/mbasic2025/mbasic_521/ - PRINT USING, error
reporting, the TRON trace, MOD and \, operator precedence, and zero-trip FOR.
PRINT USING¶
Seven separate faults in one statement, all fixed (src/basic_builtins.py).
Four of them in the field formatting:
| real 5.21 | was | |
|---|---|---|
PRINT USING "##,###.##"; 12345.67 |
12,345.70 |
12,345.67 |
PRINT USING "##.##"; 1.005 |
1.01 |
1.00 |
PRINT USING "####"; -1234 |
%-1234 |
-1234 |
PRINT USING "#.###"; -0.5 |
-.500 |
-0.500 |
PRINT USING "#.#^^^^"; 1.5 |
0.2E+01 |
1.5E+00 |
- The value goes through the same conversion routine
PRINTuses, so it is six significant figures for a single and sixteen for a double before the field rounds it to its own decimal places. This is the one the drift caught. - The field's rounding is half away from zero, not Python's half to even.
- A minus sign, or the
+of a+field, uses up one of the digit positions to the left of the point; a trailing sign does not. - The zero in front of the point is printed only while there is room for it.
- A
^^^^mantissa takes its integer-digit count from the field rather than normalising to one digit, and a double printsDwhere a single printsE.
118 forms were probed against the binary; all 118 now agree. Thirty-eight of
them are pinned in tests/regression/interpreter/test_number_format.py.
Three further PRINT USING faults came out of the same probing:
- The format string is reused until the value list is exhausted.
PRINT USING "###"; 1; 2; 3is1 2 3on the binary; we printed1and dropped the rest. Scanning stops the moment a field finds no value, after the literal text passed on the way has already gone out - which is why"### ###"with three values ends in a trailing space. - A comma delimits the value list as readily as a semicolon.
PRINT USING "###"; 1, 2was a syntax error here. That alone was whybrutef.basandsimcvt.bassat inbasic/dev/bad_syntax/; both now parse clean. - A trailing
;or,suppresses the newline, as on a plain PRINT. - Punctuation with no digit positions after it is a literal.
"A.B"printsA.Band then stops withIllegal function call, having never found a field to put the value in;"+###+"of 42 is+42+;"-#"of 5 is-5.
Untrapped error reporting¶
The message was built out of the Python exception:
?RuntimeError in 50: Cannot open NOSUCH.DAT: Cannot open NOSUCH.DAT: No such file or directory
50 OPEN "I",1,"NOSUCH.DAT"
against the binary's
File not found in 50
There is no leading ?, no exception class name, no Python text and no echo of
the source line. Direct mode drops the in <line> and prints the message
alone. The Python detail still reaches stderr through debug_log_error, which
is where it belongs. src/error_codes.py had the message table all along and
nothing imported it; error_code_for() now recovers the MBASIC code from what
was raised, and the canonical text is what gets printed.
Two behaviours went with it:
- A failed CHAIN or MERGE now stops the program. It reported the failure by printing, which left the PC alone, so execution ran on to the next line.
- A float divide by zero is not an error at all. With no handler armed the
binary prints a bare
Division by zero, substitutes machine infinity (1.70141E+38, keeping the dividend's sign) and carries on inside the expression. We stopped the program. The integer forms,5 \ 0and5 MOD 0, go through a different path in the binary and really are fatal. WithON ERRORarmed it is an ordinary trappable error, ERR 11.
Six conditions were not detected at all and now are: A$ = 5 and A = "X"
(Type mismatch), C% = 40000 (Overflow), a second DIM of the same array
(Duplicate Definition), MID$("A",0) (Illegal function call), and a FOR
or WHILE with no terminator. GOTO 9999 reported Invalid PC: PC(9999.0) at
line 9999; it is Undefined line number at the line doing the jumping.
ERR was 5, Illegal function call, for a dozen errors that have codes of
their own - the reporter and the ERR variable were reading two different copies
of the same guesswork. Both now go through src/error_codes.py, so ERROR 21
is error 21 and prints Unprintable error, which is what the binary calls any
code with no message of its own.
Making DIM refuse to re-dimension also turned up an older bug in ERASE,
twice over: it deleted under the name as written while DIM stores under the
resolved one, and the parser resolved DIM M(64) against a DEFINT while
leaving ERASE M alone. The ERASE M : DIM M(64) idiom had been silently
erasing nothing at all.
25 error provocations were run on both engines; all 25 now agree, message and
halting alike. They are pinned in
tests/regression/interpreter/test_error_messages.py.
The same wording in every UI¶
docs/user/UI_FEATURE_COMPARISON.md promised "Standard MBASIC errors" in all
four UIs, and it was true of none of them. Each backend built its own string
out of the Python exception, so one failure read four different ways:
CLI ?RuntimeError in 20: Cannot open NOSUCH.XYZ: Cannot open NOSUCH.XYZ: No such file...
curses | Error: Cannot open NOSUCH.XYZ: Cannot open NOSUCH.XYZ: No such file or directory
Tk --- Error at line 20: Cannot open NOSUCH.XYZ: ... ---
web --- Error: Cannot open NOSUCH.XYZ: ... ---
against File not found in 20.
The rendering now happens once, in ErrorInfo.message(), on top of
src/error_codes.py; message_for(exception, line) is the same thing for the
sites that hold an exception rather than an ErrorInfo. Each UI keeps its own
presentation around it - the curses box still draws its box and shows the
offending source line, because that is a deliberate affordance of a UI
documented as an IDE. What it no longer does is invent the wording.
The command handlers went the same way. Measured at the Ok prompt, the binary
answers LOAD "NOSUCH.BAS" with File not found, SAVE with Missing
operand, ZZZ 1 with Syntax error, RUN 9999 with Undefined line
number and CONT with Can't continue - no ?, no filename, no Python
detail. All four backends now do too. tests/regression/ui/test_error_message_parity.py
pins the text, and deliberately does not pin the chrome.
Two of those handlers were wrong in a way the first pass preserved, because
both cases had been written as ?Syntax error and the binary calls neither
one that:
SAVE Missing operand nothing after the keyword
SAVE "" Bad file name an operand, but an empty one
LOAD, MERGE and CHAIN measure the same. _usable_filename in
src/interactive.py is now the single check, and Bad file name (error 64)
had not been reachable before.
Missing operand is not "the statement ran out"¶
SAVE is Missing operand and ZZZ 1 is Syntax error, so error_code_for
has to tell one parse failure from another. The first rule tried was "did the
text mention EOF", on the reasoning that running out of input is what a missing
operand is. It is not, and the binary is precise about the difference:
PRINT 1 + Missing operand an operator with nothing after it
PRINT EOF( Missing operand an open bracket, then nothing
FOR I = 1 TO Missing operand
LET X = Missing operand
DIM A( Missing operand
PRINT # Missing operand
OPEN "I",1, Missing operand
PRINT MID$( Missing operand
PRINT EOF Syntax error EOF wanted a bracket, not an operand
PRINT (1 Syntax error so did the bracket
X = EOF(1 Syntax error
PRINT EOF) Syntax error
PRINT LEFT$("a" Syntax error
GOTO Syntax error
ON 1 GOTO Syntax error
Measured under cpmemu. Across our parser's whole message vocabulary the split
falls on exactly one message - Unexpected token in expression: EOF - and on
nothing else, so that is what the code keys on. Matching "EOF" anywhere in the
text got all seven of the second group wrong, because there EOF is either the
token a bracket was wanted before or the name of the EOF function. Both
groups are pinned in the parity test.
Deciding a parse failure before searching _MESSAGE_CODES also settles a
trap that predates the change: the fragment list contains "overflow", "type
mismatch" and "syntax error", and an unknown statement's name is quoted back
inside the message. OVERFLOW 1 therefore used to read as error 6. The binary
calls it, and TYPE MISMATCH and SYNTAX 1 with it, a plain Syntax error.
Said once, not twice¶
ProgramManager.parse_single_line already returns Syntax error in 20: <detail>,
and four callers wrapped that in a second prefix of their own, so a bad line in
a LOADed or MERGEd file came out as
curses ?Parse error at line 20: Syntax error in 20: column 4: ...
Tk Parse error at line 20: Syntax error in 20: column 4: ...
web 20: Syntax error in 20: column 4: ...
The prefix now belongs to whoever builds the message, and the callers print it
unchanged. (The <detail> after the colon is still ours rather than MBASIC's -
the binary does not diagnose a line until RUN at all, which is divergence 6 in
MBASIC_521_DIVERGENCES_TODO.md.)
The TRON trace¶
[nnn] is written with no newline of its own, and only when execution enters
a line, so the binary gives
[130][140][150][160]Result: 50
We printed one bracket per line, re-traced on RETURN into the middle of a line,
and traced lines whose TRON only ran part way along them.
MOD and \¶
Both truncate toward zero rather than flooring, both round their operands to integers first with halves away from zero, and both require the result to fit 16 bits:
-10 MOD 3 real -1 was 2 (the remainder takes the dividend's sign)
-10 \ 3 real -3 was -4
7.6 MOD 3 real 2 was 1.6 (operands are CINT'd, so 7.6 is 8)
40000 \ 2 real Overflow was 20000
Operator precedence¶
* and /, \, MOD, and + and - are four separate levels in MBASIC's
operator table, not three. \ and MOD were sharing a level with *:
12 \ 2 * 3 real 2 was 18 (it is 12 \ 6)
12 MOD 5 * 2 real 2 was 4 (it is 12 MOD 10)
Zero-trip FOR¶
FOR I = 10 TO 1 must not run its body at all, and must leave I at 10. We ran
it once and left I at 11.
MBASIC never falls into the body: FOR scans forward for its NEXT and jumps
to it with the increment suppressed, so the ordinary termination test is what
ends the loop. Entering through the NEXT rather than skipping past it is what
makes
10 FOR I=10 TO 1 / 20 PRINT "BODY" / 30 NEXT J
report NEXT without FOR in 30 - the NEXT still runs, and still checks the name
it was given. The termination test is a sign comparison, which is why
FOR I = 1 TO 0 STEP 0 runs for ever and FOR I = 5 TO 5 STEP 0 does not run
at all.
The five that do not match¶
Microsoft Binary Format, deliberately¶
Our arithmetic is IEEE binary32/64; MBASIC's is MBF. That is a project
decision - see src/number_format.py - so these differ and should.
- log10k -
Error: .5461504873501326against our.5457254793072934, accumulated over the benchmark. - test_type_conversion -
CDBLof a single 3.14 is3.139999866485596there and3.140000104904175here. - test_math_functions - the binary does not print
PASS: ^ worksorPASS: Trig functions work, because5 ^ 2 = 25is false on it:^is computed asEXP(Y*LOG(X))and lands on 24.999998, which prints as 25 and compares as less than it. - test_random_files - one line.
A = VAL("99.99")does not compare equal to the literal99.99on the binary, because its ASCII-to-float routine is a little less accurate than IEEE's correct rounding.
A statement 5.21 has not got¶
- test_file_io -
OPEN "A"(append) is not a 5.21 file mode; the binary answersBad file mode in 270and stops. Everything before that line matches. Append is ours; seedocs/dev/MBASIC_521_DIVERGENCES_TODO.md.
What the fixes changed elsewhere¶
Zero-trip FOR, the MOD/\ semantics and the error reporting reach every
program, so all 536 .bas files under basic/ were run against a pristine
tree and against the patched one and the outputs diffed - three times, once
after each round of changes. Discounting programs whose output depends on where
a 15-second timeout happened to cut them, 200 changed on the final pass, and
every one traces to an intended fix:
164 the error message reformatted - one canonical line where there had
been "?PythonError in N: ..." and an echo of the source
27 the program stops earlier, or prints fewer lines: an untrapped error
now halting, the source echo gone, or a trailing ';' on PRINT USING
no longer breaking the line
5 the tests in this directory, changed on purpose
4 the program gets *further* - charfreq.bas past its division by zero,
brutef.bas past the PRINT USING comma, xlabels and bmodem1 to a more
accurate error
The earlier pass, before the error work, is the one worth reading for the loop change. 27 programs changed then, and they divide into:
- Zero-trip FOR, correcting a real failure.
basic/games/superstartrek.basused to die on its first command withASC of empty string, because9450 FOR I=1 TO LEN(ZZ$)ran once on an empty string. It now plays.cpm-pert.bas,handplot.bas,prime1.bas,charfreq.basand nine games are the same shape: a loop bounded by a count that came out zero. - Programs that were never MBASIC.
xlabels.basand seven files inbasic/dev/bad_syntax/still fail, at a different point or with a different message. Two were checked:surround.basnow saysType mismatchrather thanDivision by zerofor"a" \ "b", which is what the binary says, andhexbin11.basreaches an undefined line instead of a bad subscript.
Two genuine regressions turned up in those sweeps and were fixed. IEEE's
negative zero was being treated as a negative number, so
PRINT USING "$$#####.##" of TD * (-1) printed -$0.00 - MBF has no signed
zero, and every zero total in basic/business/budget.bas had a minus in front
of it. And once the FOR/NEXT scan became unconditional, its blind spot for
clause statements became fatal: basic/utilities/million.bas waits for a key
with
0 PRINT "...":FOR I=-32767 TO 32767:X$=INKEY$:IF LEN(X$)=1 THEN GOTO 1 ELSE NEXT I
and counting only top-level statements found no NEXT at all. The scan now looks inside THEN and ELSE clauses, as MBASIC's does.
Test programs that had to change to be checkable at all¶
Four could never have matched, whatever the interpreter did.
- test_file_io, test_random_files - opened
/tmp/test_file.txtand friends, which CP/M cannot name. They now use 8.3 names in the working directory and tidy up after themselves. - test_chain, test_merge - CHAINed and MERGEd
/tmp/chain_target.basand/tmp/merge_overlay.bas, which have never existed in this repo, so both engines only ever produced a file-not-found. They now write the program they are going to chain to or merge, then use it, then delete it. Both are binary-verified,CHAIN ... , , ALLvariable passing included. - test_deftypes, test_input - used
NAME$as a variable.NAMEis a reserved word on the binary (the CP/M file-rename statement), so both died with a syntax error at that line and lost every test after it. Renamed toNM$.
Two interpreter fixes came out of that work: CHAIN "CHNTGT.BAS" was looking
for CHNTGT.BAS.bas, and a MERGE inside a running program was printing a
summary the binary does not print.