Skip to content

Releases: avwohl/mbasic

v1.0.1041

Choose a tag to compare

@avwohl avwohl released this 20 Aug 13:48

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.7 was 3,
    LEFT$("ABCDEF",2.7) gave AB, and A(2.7) read the wrong array element.
    CINT was Python's round(), which is banker's rounding, so CINT(2.5)
    came out 2 and not 3.
  • FOR I=1 TO 3: NEXT I left I at 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$ = 5 stored the number and said nothing, and A = "X" died
    with ?ValueError in 10: could not convert string to float: 'X'. Both are
    Type mismatch now.

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 CHAIN or MERGE reported 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 and MOD by zero remain fatal, as they are
    there.
  • ERR was 5 for a dozen errors that have codes of their own. ERROR 21 is
    error 21 now and prints Unprintable 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), and FOR and WHILE without their terminators.
  • GOTO 9999 reported ?RuntimeError in 9999: Invalid PC: PC(9999.0), at the
    line that does not exist. It is Undefined line number in 10 now, at the
    line doing the jumping.
  • SAVE "", LOAD "", MERGE "" and CHAIN "" are Bad file name, not
    Syntax error; error 64 had never been reachable.
  • Missing operand was chosen by testing for EOF anywhere in the parse
    error, which caught PRINT EOF, PRINT (1, X = EOF(1 and
    PRINT LEFT$("a" — cases where EOF is the token a bracket was wanted
    before, or the name of the EOF function. The split now falls on exactly one
    parser message.
  • The curses UI leaked the raw OSError when LOAD was given a missing file,
    and printed a Python traceback for ordinary BASIC errors while single
    stepping.
  • A bad line in a LOADed or MERGEd 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...

Read more

v1.0.1005 - Remove duplicate VarType, clearer compile errors

Choose a tag to compare

@avwohl avwohl released this 02 Aug 13:24

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 found for a file that plainly exists, when it cannot reach the directory — the snap-packaged z88dk cannot read /tmp or 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 mbasic

v1.0.1003 - JavaScript compiler correctness

Choose a tag to compare

@avwohl avwohl released this 02 Aug 11:49

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 mbasic

If you have generated JavaScript from a previous release, regenerate it.

v1.0.1002 - INPUT fix and working Z80 compiler

Choose a tag to compare

@avwohl avwohl released this 02 Aug 10:31

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 J

Entering 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,K with 3,4 printed 00, now prints 3 4
  • array targets — DEFINT A-Z : DIM A(5) : INPUT A(2) with 9 printed 0, now prints 9
  • file input — INPUT #1,N from a file containing 42 printed N=0, now prints N=42
  • LINE INPUT under DEFSTR returned an empty string
  • DEFSTR S : INPUT S with HI printed nothing, now prints HI
  • DEFDBL D : INPUT D with 1.2345678901234 printed 0, 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 * 2 compiled to a = ....
  • Array indices are now cast to int. A floating-point index previously read element 0 rather than the intended element.
  • A NameError aborted compilation for any program reading an array element inside a FOR loop — 101 of the 535 bundled programs.
  • Numeric INPUT could not be linked. The generated C used scanf float conversion, which cannot link against the --math-mbf32 float pack; it now uses atof.
  • 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 .com file was never written under the name reported, so --run silently found nothing.
  • Variable names containing a dot (REC.NUM, OLD.FILE) produced invalid C identifiers.
  • --compile-c out.c produced out.c.c; a trailing .c is 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 mbasic

No configuration or program changes are required.

v1.0.998 - Packaging metadata and test suite

Choose a tag to compare

@avwohl avwohl released this 02 Aug 07:35

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, plus license-files
  • Dropped the now-redundant License :: OSI Approved :: classifier
  • The LICENSE file now ships correctly in dist-info/licenses/
  • Requires setuptools>=77.0 to 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

Choose a tag to compare

@avwohl avwohl released this 02 Aug 03:43

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 mbasic

Thanks 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 resulting FileNotFoundError. Once urwid was installed, every UI including --ui cli died at import.
  • The in-UI help browser content and the --compile-c C runtime were never shipped.
  • The source distribution contained no entry point either, so pip install --no-binary could 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 mbasic with no --ui now starts a UI that is actually usable and explains the choice, instead of failing.
  • An explicit --ui curses / --ui web still fails loudly, with correct installation instructions.
  • --ui web no longer bypasses that handling and dumps a raw nicegui traceback.
  • A full-screen UI with redirected input/output now reports the problem instead of failing inside urwid's event loop.

Extras

  • Added the missing web extra. Code already told users to run pip 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]"       # everything

Also

  • 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.