Skip to content

Divergences from MBASIC 5.21 still outstanding

Found by cross-checking basic/dev/tests_with_results/ against the real binary under cpmemu, and by a sweep of the whole error catalogue. Everything here was measured against com/mbasic.com, and most of it was also traced back to the reconstructed 5.21 assembler in /home/wohl/src/mbasic2025/mbasic_521/mbasic_src/.

What has been fixed is in TESTS_VERIFIED_AGAINST_BINARY.md: PRINT USING, the TRON trace, MOD and \, operator precedence, zero-trip FOR, and untrapped error reporting. This file is the remainder.

Each open difference has a GitHub issue (#30 to #57). The issue has a reproduction on the binary, the cause in our code and a proposed fix. When an issue is closed, change its entry here to "Done", or remove the entry.

1. Errors that were not detected - mostly done

The reporting is right now - <message> in <line>, no ?, program stops - and 25 provocations match the binary exactly. The conditions that we did not notice before are now detected:

Done (GitHub issues 30 to 33, docs/dev/STRING_SPACE_AND_FILE_ERRORS.md):

  • Done (#30): a string expression that needs more than 10 string temporaries gives String formula too complex (error 16), as on the binary. The test recipe is a nest of eleven CHR$(65)+(...). The old recipe (STRING$(200,65) and twelve concatenations) gives String too long. The body of a DEF FN function is not counted.
  • Done (#31): Out of string space (error 14) when the live strings do not fit in the string space. The space is a byte budget (39168 at start-up, as FRE reports), not a memory model, so the error comes a little later than on the binary. CLEAR ,n and CLEAR ,,n change the budget, and FRE reports the string space less the live strings. Only a literal (class ProgramString) is free. A result of LEFT$, RIGHT$, MID$ or + counts, also when it equals the whole literal. A DEF FN that returns a variable or a parameter counts (a copy on the binary); a DEF FN with a literal body (also through a chain of DEF FN calls), and a string read from DATA, are free.
  • Done (#32): OPEN "R" keeps the record length (0 to 128, default 128), FIELD gives FIELD overflow when the widths are more than the record length, and GET and PUT use the record length. The binary binds the variables before the one that overflows. The interpreter changes nothing.
  • Done (#33): Bad file name and Too many files for names that CP/M cannot hold, in OPEN, KILL, NAME, LOAD, SAVE, MERGE, CHAIN and RUN in a program. A name with a slash or backslash passes as a host path, and the setting cpm_file_names turns the check off. A command typed at the prompt is not checked.
  • The JavaScript and C backends do not raise any of these four errors.

Done (#34, #35, #36): LOAD and MERGE of a file stop reading at the first bad line, as the binary does. The bad line is a line over 255 bytes (Line buffer overflow in <number>, 0 if the line has no number), a line with no number (Direct statement in file) or a bare number of a missing line (Undefined line number). The message is bare. The lines read before it stay: LOAD of a file with a bad first line leaves an empty program, and MERGE keeps the old program. A bare number of a missing line typed at the prompt also prints Undefined line number. Inside a running program the binary prints the bare message, does not run any ON ERROR handler and stops the program; ours does the same. RUN "file" of a file with a bad line stops after the message and does not run. The Tk and curses UIs print the same message. The web UI loads files through the editor and does not use the loader.

CHAIN and CHAIN MERGE use the same loader (cmd_chain in src/interactive.py), so they follow the same rules. Measured: after the bad line the program stops with the bare message. CHAIN leaves the lines read before the bad line (an empty program for a bad first line). CHAIN MERGE keeps the old program and the lines merged before the bad line. Before this, cmd_chain read the file with its own loop and ignored the bad lines, and CHAIN MERGE of a bare number of a missing line looped for ever. The curses and Tk cmd_load and cmd_merge now return True after a read abort, so a running program stops there too. Tests: tests/regression/integration/test_chain_file_errors.py. The curses and Tk UIs now have a cmd_chain (chain_into_program in src/ui/ui_helpers.py); it loads the file and starts the program with cmd_run, as RUN "file" does.

After a read abort in the CLI (LOAD, MERGE, CHAIN, CHAIN MERGE) the variables, arrays and FOR/GOSUB state are cleared and CONT gives Can't continue (measured). CHAIN with a start line now begins at that line; a start line that the new program lacks prints a bare Undefined line number. CHAIN MERGE with DELETE a-b now parses the range (before, 100-110 was read as the number -10 and the range was lost, so the merge looped for ever) and deletes those lines from the old program before the merge, even the CHAIN line. The file is opened first: a missing file deletes nothing. A missing first or last line of the range is Illegal function call at the CHAIN line, with nothing deleted. CHAIN MERGE without ALL clears the variables (only COMMON variables pass on), as CHAIN does. The web UI uses the same order and rules. DELETE a- (open range) is Illegal function call on the binary; ours is a syntax error. The curses, Tk and web read-abort paths do not clear the variables yet.

A floating-point overflow is now reported. The binary prints a bare Overflow (no line number, no Ok). It substitutes machine infinity with the sign of the true result, and the program goes on. With ON ERROR armed it is ERR 6.

The interpreter and the JavaScript backend do the same for a result that is infinite in the IEEE format of its precision (binary32 for single, binary64 for double). Examples are 10^40, 1.7E38*10, EXP(100), 1E38*1E38, CSNG(1D39), A! = 1D39 and VAL("1E400").

A literal that is too big, such as 1E39, gets Overflow when the line is entered. Machine infinity takes its place. 0^-1 is a non-fatal Division by zero. These are Illegal function call: a negative base with a fractional exponent, SQR of a negative number, and LOG of zero or a negative number. CINT(40000) is Overflow.

What is still different:

  • The MBF range is not emulated (section 10). PRINT 2^127, EXP(88) and EXP(100#) are legal here and Overflow on the binary. 1D300/1D-300 is Overflow here and Division by zero on the binary, because MBF cannot hold 1D-300. 1D300^2 and EXP(1000#) print a single-precision machine infinity on the binary and a double one here.
  • MBF has no denormal numbers. A result below about 1.5E-39 is 0 on the binary (PRINT EXP(-100) and PRINT 1E-20*1E-20 print 0). IEEE gives a denormal number here (3.78351E-44). This follows from the IEEE decision in section 10.
  • PRINT USING with ^^^^ and a value that is close to a power of ten (such as 9.99999E30): the binary scales the value by a product of powers of ten in MBF. Two effects follow. (1) The scaled value can round to exactly 1, and then the binary does renormalize. With a single-precision value, "##.#^^^^" gives 1.0E+22 for 9.99999E21 and 1.0E-20 for 9.99999E-21, and "#.##^^^^" gives 0.10E-19 for 9.99999E-21. We give 10.0E+21, 10.0E-21 and 1.00E-20. This shows for exponents of about 21 to 37 and about -21 to -39. (2) The binary truncates the scaled value when the exponent is about 12 to 26 ("##.#^^^^" gives 9.9E+12 for 9.95E12 and 9.9E+26 for 9.95E26). We round and give 10.0E+12. Double values differ in the same way ("#.##^^^^" with 9.99999999999999D+99 gives 0.17D+39 on the binary, a value in the MBF range). Issue #22 is only partly fixed: the case of a carry that is not caused by the MBF scaling agrees. We do not emulate the MBF multiplication.
  • The sign of machine infinity after a double division by zero is erratic on the binary (it depends on what was computed before). We use the sign of the dividend.
  • Done (#38): LOAD, MERGE and the command-line load print Overflow for each numeric literal that is too big (such as 1E39), while they read the file, as a typed line does. LIST shows the text as typed.
  • Done (#39): a line with an &H constant of more than four digits, or an &O constant above 65535, is refused with Overflow and not stored. A refused replacement leaves the old line. LOAD and MERGE stop reading the file at such a line, as the binary does (the lines after it are not loaded). The JavaScript compiler stops with a compilation error, and the compilation error stays.
  • Done (#39, DATA): DATA text is not crunched, so a DATA line with &O9, &O8 or &O19 is stored, listed and loaded. READ A$ gives a numeric DATA item as it was typed (&H12345, &O999, 1E39, 1.50). Outside DATA the refusal stays. The lexer splits the rest of a DATA statement into raw items (up to a comma or a colon). A clean number goes through the number reader; every other item (1 2, 12abc, &OA, &H12G, X+1, (1), PRINT) is a string with the case kept and the trailing spaces dropped, as on the binary. An empty item is an empty string. A signed item such as +3 reads as +3 into A$. The JavaScript compiler does the same. Text after a closing quote ("q"x, "q" x, "q"1, "q"'c) is a Syntax error at the DATA line on the binary, when READ reaches the item. READ A$ stores the string first, then the error is raised and the DATA pointer goes back to where the READ began, so the next READ gives the same item and the same error. Spaces before a comma, a colon or the end of the line are fine. The lexer marks the item (bad_tail on the token and on StringNode); the interpreter and the JavaScript compiler (_read_check) raise the error after the store. Test: tests/regression/integration/test_data_quote_tail.py. READ into a number variable parses the text of an unquoted item at READ time, as the binary does (data_text_to_number in src/interpreter.py, and the same code in the JavaScript compiler). Spaces inside the digits are ignored (1 2 is 12), 1E is 1, a type suffix at the end is accepted, and &H, &O and & take digits only. A bad item is a Syntax error reported at the DATA line (abc, 12abc, 1.5.5, &HG, a quoted item, a sign before &); &O8, &O19 and &9 are reported at the READ line; a value over 65535 is an Overflow at the READ line. ERL is the DATA line, and RESUME NEXT goes on after the READ, and the DATA pointer goes back to where the READ statement began, so the items that the same READ already read are read again, and the bad item is the next item to read (READ B$ after the trapped error gets it). ON ERROR GOTO 0 inside the handler reports the DATA line, as ERL does (ErrorInfo.report_pc). Test: tests/regression/integration/test_entry_time_forms.py.
  • Done: a line number stops at 6553. MBASIC takes no more digits once the value is 6553 or more, so 70000 PRINT 1 is line 7000 with the text 0 PRINT 1, and a bare 70000 is line 7000 with the text 0. 65529 is a line number. The prompt, LOAD and MERGE do this (src/line_number_split.py).
  • Done (#40): the text of an Overflow or Division by zero message that does not stop the program goes through the width logic of PRINT, one character at a time, in the interpreter and in the JavaScript backend. With WIDTH 40 and the cursor near the right edge the word is split (Overflo / w), as the binary does. Test: tests/regression/integration/test_notice_width_wrap.py.
  • Done (#41): the JavaScript backend stops with <message> in <line> with no ?, as the interpreter does. ERROR n uses the message of code n. A fatal RETURN in a program with no GOSUB is RETURN without GOSUB in <line> (it was a JavaScript ReferenceError). STOP prints Break in <line>, also inside a handler, and starts a new line first when the cursor is not in column 0. The interpreter does the same now. RESUME to a line that does not exist is error 8 at the RESUME, so a handler that is still armed catches it again, as the binary does (it was fatal). An ERROR 9 inside a handler prints Subscript out of range in <line>, in the interpreter and in the JavaScript backend. Test: tests/regression/integration/test_js_fatal_error_line.py.

Done (#42): src/error_codes.py no longer carries the nine codes 5.21 has not got - 24, 25, 68-72, 75 and 76. ERROR 24 prints Unprintable error, as the binary does, and ERR stays 24. The JavaScript backend prints Unprintable error in 10 too.

Done for the interpreter (#43): the interpreter applies FRCINT's 16-bit range check, and CONINT's second test, wherever the binary does. An integer argument is rounded and must fit -32768..32767, or the result is Overflow. A byte argument (LEFT$, RIGHT$, MID$, SPACE$, STRING$, CHR$, the start of INSTR, INP, OUT, the value of POKE, ON...GOTO, file numbers and INPUT$) must then be 0 to 255, or the result is Illegal function call (so LEFT$("AB",300) and SPACE$(256) stop with an error, as on the binary). A negative array subscript or DIM bound is Illegal function call. TAB, SPC, MKI$, RANDOMIZE and WIDTH check 16 bits. PEEK and the address of POKE accept -32768 to 65535; PEEK still returns a random byte.

The same checks cover these statements:

  • ERROR n: 0 and above 255 are Illegal function call, 5.6 rounds to 6.
  • The MID$ statement: start and length are checked. A start of 0, or a start beyond the end of the target with a length above 0, is Illegal function call (also for an empty or undefined target). A numeric right-hand side is Type mismatch.
  • FIELD: widths are checked, and widths that add up to more than the record length of OPEN (128 when LEN is left out) are FIELD overflow.
  • GET and PUT: a record number that rounds to 0 is Bad record number, a negative one is Illegal function call.
  • OPEN ... LEN: above 128, the default /S: size, is Illegal function call.
  • WAIT: the operands are checked as bytes. The interpreter has no ports, so a WAIT that would wait for ever stops with Illegal function call.

A double is taken to single precision before FRCINT rounds it, so 2.49999999# AND 3 is 3.

Done for the JavaScript backend (#43): the runtime has _frcint and _conint, which copy to_frcint and to_conint. STRING$, SPACE$, LEFT$, RIGHT$, MID$, CHR$, the start of INSTR, ON...GOTO and ON...GOSUB, INP, OUT and the value of POKE use the byte check. TAB and SPC in PRINT check 16 bits, and the argument is evaluated when its turn comes, so PRINT "A";TAB(40000) prints the A first. PEEK and the address of POKE accept -32768 to 65535. RANDOMIZE, WIDTH and ERROR n check as the interpreter does. The MID$ statement is a runtime function with the same checks as the interpreter, and it no longer deletes characters when the replacement is shorter than the length. Every array subscript goes through _ix: Overflow above 16 bits, Illegal function call when negative, Subscript out of range above the bound of its dimension. Before, an out-of-range write grew the array. The JavaScript compile path keeps one subscript per dimension (SemanticAnalyzer(flatten_arrays=False)), because the nested arrays of the generated code did not work with the flat index of the C backend. An array that is not in a DIM gets as many dimensions as its uses have subscripts. DIM A(2.5) rounds (also for the C backend), and a DIM bound above 32767 is Overflow when the DIM runs. Test: tests/regression/integration/test_js_range_checks.py. Done later: the JavaScript DIM bounds are checked when the program runs (see the #60 entry and docs/dev/JS_ARRAYS.md). Done: the file statements FIELD, GET, PUT, OPEN ... LEN and the file numbers have the range checks, and FRCINT is exact in both backends (see section 11 below).

Round 2 of the JavaScript backend: READ and SWAP with array elements use the subscripts and the bounds check (before, they assigned the whole array). OPTION BASE 1 is used by the bounds check. An array used with the wrong number of subscripts is Subscript out of range, in JavaScript and in the interpreter. The interpreter now checks the second and later dimensions against their own bound (A(2,6) on DIM A(5,5)). RESUME with no error is RESUME without error, and a handler that falls off the end reports No RESUME in <last line>. ON ERROR GOTO 0 in a handler reports the line of the first error. ERASE deletes the array in JavaScript: the next use makes it again with the bounds 0 to 10, as on the binary. A fatal ERROR n in a handler, where n has no message, prints Unprintable error. INSTR gives 0 for an empty string and for a start beyond the end, also with an empty search string (interpreter and JavaScript). MID$(A$,2,3)=A$ copies byte by byte over itself, so hello gives hhhho. Done: a target shared through B$=A$ does not copy over itself on the binary (MID$(A$,2,3)=B$ gives hhelo and leaves B$), also with A$="hel"+"lo". The interpreter and the JavaScript compiler already do this; test: test_js_misc_edges.py.

Done for the interpreter (#43, second part). Each item was measured on the binary:

  • MID$(A$,2)=X$ with no length is accepted. It replaces as much of A$ as X$ fills, as the three-argument form does with a length of 255. A start of 0 or beyond the end is Illegal function call, a number on the right is Type mismatch. The JavaScript backend does the same.
  • CLEAR already agreed with the binary: the numbers are read as 16-bit values (CLEAR 70000 and CLEAR ,70000 are Overflow), CLEAR -1, CLEAR 0,0 and CLEAR ,0 are Illegal function call, a string is Type mismatch. Tests now cover this.
  • USR and USR0 to USR9: the argument is evaluated (USR(1E40) prints Overflow first), then the call is Illegal function call, with or without DEF USR: there is no machine code to run. The argument can be of any type (USR("A") is Illegal function call, not Type mismatch).
  • DEF USR[n]=address is a statement now (it was a Syntax error). The address is -32768 to 65535 and is not kept: 65536 is Overflow, a string is Type mismatch. DEF USR10=1 is a Syntax error, as on the binary. The lexer ends the reserved word USR before a digit, so DEF USR 4=100 works and a name that starts with USR (USRX, USR1X, USR10, USR10(1)) is a Syntax error, as on the binary. Other reserved words are not split off the front of a name.
  • A missing right-hand side is Missing operand (22) when the statement ends there (end of line, : or ELSE) and a Syntax error before , or ), as on the binary: A=, DEF USR=, WIDTH LPRINT, MID$(A$,2)=. MID$(A,2)="X" with a number A is Type mismatch.
  • LPOS(x) is a function now (it was an array). The argument is any type and is not used. The value is 1, the leftmost column of a printer: LPRINT is not implemented, so there is no printer column. LPOS with no argument is a Syntax error.
  • WIDTH 0 is accepted. The width is a byte (WIDTH 256 is Illegal function call, WIDTH 70000 is Overflow) and 0.4 rounds to 0. With width 0 the binary prints a line break before the first character of a line and never wraps again, and TAB and SPC are Division by zero (error 11) because they divide by the width. WIDTH LPRINT n is a statement now (it was a Syntax error) and checks the byte only. Test: tests/regression/integration/test_mid_clear_usr_lpos_width0.py.

Left out on purpose, with the reason:

  • DIM A(32767) is Subscript out of range on the binary because the array does not fit in its memory. There is no memory model (see docs/dev/NO_MEMORY_MODEL.md), so here the array is made.
  • The maximum number of files: with the default /F:3 the binary says Bad file number for CLOSE #4. This interpreter allows 15 files on purpose.

Found while measuring (#61):

  • Done (#61): a wrong number of arguments to a built-in (ABS(), ABS(1,2), POS(1,2), USR(), LPOS(1,2), LEFT$(A$), INSTR(1), RND(), INKEY$()) is a Syntax error (2) when the line runs, as on the binary. The binary reads the arguments as the call runs, so the arguments up to the first unusable one run first: LEFT$(CVI("A")) is Illegal function call, LEFT$(A$,1,CVI("A")) is Syntax error. The parser marks the call (FunctionCallNode.arity_error, table BUILTIN_ARITY in src/parser.py); the interpreter and the JavaScript backend raise the error. The C backend stops at compile time (ABS requires 1 argument), as it does for every run-time error. For the multi-argument built-ins the binary checks the type of an argument at the , behind it, so a wrong type is Type mismatch (13) before the count is a Syntax error: LEFT$(1,2,3), STRING$("A"). ABS("x",1) and LEFT$(1) stay Syntax error (arity_type_checks in src/ast_nodes.py). A number argument is range-checked when it is read, before the count error: MID$("A",0,2,3), LEFT$("A",-1,2), STRING$(-1,2,3), INSTR(0,"A","B",4) and INPUT$(1,300,2) are Illegal function call (Overflow above 32767), and MID$("A",0,"x",3) is Illegal function call before the type of "x". SPACE$(-1,2) and CHR$(300,1) stay Syntax error (BUILTIN_ARG_RANGES in src/ast_nodes.py; interpreter and JavaScript). Test: tests/regression/integration/test_early_arg_range.py. A string given to a number argument (ABS("a"), LEN(1)) or the other way round (LEFT$(1,2)) is Type mismatch in the interpreter, as on the binary (builtin_arg_kinds in src/js_type_check.py).
  • Done (#61): a FOR inside a THEN or ELSE part (IF 1 THEN FOR I=1 TO 2:PRINT I:NEXT:PRINT "a") runs in the interpreter. The body of such a loop is the rest of the part, which has no PC, so the clause executor goes back to the statement behind the FOR (_clause_jump in src/interpreter.py). This also works after a GOSUB in the body and after RESUME NEXT. A NEXT on a later line (10 IF 1 THEN FOR I=1 TO 3:PRINT I / 20 NEXT) loops as on the binary: the NEXT runs the rest of the part again (_execute_next_single). Done: FOR keeps the text pointer of the NEXT that its scan found (NXTSCN), and only that NEXT can end the loop. Any other NEXT is NEXT without FOR: the second NEXT of IF c THEN NEXT ELSE NEXT (also at the end of the loop of a FOR that is not in a clause), a NEXT that a jump reaches, and a second NEXT on a later line. The interpreter keeps that NEXT per loop variable (_for_next_node); the JavaScript compiler gives the error for every NEXT that no FOR was paired with, and the semantic analyzer no longer refuses such a program for JavaScript (runtime_next_errors). Test: tests/regression/integration/test_next_matches_first.py.
  • Done (#61): INSTR gives its error from the types, not only the count (instr_shape_error in src/ast_nodes.py, interpreter and JavaScript backend). A string first argument is the two-argument form: INSTR("A",1) is Type mismatch and INSTR("A",1,1) is Syntax error. A number first argument is the start: INSTR(1,"A") and INSTR(1,"A",1,1) are Syntax error, INSTR(1,1) and INSTR(1,"A",1) are Type mismatch. All 30 combinations of 1 and "A" with one to four arguments match the binary.
  • Done (#61): WIDTH 40,1 and WIDTH 30, set the width and then give Syntax error (WidthStatementNode.bad_device). WIDTH LPRINT 40,1 and WIDTH #1,40 were already Syntax error. The C backend refuses the form.
  • Done (#61): a CLEAR that succeeds forgets ON ERROR GOTO (the next error is fatal) and ends a running handler (RESUME after it is RESUME without error). ERR and ERL keep their values. The same CLEAR also empties the FOR and GOSUB stacks (NEXT without FOR, RETURN without GOSUB), sends the DATA pointer back to the first item, forgets the DEF FN functions defined so far (Undefined user function) and allows OPTION BASE again (the base is 0). A CLEAR that fails (CLEAR ,1 is Out of memory) changes nothing, and WIDTH is not changed. The JavaScript backend checks the CLEAR arguments as the interpreter does (Overflow, Illegal function call, Out of memory; each CLEAR starts from the start-up memory top and stack) and forgets the handler only when the CLEAR succeeds. CLEAR ends at ELSE in a one-line IF. The JavaScript and C compilers clear the variables too (see the entry on CLEAR and the DEF types below).
  • Done (#61): when the operand that ran out is ELSE (A=ELSE, A=1+ELSE, PRINT 1+ELSE, also after THEN/ELSE), the error is Missing operand and RESUME NEXT goes on at the next line: the rest of the line is skipped, : parts and the ELSE branch too. Other missing operands (A=:PRINT 1) go on behind the :. The JavaScript backend gives Missing operand (22) for these now, not Syntax error. In JavaScript too, RESUME NEXT after A=: or A=1*: goes on behind the : (measured again after the merge of #58 and #61).
  • Done (#61): an ELSE that no IF waits for (a second ELSE, PRINT "a" ELSE ..., ON x GOTO 50 ELSE ..., A=1:ELSE ...) skips the rest of the line on the binary. It is not an error. Before, the interpreter and the JavaScript backend gave Syntax error. The parser keeps such an ELSE as a SyntaxErrorStatementNode with stray_else set, and the statement does nothing. The C backend still refuses the form.
  • Done (#61): WIDTH n,x outside an IF takes the rest of the line, ELSE part included, so RESUME NEXT goes on at the next line. In a THEN clause the ELSE still belongs to the IF; in an ELSE clause a second ELSE is taken too.
  • Done (#61): RESUME NEXT and RESUME after an error in an IF line. The binary keeps the text pointer of the last statement that its main loop started (the statement behind a :, or the start of the line). It does not start the first statement of a THEN or ELSE part: the IF starts it. RESUME NEXT scans from the start of that statement to the next : and goes on there. The binary puts a : before each ELSE. Result:
  • An error in the condition, or in the first statement of a THEN or ELSE part, goes on behind the first statement of the THEN part. This holds when the error was in the ELSE part: IF 0 THEN PRINT "t":PRINT "u" ELSE X=1/0 prints u. With one statement in the THEN part, RESUME NEXT goes on at the next line, because an ELSE skips the rest of the line.
  • An error in a later statement of a part (behind a :) goes on with the next statement of the part (IF 1 THEN PRINT "a":X=1/0:PRINT "b" prints b).
  • WIDTH n,x is the exception. It fails after it ran, and the text pointer is inside it, so RESUME NEXT goes on behind the next : also in the first statement of an ELSE part.
  • RESUME runs the statement again from the same place: the whole IF, condition included, for the condition or the first statement of a part, and the later statement alone otherwise. The interpreter does this in clause_tail_after (src/ast_nodes.py), _note_clause_error and execute_resume. The JavaScript backend does it in _plan_statement_labels. A GOSUB or a jump in the statements that RESUME NEXT runs works. Test: tests/regression/integration/test_resume_next_clauses.py (82 shapes measured on the binary, RESUME and RESUME NEXT).
  • Done: a file number that no OPEN made. PRINT #1,"x", PRINT # USING, WRITE #, INPUT #, LINE INPUT #, GET and PUT are Bad file mode (54) for 0 to 15, Bad file number (52) for 16 to 255 and Illegal function call above 255 or below 0. FIELD, EOF, LOC and LOF are Bad file number for 0 to 255. CLOSE #16 is Bad file number (CLOSE #1 of a file that is not open is no error). INPUT$(1,#1) is Input past end for 0 to 15. EOF and INPUT$ on a file open for output are Bad file mode. Measured with /F:15, because this interpreter has 15 files. The JavaScript compiler gives the same errors (_file_of) and numbers its files 1 to 15 in OPEN; before, it stopped with its own text (File #1 not open). A file statement with FIELD, GET or PUT and no OPEN no longer stops with _ffield is not defined. Test: tests/regression/integration/test_file_number_errors.py.
  • Done: the mode of OPEN can be any string expression (OPEN M$,#1,F$, OPEN CHR$(79),...). Before, a mode that was not a literal was always input. Only the first letter counts. The operands are checked in the order of the binary: mode (a number is Type mismatch, an unknown letter or "" is Bad file mode), file number, name (Type mismatch), File already open, the file (File not found). A record length is read for a random file only; for another mode the file is opened first and the length is a Syntax error, with the file left open. INPUT$(n,#f) past the end of the file is Input past end. The AST node has mode_expr; the C backend stops with Unknown file mode for a mode that is not a literal. The JavaScript compiler gives the same errors and also File already open, Bad file name and File not found (it showed a Node message before). Test: tests/regression/integration/test_open_operands.py.
  • Done: the JavaScript compiler makes a valid name from a BASIC name with a dot (A.B, FNA.B) and from a name that JavaScript or the generated program uses (MAIN, PROCESS, TRUE, ENUM, WINDOW, REQUIRE ...; _mangle_var_name). Before, such a program did not run. Test: tests/regression/integration/test_js_variable_names.py.
  • Done: NULL is a statement and a reserved word. NULL n checks that n is a byte (Illegal function call, Overflow, Type mismatch, Missing operand, as on the binary) and prints no nulls. NULL=2 and X=NULL are Syntax errors. Test: tests/regression/integration/test_null_statement.py.
  • Done: a random file (OPEN "R") used with PRINT #, WRITE #, INPUT # or LINE INPUT # works on the record buffer at a cursor, as on the binary, in the interpreter and the JavaScript backend. A line ends in CR LF, GET and PUT send the cursor back to the front, and a write or a read past the end of the record is FIELD overflow. The binary starts the buffer with whatever was in that memory; here it starts with blanks. Test: tests/regression/integration/test_random_file_stream.py.
  • Done: LOF and LOC count sectors of 128 bytes, as on the binary, in the interpreter and the JavaScript backend. LOF is the size of the file at the OPEN, rounded up. LOC of an input file is the sectors read so far: 1 after the OPEN, then the position rounded up (128 bytes read is 1, 144 is 2). LOC of an output file is the sectors written out, the position rounded down. A line end counts as CR LF, two bytes, as in a CP/M file. Still different: a new file that the program closes empty has LOF 1 on the binary (CP/M keeps one sector) and 0 here. Test: tests/regression/integration/test_loc_sectors.py.
  • Done: a string as a condition is Type mismatch: IF "a" THEN, IF A$ THEN, IF "" THEN ... ELSE, and WHILE "a" (the error comes before the loop is entered, so after RESUME NEXT the WEND is WEND without WHILE, ERR 30). A comparison of strings is a number and is fine. A WEND takes the stack entry that holds its own place (the WHILE stores the place of its WEND), so this is also true inside an outer WHILE. The JavaScript compiler keeps the open WHILE loops (_wopen) for the same reason. Test: tests/regression/integration/test_string_condition.py.
  • Done: FOR I=1 TO "a" and STEP "a" are Type mismatch (13), no loop is made, and the NEXT is NEXT without FOR. In JavaScript each loop entry keeps its own NEXT (nx), so the ELSE NEXT in IF c THEN NEXT ELSE NEXT of an inner loop is NEXT without FOR, as in the interpreter.
  • Done: a call cut short by the end of the line, or a MID$ statement with a fourth argument, still range-checks the number arguments that it read: PRINT MID$("A",0, and MID$(A$,0,2,3)="x" are Illegal function call. The bad statement keeps the calls (partial_calls). A MID$ statement cut off by the end of the line (MID$(A$,0,, MID$(A$,1,300) does the same. Test: tests/regression/integration/test_loop_and_call_error_edges.py.
  • Done (interpreter): a NEXT finds its FOR below a WHILE that is still open and discards the WHILE entry, so in FOR K=1 TO 2:WHILE K:NEXT:WEND the WEND is WEND without WHILE (30). The FOR entry records the stack depth. The JavaScript semantic analyzer still refuses this crossed nesting.
  • Done: DEFINT, DEFSNG, DEFDBL and DEFSTR are run-time statements, as on the binary (src/deftypes.py). They change a table when they run. A name without a suffix is typed from the table each time the program uses it. So a DEFINT that a GOTO jumps over, or that is in an IF that is not taken, does nothing. A DEFINT in a loop types the lines above it on the next pass. After DEFINT A the name A means A%, and A! is the single variable (A, A% and A! are three names). CLEAR and RUN put all letters back to single precision. DIM A(3) and ERASE A take the type when they run. The type of FNA is the type of the letter A when the DEF FN runs; a later DEFINT A makes FNA another function (Undefined user function). The parser still gives each node the suffix that the DEF statements above it in the text imply. Runtime.setup makes the nodes dynamic (a node class with a type_suffix property that reads the table) only when the program has a DEFtype statement, so other programs pay nothing. DEFINT typed at the Ok prompt does not reach RUN; the table that the program leaves is the table at the prompt (make_dynamic on the statement typed there). Test: tests/regression/integration/test_clear_deftypes.py (measured on the binary). The UIs that keep a ProgramManager map (Tk, curses) type a program with no DEFtype statement from that map. The JavaScript and C compilers keep the static view: the types follow the DEFtype statements in the text, so a program that runs CLEAR or a DEFtype out of text order can differ there.
  • Done: DEF FN is known when its statement runs, not at the start of the run: FNA before its DEF FN, or after a CLEAR, is Undefined user function (18). The result has the type of the function: FNA% rounds to an integer (FNA%(3) with X*1.5 is 5), a $ function that gives a number and a number function that gives a string are Type mismatch. The JavaScript backend does the same in a program that has a CLEAR. A compiled program cannot define one FN name twice (semantic error).
  • Done: SWAP of two variables of different types (X% and Y, a string and a number) is Type mismatch and swaps nothing.
  • Done (JavaScript): CLEAR sets every variable to 0 or "", deletes every array (a later use makes it 0..10, a later DIM sizes it), empties the FOR and GOSUB stacks, sends the DATA pointer back, closes the files and forgets the DEF FN functions. It does not reset OPTION BASE or the DEF types (fixed when the program is compiled), and it does not restart RND.
  • Done (C): CLEAR sets every variable to 0 or "", deletes every array, empties the GOSUB stack, sends the DATA pointer back and forgets ON ERROR GOTO. The FOR loops are jumps in the C code, not a stack, so CLEAR does not end them (NEXT after it does not give NEXT without FOR). It does not reset OPTION BASE, the DEF types or DEF FN.
  • Done (interpreter): the second variable of SWAP must exist already. SWAP Y,Y2 with Y2 never used is Illegal function call (5). The first variable is made by its lookup, so SWAP Y9,Y9 and SWAP Y5,Y3 (first unused) are fine, and so are array elements. SWAP in the compilers is not changed.
  • Done (interpreter): FOR F%=1 TO 2.6 rounds the limit as an integer (1 2 3; 40000 is Overflow). DEFINT F does the same.
  • Done (JavaScript, C): a DIM after a CLEAR is not a compile error, as DIM after ERASE was not. The JavaScript backend gives NEXT without FOR for a NEXT that follows a CLEAR inside its loop (a program with a CLEAR checks each NEXT). The C backend does not (see above).
  • A MID$ statement whose value is the target string, as in MID$(A$,3)=A$, copies one byte at a time inside the one string on the binary (HEHEH). The interpreter and the JavaScript backend do the same. When the value is a separate string (LEFT$(A$,5), A$+"", another variable), the copy is from the old value (HEHEL), as on the binary.
  • The MID$ statement needs MID$( with no space. MID$ (A$,2)=X$ is a Syntax error, as on the binary; the function form MID$ (A$,2,2) allows the space. CLEAR with more than three arguments is a Syntax error found before any argument runs, as on the binary.

2. ON ERROR, and what happens around a handler

The common path works - a trapped error reaches the handler with the right ERR and ERL, and RESUME NEXT lands in the right place. The edges do not:

  • Done (#44): ON ERROR GOTO 0 inside a live handler disables the handler and raises the pending error again, reported at the line that first failed.
  • Done (#45): ON ERROR GOTO <line that does not exist> is Undefined line number at the ON ERROR statement, checked when the statement runs.
  • Done (#46): a handler that ends with END ends the program with no error (the binary prints Ok).
  • Done (#47): an error inside a handler is reported with its own message at its own line, and is not trapped. It was a Python AttributeError.
  • Done (#37): a handler that runs off the end of the program is No RESUME in <last line>.
  • The interpreter keeps the error a handler works on in state.pending_error. state.error_info is only a fatal error, so the UIs and the run loops no longer see a handled error as a failure. Only RESUME ends a handler. A GOTO out of a handler does not end it: the binary leaves the next error untrapped, and so do we now.
  • The JavaScript compiler has the same edges (it prints in <line> since #41). After ON ERROR GOTO 0 in a handler it names the line that first failed, and a handler that runs off the end is No RESUME in <last line>, as on the binary. One difference stays: it does not check A$=A at run time. The C backend has ON ERROR since 2026-10-27, with the same edges (docs/dev/C_BACKEND.md).
  • Done: ON ERROR GOSUB is a Syntax error on the binary, at the prompt and (for a stored line) when the line runs, as for any bad statement. The interpreter and the JavaScript compiler do the same.
  • Done (#48): after CONT, a second ^C prints Break in <line> and CONT works again, as on the binary. CONT arms the break handler, as RUN does. A ^C at an INPUT prompt breaks at once, and CONT asks the question again. Tests: tests/regression/ui/test_cli_break.py.
  • Done (#48): CONT after a run-time error runs the same statement again (also in the middle of a line and in a FOR loop). It prints the same error until the cause is fixed at the prompt. CONT after END resumes at the statement after the END. After the program ran off its end, CONT prints only Ok. An edit after the error gives Can't continue. All measured on the binary in a pty. Only the CLI does this. A direct statement that fails after the program stopped on an error prints the bare message, with no in <line> (as the binary does). Tests: tests/regression/integration/test_cont_after_error_and_end.py.
  • Done (#49): python3 mbasic FILE with a line over 255 bytes now prints the bare message and stays at the prompt, also when the first line is the long line and the program is empty. The binary also does this. The binary RUNs a loaded file at once when it is on the command line; we wait for RUN.

3. No Ok prompt - done

Done (#49): the CLI prints Ok once at start-up (in place of Ready), after every direct command, after an error message and after a program stops or breaks. A numbered line and a blank line print no Ok, as on the binary. Tests: tests/regression/ui/test_cli_ok_prompt.py. The curses, Tk and web UIs have their own status areas and did not change.

4. Errors reach the user through print(), not through the I/O handler

The wording is now consistent everywhere - see TESTS_VERIFIED_AGAINST_BINARY.md - but src/interactive.py still writes roughly forty of its messages with a bare print(...) rather than self.io.output(...). Only the CLI uses InteractiveMode, and in the CLI print() and the I/O handler write to the same terminal. Thus the user sees no difference, and the print() calls have no issue. The change to self.io.output() is code cleanup.

The UI backends each carry their own copy of the command handlers - cmd_load, cmd_merge and the rest exist four times over, once per backend, which is why the same message had to be fixed in four places. Collapsing the handlers onto one implementation that reports through the I/O handler is also code cleanup.

RENUM is already collapsed: all backends call ui_helpers.renum_program(). Curses, Tk and the web UI set interactive_mode and define cmd_renum, so RENUM in the immediate line works in all of them.

  • Done (#50): the web UI sets interactive_mode when it starts, and defines cmd_renum, cmd_delete, cmd_load, cmd_save, cmd_merge, cmd_list, cmd_run, cmd_cont, cmd_chain and cmd_system. LOAD, SAVE and MERGE use the session's in-memory files, never the server disk. CHAIN in the immediate line loads the file and runs it. CHAIN inside a running program gives an error: the web UI has no way to restart a running program with a new program (this limit is not part of #50). Tests: tests/regression/ui/test_web_immediate_commands.py.
  • Done (#51): Tk prints nothing after RENUM. A reference to a missing line is still reported. Tests: tests/regression/ui/test_tk_renum_silent.py.

5. Reserved words followed by a type suffix - done (#52)

NAME$, LEN%, FOR$, TO%, ERR, ERL are all syntax errors on the real binary. Done (#52): the lexer now gives the same result. Two of the tests here were written using NAME$ and died at that line on the binary; they have been renamed to NM$.

MBASIC's CRUNCH (bintrp.mac, the tstanm call around line 2504) treats a reserved word as a keyword only when the character after it is not a letter, not a digit and not .. That single rule gives:

NAME$   FOR$   LEN%   TO%   ERR   ERL        Syntax error
TOTAL   NAMES$   FORM   COST   NAME.X       fine - all accepted
FORX=1TO3                                    Syntax error (TO is followed by 3)

So the folklore that a variable name may not contain a reserved word is false for 5.21; what matters is only what follows the word.

Our lexer (read_identifier in src/lexer.py) reads the identifier with maximal munch, and looks the whole string up in KEYWORDS first. MID$ and STR$ work because those entries are stored with the dollar. When the whole string is not a keyword, but the string without its type character is a reserved word, the lexer returns the keyword and leaves the type character in the input. The RESERVED_STEMS set in src/lexer.py holds the reserved words of the CRUNCH table. The words this implementation adds (HELP, SHOW, SETTINGS and so on) are not in the set, so SHOW% is a plain variable. The stray type character is a Syntax error when the statement runs (section 6).

Two traps for the rule, both handled:

  • INPUT$(n) is legal on 5.21 and must keep working. INPUT$ is not in the reserved list at all - INPUT crunches and the $ stays an ordinary character.
  • TIME$ is not reserved in the CP/M build, and LEFT and CHR without the dollar are ordinary variables.

6. Syntax errors are found at the wrong time - done (#53)

The binary crunches a line at entry without a message, stores it, and reports Syntax error in 10 at RUN, when execution reaches the bad statement. The statements before the bad one on the line run first, and RESUME NEXT goes on with the statement behind the next :. After an untrapped error the binary opens the line editor on the line.

Done (#53): the interpreter does the same. A statement that does not parse becomes a SyntaxErrorStatementNode (see ProgramManager.parse_single_line with defer_syntax_errors, and the parser and lexer flags of the same idea). It holds the text of the bad statement, up to the next : or ELSE. Typed lines, LOAD, MERGE, EDIT, AUTO, CHAIN and RENUM (also a bad line that RENUM numbers again) store the line and print nothing. LIST, SAVE and RENUM use the text of the line, so they keep it as typed. When the statement runs it raises error 2, which ON ERROR can trap. After an untrapped error the CLI starts the EDIT command on the line. The EDIT prompt now has the space after the line number that the binary prints (20 PRINT, not 20PRINT). The full-screen and window UIs still refuse a bad line when it is typed in their editors, because they show the error at once. They keep a bad line that LOAD or MERGE reads.

The JavaScript and C backends turn the same statement into a run-time Syntax error (--compile-js, --compile-c). The JavaScript runtime prints ?Syntax error without the line number (#41). A line number that is too large or missing, and an &H or &O overflow, still stop the compilation.

An unterminated string is still a Syntax error here, where the binary accepts PRINT "abc and prints abc. This difference is older than #53.

One case is fixed. WRITE with a , or ; after the last item (WRITE 1;, WRITE #1,2,) is accepted at entry and on LOAD, and LIST and RENUM keep its text. When the line runs, the interpreter and the JavaScript runtime write the items and a , with no end of line, then raise Missing operand (error 22). The same happens in a file. WRITE #1, with no items writes an empty line. WRITE #1 with no comma and no items is a Syntax error (error 2) when it runs. A , or ; before the first item or twice in a row (WRITE ,1, WRITE 1,,2, WRITE ;) is also accepted at entry and on LOAD. When the line runs, the items before the bad separator and a , go out with no end of line (nothing, if no item comes first), then Syntax error (error 2) occurs. The interpreter, the JavaScript runtime and the C backend all do this. The C backend writes the error text to the error stream and stops, as it does for other errors without an ON ERROR handler.

7. The compiler backends are out of step with the interpreter

The precedence fix is in the parser, so every backend gets it. The operator semantics are not:

  • Done: the JavaScript backend rounds the operands of \ and MOD to integers and checks their 16-bit range, as the interpreter does. 7.6 MOD 3 is 2, and 40000 MOD 3 is Overflow (ERR 6).
  • Done (GitHub issues 28 and 29, docs/dev/C_BACKEND.md): the C backend has \ and MOD (operands rounded to integers, as CINT does), / always gives a floating-point result, comparisons give -1, and AND, OR, XOR, EQV, IMP and NOT work bit by bit on 16-bit integers.
  • Done (#54): the C backend rounds a fractional operand of \, MOD, AND, OR, XOR, EQV, IMP and NOT, and an operand outside -32768..32767 after rounding is Overflow. \ and MOD with a zero divisor are Division by zero. -32768 \ -1 is 32768 in the C backend too: a \ that can give it is typed single precision (see docs/dev/C_BACKEND.md).
  • Done: in the C backend a float sum, difference, product or power that is too large, EXP of 87.34 or more and A^B from 2^126 print Overflow and go on with the largest float. EXP below -88.72 is 0. A constant above the range is the largest float. +, - and * of integers do not wrap in PRINT, STR$, a store into a float variable, and a comparison; a store into a % variable and an operand of \ give Overflow. Not done: PRINT USING, a negative DIM size and a second DIM are compile errors (docs/dev/C_BACKEND.md).
  • Done (#55): FOR and NEXT end a loop when the sign of (v - end) equals the sign of the step, in the JavaScript backend (_for_done(v, end, step)) and in the C backend. With STEP 0 the loop ends only when the variable equals the limit. Test: tests/regression/integration/test_js_for_step_zero.py and tests/regression/integration/test_c_backend_ops.py.
  • Done (#56): AND, OR, XOR, EQV, IMP and NOT. The JavaScript backend matches real MBASIC (GitHub issue 15, docs/dev/JS_LOGICAL_OPERATORS.md), and the interpreter now does too: it rounds a fractional operand (NOT 2.7 is -4) and stops with Overflow for an operand outside -32768..32767 (40000 OR 1), which ON ERROR can trap.
  • Done (#57): WHILE and WEND in the JavaScript backend are jumps, as FOR and NEXT are (docs/dev/JS_CONTROL_FLOW.md). A WHILE and its WEND on different lines work. Test: tests/regression/integration/test_js_while_wend.py.
  • Done (GitHub issue 25, docs/dev/JS_BROWSER_YIELD.md): the browser build gives the page time to run in long loops and at INKEY; Node reads INKEY from stdin.
  • Done (GitHub issue 13): NEXT pairs with FOR by position in the text, the loop-back and GOSUB return points are statements in the middle of a line, and NEXT J,I passes the semantic analyzer. See docs/dev/JS_CONTROL_FLOW.md.
  • Done (#59): the JavaScript backend gives Type mismatch at run time for a string mixed with a number, for -"a" and NOT "a", for a wrong kind in an assignment, a condition, a FOR, a built-in function, DEF FN and PRINT USING. Unary + on a string is allowed. The semantic analyzer no longer folds or rewrites such an expression. See docs/dev/JS_TYPE_MISMATCH.md. Done later: DIM B("a") is Type mismatch at run time (see docs/dev/JS_ARRAYS.md). Done: USR(x) is Illegal function call for any argument, as on the binary. The C backend refuses the mixed forms at compile time, on purpose. Test: tests/regression/integration/test_js_type_mismatch.py.
  • Done (#58): in the JavaScript backend, RESUME NEXT goes on at the next statement, and RESUME runs the failing statement again, not the line. See docs/dev/JS_CONTROL_FLOW.md. The interpreter and the JavaScript backend now follow the binary also for an error inside a THEN or ELSE part (see the #61 entry about RESUME NEXT in an IF line).
  • Done (#60): in the C backend, ERASE deletes the array. The next use makes the array again with the bounds 0 to 10 (1 to 10 with OPTION BASE 1), and a DIM can give the array a new size and a new number of subscripts. Measured on the binary:
  • DIM of an array that exists is Duplicate Definition.
  • ERASE of an array that does not exist is Illegal function call. This is true for an array never used, and for an array erased already. The interpreter did nothing in these cases. Now it gives the error too.
  • A use with another number of subscripts than the array has is Subscript out of range.
  • The storage is static, so a loop of DIM and ERASE does not use more memory. See docs/dev/C_BACKEND.md.
  • Not done in the C backend: a second DIM of the same array with no ERASE between is still a compile error. The binary stops at run time with Duplicate Definition. This is a deliberate refusal.
  • Not done in the C backend: a DIM after ERASE with a size that is not a constant (for example DIM A(R*2) in a loop) is a compile error. The binary and the interpreter accept it. The C storage is static, so the C compiler must know the largest size. This is a limit of the design, as for every DIM with a variable size.
  • Done: the interpreter gives Subscript out of range for DIM A(0) with OPTION BASE 1, also when the array exists. The binary checks the bound before it checks for Duplicate Definition. (The C backend refuses such a program at compile time.)
  • Done: the JavaScript backend gives Illegal function call for ERASE of an array that does not exist, Duplicate Definition for a DIM of an array that exists (also after a use, after a jump back, and after CLEAR, which deletes every array), Illegal function call for DIM A(-1), Type mismatch for DIM B("a") and Overflow above 32767. A bound can be any expression, so DIM A(N*2) in a loop with ERASE works. All were measured on the binary, with ERR, ERL and RESUME NEXT, and also with OPTION BASE 1 and string arrays. A DIM that fails leaves the later arrays of its statement unmade. A plain DIM with constant bounds at the start of the program stays a fixed array; the other arrays check their bounds at run time. See docs/dev/JS_ARRAYS.md. Test: tests/regression/integration/test_js_dim_erase_runtime.py.
  • Done: CLEAR in the C backend never fails, also when an array is not yet dimensioned (10 CLEAR 500:20 DIM A(10) is correct).
  • Done: the JavaScript backend keeps the number of subscripts of an erased array at run time. 10 DIM A(3):30 ERASE A:40 DIM A(3,3):60 A(2,2)=5 works, and a use with another number of subscripts is Subscript out of range. Before, the program stopped at line 20 with Subscript out of range. A DIM of an erased array makes the array with the sizes of that DIM. Test: tests/regression/integration/test_erase_dim_rank.py. The C backend gives the same output (checked with uc80 and cpmemu).

8. serialize_expression and a unary minus - done

serialize_expression in src/ui/ui_helpers.py now puts brackets around the operand of a unary minus or NOT when the operand binds looser. -(A + B) and -(A * B) keep their brackets. tests/regression/interpreter/test_parser_forms.py covers the unary cases. RENUM does not call the serializer (it edits the text of each line, see src/renum.py).

9. Statements where we are ahead of 5.21

Each item here is a place where a program that works here does not work on the real thing. The two items that were here (OPEN "A" and MERGE in a running program) are now decisions. See section 10.

10. Deliberate, and staying that way

MBASIC's arithmetic is Microsoft Binary Format and ours is IEEE - a project decision, see src/number_format.py and the note in docs/dev/TESTS_VERIFIED_AGAINST_BINARY.md. These differ and should:

CDBL of a single 3.14      real 3.139999866485596   ours 3.140000104904175
5 ^ 2 = 25                 real false (EXP/LOG gives 24.999998)
VAL("99.99") = 99.99       real false, and by a different amount than ours

The MBF range goes with it: an MBF result between 1.7E38 and the IEEE limit (3.4E38 for single) overflows there and does not here. Only a result that is infinite in the IEEE format of its precision is an Overflow here (section 1). Machine infinity, 1.70141E+38, is nonetheless reproduced where it is observable - a float divide by zero and an overflow yield it, as they do on the binary.

Two statements accept more than 5.21 does. The user decided on 2026-10-26 to keep them as extensions:

  • OPEN "A" opens a sequential file for append. The file is made if it is not there. 5.21 has no append mode and answers Bad file mode. The interpreter, the JavaScript backend and the C backend all accept "A". test_file_io.bas uses OPEN "A", so that test is not binary-verified.
  • MERGE in a running program merges the file and then the program continues at the next statement. On 5.21, MERGE always returns to command level. The summary line that MERGE prints at command level does not show when a running program merges, as on the binary.

11. JavaScript backend, assorted edges - done

Each item was measured on the binary. Test: tests/regression/integration/test_js_misc_edges.py.

  • USR(x) is Illegal function call for every argument (also a string and 70000); USR() and USR(1,2) are Syntax errors.
  • A DEF FN parameter takes the type of its letter in the JavaScript backend: DEFINT I: DEF FNQ(I)=I*2: PRINT FNQ(2.6) is 6, and 40000 is Overflow. A single parameter drops digits. The interpreter did this.
  • FOR checks its start, end and step in that order, each at once: a string is Type mismatch before the next expression runs, and a string loop variable with a number start is Type mismatch. The failed FOR leaves no loop, so the NEXT after a trapped error is NEXT without FOR (error 1). The JavaScript NEXT with no loop is now that error (it went on silently), the JavaScript FOR runs start, end, step in text order, and the interpreter no longer registers a loop when a value is a string.
  • FRCINT (CINT, an integer variable, \, MOD, subscripts, HEX$, OCT$, byte arguments, the logical operators): the binary adds 0.5 in the precision of the number, takes the sum to single (a tie goes to the larger magnitude), drops the fraction of the magnitude, and complements the result of a negative or zero sum. So CINT(-2.5) is -3, CINT(-.5) is -1, CINT(.49999998#) is 1, CINT(-.49999998#) is 0, CINT(32767.49999999#) is Overflow and HEX$(2.49999999#) is 3. to_frcint in src/number_format.py and _frc_int in the JavaScript runtime do this. Before, they took the double to single and then rounded half away from zero, which differs near a half.
  • File statements in the JavaScript backend: the file number is a byte (Overflow, then Illegal function call for 256 or -1), then above 15 is Bad file number. A statement on a number with no open file is Bad file mode for PRINT, WRITE, INPUT, LINE INPUT, GET and PUT, and Bad file number for EOF, LOC, LOF and FIELD. INPUT$ on a number with no open file is Input past end. An output file used for input, GET, PUT or FIELD is Bad file mode; INPUT from a random file is FIELD overflow. CLOSE 0 does nothing, CLOSE of a closed number is fine, CLOSE 1,99 closes 1 and then gives Bad file number. OPEN: the number first (0 is Bad file number), then LEN (FRCINT, above 128 is Illegal function call), then File already open. OPEN "I" of a missing file is File not found. GET and PUT take the record by FRCINT: 0 is Bad record number, below 0 is Illegal function call, above 32767 is Overflow. FIELD widths are bytes and the sum must fit the record (FIELD overflow).
  • FIELD binds its variables to the record at once, GET fills them, LSET and RSET change the variable and the record (before, the compiled variables were never set, so GET then PRINT A$ printed nothing). LSET of a plain string variable keeps its length. The semantic analyzer now declares variables that only FIELD, LSET or RSET use.
  • ASC("") is Illegal function call in the JavaScript backend.
  • The JavaScript compiler sorts the lines of the source by number (and a later line with the same number replaces the earlier one), as MBASIC does when the lines are typed. Before, a source with lines out of order ran in file order.
  • PRINT INPUT$(n[,#f]) compiled to invalid JavaScript (an await in the arrow function that delays a PRINT item). The items before the INPUT$ item now print first, and the item is read in the statement.
  • OPEN reads its arguments in the order of the binary: the mode (a number is Type mismatch; an empty or unknown mode is Bad file mode), the number, LEN, then the name (a number is Type mismatch; an empty name is Bad file name, also in the JavaScript backend, which crashed with the host error). The mode may be a string expression (OPEN M$,1,"F"); only its first letter counts ("Input" opens for input). The parser keeps a literal mode as before and stores any other expression in mode_expr. An I or O file with a fourth operand is opened (File not found comes first, and the file stays open) and then the statement is Syntax error; the length is not read. The interpreter and the JavaScript backend do this.
  • FOR with an integer loop variable takes the start, the limit and the step through FRCINT, in that order: FOR I%=1 TO 2.5 runs to 3, STEP .5 is STEP 1, and a value over 32767 is Overflow at the FOR (the variable keeps its old value). The interpreter and the JavaScript backend did not.
  • A DEF FN result takes the type of the function: FNA% rounds through FRCINT (Overflow over 32767), DEFINT K: DEF FNK(X)=X*1.5 gives 5, and a string body for a number function (or the reverse) is Type mismatch.
  • LOF is the size the file had when it was opened, in sectors of 128 bytes (a new file is 0, and writing does not change the number); the backends gave the length in bytes.
  • FIELD variables are views of the record in the JavaScript backend: a second overlapping FIELD shows what LSET wrote through the first, and MID$(F$,..)= changes the record. A variable that was given another string (A$="plain") is out of the record for good: LSET keeps its length and GET does not fill it. The JavaScript runtime finds this by comparing the variable with the string it was shown last, so A$=A$ after a GET does not detach it, and an assignment of the same text is not seen. A variable that no FIELD, LSET or GET has set holds memory garbage on the binary (D$ of a FIELD that was never read); that is not copied.
  • The JavaScript compiler no longer stops with "Semantic analysis failed" for a NEXT that matches no FOR (NEXT with no loop, NEXT J for another variable, NEXT I,I); these are run time errors (NEXT without FOR) that ON ERROR can trap. SemanticAnalyzer(lenient_next=True); the C compiler still rejects them. After such an error RESUME NEXT goes on at the next statement in the interpreter and in the JavaScript backend; the binary sometimes runs the NEXT a second time when the variable did not match (FOR I=1 TO 2:NEXT J:PRINT I gives E 1, E 1 and 0 there). This is not copied.
  • Not done: the backends allow 15 files, the binary 3 by default (/F:n raises it; cpmemu did not pass the switch on, so the number above 3 was not measured with a bigger limit). Done since: PRINT #n and WRITE #n on a random file write into the record, as on the binary (see above); a number with no open file gives the errors of the binary in the interpreter too (test_file_number_errors.py), and GET and PUT work before FIELD (the record buffer is there from OPEN on); FIELD views are done, see "round 2" below. OPEN "A" is an extension and stays.
  • Done (round 2): the interpreter follows the binary for FIELD views: FIELD gives the variables their part of the record at once, a second overlapping FIELD shows what LSET wrote, MID$(F$,..)= changes the record, a variable that was given another string is out of the record for good (GET does not fill it, LSET keeps its length), LSET and RSET of a plain string variable keep its length, and the variables keep their text after CLOSE. FIELD with a number variable is Type mismatch, after the variables before it. A new record is spaces here (garbage on the binary).
  • Done (round 2): the loop stack. The interpreter and the JavaScript backend keep the FOR loops in the order they were entered, with the NEXT the scan of the FOR ended at (section "FOR and NEXT sites" in JS_CONTROL_FLOW.md). A NEXT at another place is NEXT without FOR and spoils the loop and the loops below it. So after FOR I=1 TO 3:FOR J=1 TO 1/0:NEXT:...:NEXT I with RESUME NEXT, the bare NEXT is error 1 and the next NEXT I too (the outer loop is gone). An Overflow in the NEXT of an integer variable spoils the loop the same way. A NEXT that finds its loop drops the loops entered after it. A subroutine does not see the loops of its caller (NEXT in a subroutine for a loop of the caller is NEXT without FOR), and RETURN drops the loops of the subroutine. A FOR with no NEXT is FOR Without NEXT before the variable is set. The FOR that runs again drops its old loop and the inner loops as soon as the start value is in. Test: tests/regression/integration/test_for_next_sites.py.
  • Done (round 2): a DEF FN is its name and its type. DEF FNA$ is not FNA (Undefined user function, error 18). The type of an unsuffixed name is the DEF type of the letter after FN at the DEF and at the call (DEF FNE(X)=X*2: DEFINT E: PRINT FNE(1.6) is error 18). A parameter without a suffix has the DEF type of its letter when the call runs, not when the DEF FN was read: DEF FNA(X)=X*2: DEFINT X: PRINT FNA(1.6) is 4, and DEFINT X: DEF FND(X)=X*2: DEFSNG X: PRINT FND(1.6) is 3.2. The parser keeps the DEF types of the call in FunctionCallNode.def_types.
  • FIELD NAME$ (a reserved word, so NAME then $) is a Syntax error of the whole statement in the JavaScript backend too. It reported Type mismatch before.
  • Not done: a variable in the body of a DEF FN that gets its type from a DEFxxx after the DEF FN (DEF FNE(X)=X+Y: DEFSTR Y: Y="5": PRINT FNE(1) is Type mismatch on the binary, which reads the type when the call runs). The interpreter types the body variables when the line is read. The JavaScript compiler takes the DEF types of the whole program (DEFINT X after X=1.5 also makes the earlier X an integer). Both come from the design of the typed variables, not from one statement.
  • Done: a FOR enters its NEXT on the binary, also when the loop runs. With another name (FOR I=1 TO 2: PRINT "a": NEXT J) the error NEXT without FOR comes at the FOR, with the line of the NEXT, before the body runs, and I keeps its value. A FOR whose scan ends at a NEXT of an outer loop does the same. The interpreter and the JavaScript backend do this now. RESUME and RESUME NEXT make ERL the current line: until a new line starts, an error on the line they go back to is reported at ERL. This also holds after READ of a bad DATA item, where ERL is the DATA line. Test: tests/regression/integration/test_loop_and_call_error_edges.py.
  • Not done: a spoilt FOR entry also hides the GOSUB frames below it on the binary (the next RETURN is RETURN without GOSUB). The loops and the GOSUB frames are separate here, so only the loops are dropped.

The cause is in NEXT (bimisc.mac): it cuts the stack back to the FOR entry it finds before it checks the text pointer, so the GOSUB frames above that entry go too. To copy it, the interpreter needs one stack for FOR and GOSUB entries. That is a large change for an error path, so it waits. - Done (random PRINT test against the binary, 2026-10-26): a numeric constant is double when more than 7 digits are typed from the first one that is not 0 (1000000.0, 10000000, 123456.70); + of strings in the JavaScript backend gives String too long above 255 characters; the semantic analyzer no longer rewrites X AND -1, X OR 0, X XOR 0 and X * 1# to X (the operators round to 16-bit integers, and 1# widens the result), and its constant folder rounds the operands of the logical operators and gives up on one out of range; VAL gives Overflow above 1.70141E+38 and 0 below 2^-129. Test: tests/regression/integration/test_literal_precision_and_rewrites.py. A second random test of READ and PRINT USING found more, all done: DATA with nothing after it is one empty item; RESTORE n goes to the first item at or after line n in the JavaScript backend too, and a line that is not there is Undefined line number (then Out of DATA); a bad item read into a number variable stores the number it starts with before the Syntax error; a program line keeps its trailing spaces (an unclosed string ends with them); PRINT USING with no field stops before the end of the line. Test: tests/regression/integration/test_data_restore_using.py. A third random test, of the built-in functions, found two more, done: CVI, CVS and CVD read the first 2, 4 or 8 bytes of a longer string (a shorter one is Illegal function call), and an argument that the binary range-checks as it reads it gives its error before a later argument can give Type mismatch (MID$("a",-1,"x") is error 5). Test: tests/regression/integration/test_function_argument_edges.py. A fourth random test, of control flow, found one more, done: a FOR with a double-precision variable (I#, or a DEFDBL letter) is Type mismatch after the start is worked out and before the limit is read, in the interpreter and the JavaScript backend. Test: tests/regression/integration/test_loop_and_call_error_edges.py. TAB( and SPC( are PRINT items only: in any other expression they are a Syntax error, and PRINT TAB(2)+1 is two items. The same random test, run against the C backend, found more, done there: VAL skips blanks inside a number, POS exists, RESTORE n goes to line n, a DATA item +7 compiles, and READ A$ of a number item takes the text as it was typed. A second run against the C backend found three more, done: ON x GOTO and ON x GOSUB round the index (ON 1.5 takes the second line), READ of a text DATA item into a number skips blanks like VAL (DATA 1 2 is 12), and an integer FOR rounds its limit and step (STEP .5 is STEP 1; it was 0, a loop that never ended). Tests: test_function_argument_edges.py, test_c_backend_edges.py, test_c_backend.py. Still different, by design (MBF arithmetic is not emulated): 100^16 prints 9.99999E+31 on the binary, a single product between 1.70141E+38 and 3.4E+38 is Overflow there, and 32768*-32769 prints -1.07377E+09. The random tests are utils/fuzz_real.py (interpreter, JavaScript) and utils/fuzz_real_c.py (C backend). A fifth run, with the JavaScript backend on control flow, found one more, done: NEXT in the interpreter steps a single-precision variable in single precision, so FOR I=1E10 TO 1E10 STEP 3 never ends, as on the binary (1E10+3 is 1E10). Test: test_loop_and_call_error_edges.py. A tie (16777216+1) still rounds to even, not up as MBF does, in NEXT as in +. A run of 100 seeds in each mode found two more, done in the interpreter and the JavaScript backend: AND, OR, XOR, EQV, IMP, \ and MOD make the left operand a 16-bit integer before they read the right one (65535 AND 1/0 is Overflow, "a" AND 1/0 is Type mismatch), and a single gets its six printed digits by FOUT's steps of ten (see NUMBER_FORMATTING.md; the C runtime too). Test: test_operand_order_and_single_print.py.