Minz retro-compiler

Minz retro-compiler
Minz logo.

Recently I've been on a mission to find relatively modern languages that compile to z80 (and bonus marks for compiling on a z80), my most promising candidate so far being Cowgol. While trawling through github, I discovered Minz ("mints") which describes itself as:

Modern Programming Language for Vintage Hardware. Write modern code. Run it on Z80, eZ80, 6502, and more.

Two things that immediately stood out:

  1. it has a z80 backend that is a first class target - meaning that the design of the whole toolchain takes the z80's requirements into account. No more kludgy LLVM backend hacks!
  2. the primary language - nanz - is basically Rust-light. It has very nice syntax, and most of the modern affordances we need to feel comfortable in our little software engineering bubble.

Frontends

These are the supported frontend languages:

  • Nanz (Rust-like, the primary showcase language, very exciting)
  • Minz (the previous primary showcase language, now deprecated)
  • Lanz (a low-level S-Expression language)
  • Lizp (hell yeah! who doesn't love a Lisp?)
  • PLM/M-80 (yes, that PLM)
  • Pascal (missed an obvious "Pazcal" opportunity here)
  • C89 (with bits of later Cs all the way up to C23)
  • ABAP (some sort of god-awful more-Java-than-Java business programming language)
  • Frill (it's an ML, but it's not mentioned in most of the docs. ML! On a Z80!)
  • ObjC (not mentioned in most of the docs, and meh who cares)

Cross language imports are actively encouraged.

Backends

The available backends are:

  • CUDA
  • OpenCL
  • Vulkan
  • Metal
  • z80
  • 6502 (recently added)

Minz has platform targets (stdlib and builtins support) for the ZX Spectrum, the Agon2 Light, generic CP/M, and MSX.

There are some enticing looking screenshots:

2KB SQL database on Z80 hardware

Obviously, an extremely minimal subset of SQL, but just seeing SELECT * FROM mara in that font gives me shivers.

Toolchain

  • mz - the compiler
  • mza - table driven z80 assembler
  • mze - headless z80 emulator, a la iz-cpm
  • mzx - T-state accurate ZX Spectrum emulator (GUI)
  • mzd - a tracing z80 disassembler
  • mzrun - remote runner for ZDRP
  • mzv - MIR VM runner (breakpoints, tracing)
  • mzlsp - LSP server (diagnostics, hover, goto-def, completion)

Libraries

The standard library is "variable quality":

Performance

Some claimed code-size numbers:

I couldn't find any equivalent performance figures.

Nanz language features

Feast your eyes on this little lot:

  • multiple returns from functions, lambdas
  • operator overloading
  • structs, methods, impl blocks
  • statically typed variables, casts, u8, u16, u24, u32, i8, i16, i24, i32, f8.8, f16.8, f8.16, f16.16, bool, void (return only)
  • global variables (can be parked at specific addresses)
  • control: if, while, for...in, switch, break, continue
  • pointers (typed and untyped) and arrays (bounds checking & uninit'd ptr checks)
  • interfaces and Uniform Function Call Syntax
  • inline assembly
  • iterator chains, pipe operator, named iterator pipelines
  • enums, ADTs, match operator
  • type aliases
  • module system
  • C and Pascal style strings, Ruby style string interpolation
  • metaprogramming
  • cross-language imports
  • universal compile time assertions

It's a veritable candy store!

Documentation

There is a tonne of it, which is both good and bad. It's not particularly well organised, and it's often difficult to find stuff. A lot of of it is out of date (and refers to minz, previous backends etc). A lot of it is plain wrong, as we'll see. Some of it is in Russian. In general, it is very Claudy, speaking of which:

AI trigger warning

It's heavily LLM assisted, but in a spec-driven development way rather than a vibe-coded way. The tell-tale is the large number of content-rich context-friendly documents. On the plus side, this makes a local repo copy easily searchable by Claude. If you're allergic to AI then this project is not going to be good for your blood pressure.

Installing it

OK, enough foreplay. Let's download it and give it a go. After cloning the repo and entering the working copy, we can:

cd minzc
make all

and it builds super cleanly:

If you don't have a Go toolchain installed, it will download one. We can run the test suite with:

make test-all

Here the result isn't so clean. Half the tests are failing, and the test-suite hangs.

The README recommends that your first compile be:

# Test an example end-to-end
./mz ../examples/hello_print.minz -o /tmp/hello.a80
./mza /tmp/hello.a80 -o /tmp/hello.tap
./mze /tmp/hello.tap

but the problem is that this compiles a minz file and the minz compiler is deprecated. Unfortunately, most of the examples and the majority of the documentation refer to outdated minz files, but there is an ../example/nanz folder where we can look for something suitable.

I picked 01_sum_array.nanz as it's one of the smaller examples. Let's have a look at it:

First Nanz example

Pretty straight forward: make an array with 5 elements, call a function to sum them, and print the result. Let's try to compile it:

./mz ../examples/nanz/01_sum_array.nanz -o /tmp/sum.a80

Here's the result:

Oh dear. That array initialisation is all kinds of broken: this will leave our array with [5, 0, 0, 0, 0]. Also the sum = 0 is a byte-counted prefix string, but the print.str loop runs until it hits a NUL terminator. So it prints the 0x6 byte first, and only stops when it hits a zero somewhere in memory. Looks like @mir.io.print.u8 got inlined, but it does an OUT (0x23), A instead of emitting the digits that represent the value in A.

On the plus side, it's reasonably compact (for a compiler) and I really like how each function is annotated with its inputs, outputs and clobbers.

We know this won't work, but let's try executing it anway, for the lolz:

./mza /tmp/sum.a80 -o /tmp/sum.tap
./mze /tmp/sum.tap

The results are even weirder than expected:

Eh?

Turns out mze doesn't support TAP files:

Better, but...

So closer to what we expected, but still very wrong. Also --console-io doesn't seem to make any difference!

Let's see if we can produce a broken binary that will nevertheless run on the MicroBeast! We'll try:

./mz ../examples/nanz/01_sum_array.nanz -t cpm -o /tmp/sumcpm.a80
./mza /tmp/sumcpm.a80 -t cpm -o /tmp/sumcpm.com

We can try out the included disassembler to look at the output:

Nice. It's the same bogus stuff as before, but with new shonkiness. Despite us using -t cpm at every juncture, you'll note that the output is still done via OUT (0x23), A (an mze specific console IO port), and it hasn't swapped it out for the cp/m equivalent. The Nanz manual says that you can provide a target-specific version of @print(), but as far as I can tell that's cobblers: builtins can't be over-ridden, and the z80 code generator has no switch on the platform target at all. The previous version used to, but it seems to have got lost.

What we can do though, is leverage the inline assembler to write our own console IO routines (actually cribbed from stdlib/tui/render.nanz because it has a correct decimal printer).

🤪
You're probably thinking "why don't you use the included CP/M BDOS stdlib routines?". Well, I'm not using them because they don't work. Don't even compile, in fact.

Here's what I came up with:

// Nanz — 01_sum_array.nanz for CP/M.
// Same logic, but output goes through BDOS instead of @print, which is
// hardwired to the emulator console port (OUT ($23), A).
//
//   mz 01_sum_array_cpm.nanz -t cpm -o 01_sum_array_cpm.a80

global arr: [u8; 5]

// BDOS function 2 (console output): character in E.
// BDOS preserves nothing, so every helper declares (clob all).
fun bdos_putc(ch: u8) -> void {
    asm z80 (in ch) (clob all) {
        LD E, A
        LD C, 2
        CALL 5
    }
}

// Print a string literal. Literals are emitted with a u8 length prefix
// (DB n, "..."), not a NUL terminator.
fun bdos_puts(s: ^u8) -> void {
    asm z80 (in s) (clob all) {
        LD A, (HL)
        OR A
        JR Z, .done
        LD B, A
        .next: INC HL
        LD E, (HL)
        PUSH BC
        PUSH HL
        LD C, 2
        CALL 5
        POP HL
        POP BC
        DJNZ .next
        .done:
    }
}

// Print u8 as decimal, no leading zeros on the hundreds digit.
fun bdos_print_u8(n: u8) -> void {
    asm z80 (in n) (clob all) {
        LD C, 0
        .hloop: CP 100 / JR C, .hdone / SUB 100 / INC C / JR .hloop
        .hdone:
        LD B, A
        LD A, C / OR A / JR Z, .skip_h
        ADD A, 48 / LD E, A / PUSH BC / LD C, 2 / CALL 5 / POP BC
        .skip_h:
        LD A, B / LD C, 0
        .tloop: CP 10 / JR C, .tdone / SUB 10 / INC C / JR .tloop
        .tdone:
        LD B, A
        LD A, C / ADD A, 48 / LD E, A / PUSH BC / LD C, 2 / CALL 5 / POP BC
        LD A, B / ADD A, 48 / LD E, A / LD C, 2 / CALL 5
    }
}

// CR LF
fun bdos_newline() -> void {
    asm z80 (clob all) {
        LD E, 13 / LD C, 2 / CALL 5
        LD E, 10 / LD C, 2 / CALL 5
    }
}

fun sum_array(ptr: ^u8, n: u8) -> u8 {
    var s: u8 = 0
    var i: u8 = 0
    while i < n {
        s = s + ptr[i]
        i = i + 1
    }
    return s
}

fun main() -> void {
    arr[0] = 1
    arr[1] = 2
    arr[2] = 3
    arr[3] = 4
    arr[4] = 5
    var result: u8 = sum_array(&arr, 5)
    bdos_puts("sum = ")
    bdos_print_u8(result)
    bdos_newline()
}

Look at those asm() calls - the way it annotates the input and output registers is gorgeous. Also a big fan of the (clob all) annotation which tells it that BDOS isn't going to preserve any registers, so they need to be saved at the call site.

Let's build it:


./mz ../examples/nanz/01_sum_array_cpm.nanz -t cpm -o /tmp/sum_cpm.a80
./mza -t cpm /tmp/sum_cpm.a80 -o /tmp/sum_cpm.com
./mze -t cpm /tmp/sum_cpm.com
Confidently asserting nonsense

Splendid! Now for the acid test, let's run it on the 'Beast (a NanoBeast this time, since you asked):

Hurrah!

Conclusions

It's a very exciting development, but it isn't ready for real work. The toolchain is an astonishingly accomplished piece of work, and I'm desperate to get to grips with the nanz language, but the generated code has many fundamental errors that make ongoing experimentation too painful.

I've submitted a few defect reports, and will monitor ongoing progress closely. With a bit of luck I'll be able to make a follow on article where we go into more depth.

Comments