Releases: avwohl/mbasic
Release list
v1.0.1041
Numbers are printed and computed the way MBASIC 5.21 does
This is the largest change in the release, and it changes the output of nearly
every program that does arithmetic. Read this section before upgrading if you
compare mbasic's output against anything.
PRINT was handing out Python's own formatting:
PRINT 1/3 0.3333333333333333 5.21 prints .333333
PRINT SQR(2) 1.4142135623730951 1.41421
PRINT 0.1+0.2 0.30000000000000004 .3
PRINT 1000000! 1000000 1E+06
K%=7: PRINT K% 7, with no spaces " 7 "
The last one was an outright gap: execute_print formatted only float
values, so an integer-typed value fell through to str() and lost the leading
and trailing spaces MBASIC puts around every number.
src/number_format.py is new, and every rule in it was measured against
com/mbasic.com under cpmemu rather than taken from the manual, which is wrong
about where scaled notation starts: it says 10^-7 prints as 1E-7, and the
binary prints .0000001. How many significant figures a value gets comes from
the type of the expression rather than the size of the number, so A=1/3
prints .333333 and A#=1#/3# prints .3333333333333333 from the same
arithmetic. STR$ goes through the same formatter, so STR$(-2) is now -2
and not -2.0.
Values are also computed and stored in single precision now
(Runtime.set_variable and set_array_element coerce on store, and arithmetic
rounds per operation). Everything was previously an IEEE double, so a
single-precision result carried digits the real machine never had: F# = 1/3
was .3333333333333333 where 5.21 gives .3333333432674408. MBASIC's single
is Microsoft Binary Format, 24 mantissa bits, so a float32 round trip
reproduces it. Two things had to survive parsing first: the lexer discarded a
literal's type suffix, so 1# and 1 both arrived as 1.0 and 1#/3# could
not be typed double, and the parser stripped the suffix from a DEF FN name,
so FNB# looked single. Both are kept now.
Three further faults surfaced while measuring:
- Float-to-integer conversion did not round to nearest, halves away from zero,
the way MBASIC does. Most sites truncated:A% = 3.7was3,
LEFT$("ABCDEF",2.7)gaveAB, andA(2.7)read the wrong array element.
CINTwas Python'sround(), which is banker's rounding, soCINT(2.5)
came out2and not3. FOR I=1 TO 3: NEXT IleftIat 3. On the real machine the loop variable
ends one step past the limit, at 4.- Mixing a string and a number in an assignment did the wrong thing in both
directions:A$ = 5stored the number and said nothing, andA = "X"died
with?ValueError in 10: could not convert string to float: 'X'. Both are
Type mismatchnow.
SQR, SIN, COS, TAN, ATN, LOG and EXP were single by signature,
the way 5.21's are, so a double argument had its extra bits thrown away before
the function ran. They follow their argument now: A# = ATN(1#)*4 gives
3.141592653589793 where the binary gives 3.141592979431152. This is a
deliberate divergence, added to the standing decision to compute in IEEE rather
than MBF — the arithmetic here is native binary32 and binary64 and does not
reproduce MBF anyway, so narrowing a declared double to 24 bits is a lost bit
rather than fidelity. A single argument is untouched, and SQR(2) is still
1.414213538169861, exactly what the binary prints.
basic/business/log10k.bas is the one shipped program whose output moves
because of this.
PRINT USING was wrong in a long list of ways, found by cross-checking against
the binary. A plain field printed negative numbers as positive — USING "###.##"; -3.14 gave 3.14, because the sign was computed and then never
appended — and the overflow path gave %123.46 where the binary says
%-123.46. The value now goes through the same six- or
sixteen-significant-figure conversion PRINT uses before the field rounds it,
the field's own rounding is half away from zero, a sign consumes a digit
position, a leading zero is printed only when there is room for it, a ^^^^
mantissa takes its width from the field, the format string is reused until the
value list runs out, a comma delimits that list as readily as a semicolon
(PRINT USING "###"; 1, 2 was a syntax error and prints 1 2 now), a
trailing ; or , suppresses the newline, and punctuation with no digit
positions after it is literal text. 118 forms were probed against the binary
and 118 agree.
RND now reproduces MBASIC 5.21's sequence
RND was Python's random.random() seeded from the clock. It was not
MBASIC's sequence, and it was different on every run where MBASIC's is the same
on every run. A 1979 game dealt a different hand here than on the real machine,
and a different one each time from itself.
The routine at 0x37DD in com/mbasic.com is reproduced in src/mbasic_rnd.py
— it is not the RND from the 6502 Microsoft BASICs, and their published
constants do not appear in this image. Verified against the binary under
cpmemu: 200 consecutive values, RND(0) repeating, RND(x>0) ignoring its
magnitude, RND(-1) equal to RND(-2) but not to RND(-1000), RANDOMIZE
five ways, and RUN/CLEAR restarting the sequence. basic/games/poetry.bas
now produces byte-identical output.
Bare RANDOMIZE prompts for a seed like the real one instead of reading the
clock. RANDOMIZE n overwrites only the middle two bytes of the seed, so
RANDOMIZE 1 twice in one run gives two different numbers — which is why
test_randomize.bas prints FAIL on the real machine too, and its expected
output was corrected rather than the interpreter.
PEEK still returns a random byte and POKE is still accepted and discarded.
There is no memory model in this interpreter, that is now recorded as a
decision in docs/dev/NO_MEMORY_MODEL.md rather than left looking like a stub,
and both docstrings say so.
Errors are reported in MBASIC's words, in every UI
Errors were printed as ?PythonExceptionName in N: python text, with an echo
of the source line. MBASIC prints the message and in <line> and nothing
else, and stops. Each backend also built its own string out of the Python
exception, so one failure read four different ways depending on which UI you
were in; the rendering happens once now, in ErrorInfo.message() and
message_for() on top of src/error_codes.py.
Measured against 5.21 under cpmemu across 25 provocations, and fixed where they
differed:
- A failed
CHAINorMERGEreported by printing, which left the PC alone and
let the program run on. It stops now. - A floating-point divide by zero is not an error on 5.21 at all. It prints
Division by zero, substitutes machine infinity with the dividend's sign,
and carries on. Integer division andMODby zero remain fatal, as they are
there. ERRwas 5 for a dozen errors that have codes of their own.ERROR 21is
error 21 now and printsUnprintable error in 10, which is what the binary
calls a code with no message of its own, where it used to print
?RuntimeError in 10: ERROR 21.- Six conditions were never detected:
A$ = 5,C% = 40000, a duplicate
DIM,MID$("A",0), andFORandWHILEwithout their terminators. GOTO 9999reported?RuntimeError in 9999: Invalid PC: PC(9999.0), at the
line that does not exist. It isUndefined line number in 10now, at the
line doing the jumping.SAVE "",LOAD "",MERGE ""andCHAIN ""areBad file name, not
Syntax error; error 64 had never been reachable.Missing operandwas chosen by testing forEOFanywhere in the parse
error, which caughtPRINT EOF,PRINT (1,X = EOF(1and
PRINT LEFT$("a"— cases whereEOFis the token a bracket was wanted
before, or the name of theEOFfunction. The split now falls on exactly one
parser message.- The curses UI leaked the raw
OSErrorwhenLOADwas given a missing file,
and printed a Python traceback for ordinary BASIC errors while single
stepping. - A bad line in a
LOADed orMERGEd file was announced twice, as
?Parse error at line 20: Syntax error in 20: column 4: ..., because four
callers wrapped a prefix around a message that already had one. The prefix
belongs to whoever builds the message now.
Alongside: TRON writes [nnn] with no newline and only on entering a line;
MOD and \ truncate toward zero and CINT their operands; \ and MOD
have precedence levels of their own rather than sitting with * and /; and
FOR I = 10 TO 1 does not run its body.
ERASE had been erasing nothing, twice over — it looked up the raw name while
DIM stores the resolved one (a!, a%), and the DEF type map was not
consulted. DIM A(3) twice used to re-dimension silently, throwing away
everything the array held; it is Duplicate Definition now, which is what made
the ERASE bug visible.
The expected outputs in basic/dev/tests_with_results/ had been captured from
this interpreter's own output, so they pinned its behaviour rather than
MBASIC's, and eighteen of them had gone stale. They are captured from
com/mbasic.com under cpmemu now, and utils/crosscheck_tests.py re-runs the
comparison. 34 of the 39 match the binary character for character; the other
five are the MBF/IEEE divergence and OPEN "A", which 5.21 has not got.
INKEY$ and INPUT$ could not read the keyboard
INKEY$ on POSIX did not merely lose keystrokes, it hung the interpreter.
tty.setraw() defaults to TCSAFLUSH, which discards input that has arrived
but not been read — precisely the keystroke select() had just reported — and
sys.stdin.read(1) then blocked forever waiting for a character that no longer
existed. Measured under a pty with ICANON off, any keypress at all, arrow or
plain letter, never returned. Fixed with TCSANOW and os.read() decoded
latin-1. An arrow key now arrives as the...
v1.0.1005 - Remove duplicate VarType, clearer compile errors
Maintenance release
No change to how programs run or compile. This release removes a latent fault in the source and improves one error message.
Removed a duplicate type enumeration
Two separate enumerations named VarType existed, one in the AST module and one in the semantic analyzer, with the same four members. Both were in use: the first backs the parser's DEF type map, the second backed variable records and every type comparison in both compiler backends.
Members of two different enumerations are never equal, so which one a module saw depended entirely on whether its explicit import happened to follow a wildcard import. It did, everywhere, so the code was correct — by ordering, not by design. Swapping two adjacent import lines would have silently made every type comparison false.
That is not hypothetical: it is exactly the failure that broke the Z80/8080 compiler (fixed in 1.0.1002) and then the JavaScript compiler (fixed in 1.0.1003). There is now a single definition, so the third occurrence cannot happen.
Verified as a pure refactor: the C and JavaScript generated for all 541 bundled example programs is byte-identical before and after, and the test suites are unchanged.
Clearer --compile-c failure output
When z88dk fails, --compile-c still exits non-zero — no .com file is produced, so reporting success would be wrong. But the output was confusing in two ways:
Generated C: <file>could appear after the failure message, because standard output is buffered when redirected while errors are not. It looked as though the C file had never been written.- The failure was reported as z88dk's raw output, which can read as nonsense. It says
file 'x.c' not foundfor a file that plainly exists, when it cannot reach the directory — the snap-packaged z88dk cannot read/tmpor hidden directories.
The failure now states that the .com was not created, gives the path of the C source that was generated, and then quotes z88dk:
Generated C: prog.c
z88dk compilation failed - prog.com was not created.
The generated C source is at prog.c.
z88dk reported:
file 'prog.c' not found
Note on version numbering
There is no 1.0.1004 on PyPI. The version is incremented per commit, and both commits in this release landed before it was published, so the published sequence runs 1.0.1003 to 1.0.1005.
Upgrading
pip install --upgrade mbasicv1.0.1003 - JavaScript compiler correctness
JavaScript compiler produced incorrect code for most programs
--js was generating subtly wrong JavaScript for 235 of the 291 bundled example programs that compile (81%). Three separate faults, all fixed by this release:
String variables were initialised to the number 0 instead of an empty string.
10 PRINT "[";A$;"]"
20 A$ = A$ + "X"
30 PRINT "[";A$;"]"The generated JavaScript printed [0] then [0X]. The interpreter prints [] then [X]. It now matches.
Single-precision rounding was missing in two places. FOR loop counters carried no per-iteration coercion (coerce: null), and numeric INPUT results were not converted, so neither rounded the way MBASIC does. Both now apply the single-precision conversion. Plain assignments were already correct.
INPUT into a string variable called the numeric input helper, so text entered at the prompt was run through numeric parsing. String input now uses the string helper.
Cause
compile_to_javascript imported the lexer, parser and semantic analyzer under their flat module names while the code generator imported them as src.*. Python treats those as separate modules with separate class objects, so every internal type comparison in the JavaScript backend silently evaluated false. This is the same fault that was fixed for the Z80/8080 compiler in 1.0.1002; it was still present on the JavaScript path.
Scope
Verified by generating JavaScript for all 541 bundled programs before and after: the same 291 programs compile, 235 produce different output, none newly fail and none newly succeed. Sampling 30 of the changed programs and classifying every added line found 27 string-input corrections, 198 added coercions and 113 string initialisations, with no case of a numeric variable being given string handling.
The interpreter and the Z80/8080 compiler are unaffected by this release: a recursive comparison of the installed 1.0.1002 and 1.0.1003 packages differs in exactly two files, the import change and the version string.
Note that the generated JavaScript was verified by inspecting the emitted source and comparing it against interpreter output; it was not executed, as this project's CI does not run Node.
Upgrading
pip install --upgrade mbasicIf you have generated JavaScript from a previous release, regenerate it.
v1.0.1002 - INPUT fix and working Z80 compiler
Interpreter fix: INPUT discarded your value under DEFINT / DEFSTR / DEFDBL / DEFSNG
If a program used a DEF statement, INPUT wrote into a different variable than the rest of the program read, so the value you typed was silently thrown away:
10 DEFINT A-Z
20 INPUT J
30 PRINT JEntering 7 printed 0. It now prints 7.
This affected far more than simple numeric input. All of the following were broken and are fixed:
- multi-variable input —
DEFINT A-Z : INPUT J,Kwith3,4printed00, now prints3 4 - array targets —
DEFINT A-Z : DIM A(5) : INPUT A(2)with9printed0, now prints9 - file input —
INPUT #1,Nfrom a file containing42printedN=0, now printsN=42 LINE INPUTunderDEFSTRreturned an empty stringDEFSTR S : INPUT SwithHIprinted nothing, now printsHIDEFDBL D : INPUT Dwith1.2345678901234printed0, now prints the full value
Real programs were unusable because of this. The bundled hanoi.bas rejected every valid disk count, looping on INVALID TOTAL NUMBER OF DISKS -- REENTER; it now accepts input and runs.
Cause: split_name_and_suffix() in the parser did not consult the DEF type map, so INPUT J built an unsuffixed variable while PRINT J built a suffixed one, and the two referred to different variables.
Programs without a DEF statement are unaffected. READ, FOR, NEXT and SWAP are unchanged. A 134-program differential run against 1.0.998 found no real output differences.
Z80/8080 compiler (--compile-c)
--compile-c was substantially broken; several independent faults are fixed. On 1.0.998 even 10 PRINT "HI" : 20 X = 1.5 : 30 PRINT X failed with Unknown symbol: x and produced no binary. Of 40 sampled bundled programs, 0 produced a working CP/M .com on 1.0.998; 12 do now.
- No variable declarations were emitted at all. The compiler loaded its type classes twice under two module names, so every internal type comparison silently returned false. String variables were treated as numeric for the same reason.
- Every string variable collapsed onto string ID 0, so any program with two or more strings compiled into one that aliased them.
- Array subscripts were silently dropped for implicitly typed arrays:
A(I) = I * 2compiled toa = .... - Array indices are now cast to
int. A floating-point index previously read element 0 rather than the intended element. - A
NameErroraborted compilation for any program reading an array element inside aFORloop — 101 of the 535 bundled programs. - Numeric
INPUTcould not be linked. The generated C usedscanffloat conversion, which cannot link against the--math-mbf32float pack; it now usesatof. - Float programs could not be linked on the z80 target, which was not passing the maths flag the 8080 target passes (
undefined symbol: init_floatpack). - The
.comfile was never written under the name reported, so--runsilently found nothing. - Variable names containing a dot (
REC.NUM,OLD.FILE) produced invalid C identifiers. --compile-c out.cproducedout.c.c; a trailing.cis now treated as the base name.- A missing z88dk reported
File not found: <program>.bas, blaming a source file that exists. It now says z88dk is missing and points at the generated C.
JavaScript compiler (--js)
The NameError fix above applies here too: successful compilation across the 535 bundled programs rises from 171 to 256, with no program that previously compiled now failing.
Test suite
Five curses UI tests were repaired; four had been reporting success while verifying nothing — two spawned a file that has never existed, so "the process exited cleanly" was true for the wrong reason. Three files that only print manual instructions moved to tests/manual/, and the regression runner gained a skip state, so a test that cannot run because an optional dependency is absent is no longer counted as a pass.
Upgrading
pip install --upgrade mbasicNo configuration or program changes are required.
v1.0.998 - Packaging metadata and test suite
Maintenance release — no functional changes
If you are already on 1.0.996 there is nothing here that changes how MBASIC behaves. This release contains packaging metadata and test-suite work only. The user-visible fixes (the broken mbasic command, the curses ^N crash, the missing web extra) all shipped in v1.0.996.
Packaging metadata
Migrated to PEP 639 SPDX license metadata. setuptools had been warning that both project.license as a TOML table and License :: classifiers are deprecated and stop working after 2027-02-18.
license = "GPL-3.0-or-later"as an SPDX expression, pluslicense-files- Dropped the now-redundant
License :: OSI Approved ::classifier - The LICENSE file now ships correctly in
dist-info/licenses/ - Requires
setuptools>=77.0to build (build-time only — the wheel is pure Python)
The build now emits zero deprecation warnings.
Note for anyone installing from the source distribution on Python 3.8: setuptools has required Python 3.9+ since 75.8.2, so an sdist build on 3.8 can no longer resolve a usable setuptools. Installing the wheel on 3.8 is unaffected, since it needs no build step.
Test suite
Fixed five curses UI tests, four of which were reporting success while verifying nothing:
- Two spawned
python3 mbasic.py, a file that has never existed. The child died instantly, so the "process exited cleanly" assertion passed — they would have passed against any nonexistent command. Both now assert the UI is genuinely running before sending any key. - One additionally imported constants that do not exist, and never called
sys.exit(1), so it could only ever report success. - Three were manual instructions for a human with no assertions at all; they moved to
tests/manual/so the automated runner stops counting them as passes.
The regression runner also gained a skip concept (exit code 2), so a test that cannot run because an optional dependency such as urwid is absent is reported as skipped rather than passed.
v1.0.996 - Fix broken PyPI install
Fixes a completely broken 1.0.995 install
If you installed 1.0.995 from PyPI, it could not run at all. Please upgrade:
pip install --upgrade mbasicThanks to @rolandkirsche for two precise bug reports (#1, #2) that found and diagnosed this.
The mbasic command did not work (#1)
pip install mbasic produced a console script that failed immediately with ModuleNotFoundError: No module named 'mbasic'. The packaging config pointed at mbasic.py, but the entry-point script is the extensionless file mbasic, so setuptools warned non-fatally and shipped no module at all.
Three further problems surfaced once the wheel could actually run:
- The keybinding JSON files were never shipped, and the curses import was guarded by
except ImportError, which cannot catch the resultingFileNotFoundError. Once urwid was installed, every UI including--ui clidied at import. - The in-UI help browser content and the
--compile-cC runtime were never shipped. - The source distribution contained no entry point either, so
pip install --no-binarycould not work.
Curses UI crashed on ^N (#2)
^N (New) and File→New raised AttributeError in any session where the Settings dialog had never been opened. The same latent bug affected ^Y (Insert Line) on the first program line. Both are fixed.
Better behaviour on partial installs
MBASIC installs zero dependencies by design, so an optional UI being absent is a normal state rather than an error:
- Running
mbasicwith no--uinow starts a UI that is actually usable and explains the choice, instead of failing. - An explicit
--ui curses/--ui webstill fails loudly, with correct installation instructions. --ui webno longer bypasses that handling and dumps a rawniceguitraceback.- A full-screen UI with redirected input/output now reports the problem instead of failing inside urwid's event loop.
Extras
- Added the missing
webextra. Code already told users to runpip install "mbasic[web]", but no such extra existed, so pip silently installed nothing and reported success. mbasic[all]now genuinely covers every UI backend (previously urwid only).
pip install mbasic # CLI, no dependencies
pip install "mbasic[curses]" # full-screen terminal UI
pip install "mbasic[web]" # browser-based UI
pip install "mbasic[all]" # everythingAlso
- Three curses menu items dispatched to methods that did not exist and silently did nothing (Edit→Insert Line, Edit→Renumber, Run→Step Statement).
- The publish workflow now installs and runs the built wheel before publishing, so a release this broken cannot ship again.