Skip to content

oisee/minz

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

922 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

MinZ Programming Language

ZX Spectrum Tetris in 853 lines of Nanz. Wall kicks, hold piece, ghost piece, T-spin scoring. Compiles to 2176 lines of Z80 assembly (48 functions). Plus three new language features:

Feature Output Quality
ptr(addr)^ peek LD A, (HL) / RETno asm needed Optimal
ptr(addr)^ = val poke LD (HL), C / RETlanguage-level I/O Optimal
5 |> double |> inc LD A, 11 / RETconstant-folded! Optimal
asm (ret A) (clob A, F) ADD A, A / RETprecise clobbers Optimal
asm (clob auto) Parses asm text, computes write-set Safe default

Full report with Tetris architecture, asm design, and 5 showcase examples


Visual Showcase — Cross-Language Canvas

ObjC demoscene effects compiled through HIR → MIR2 → VM (and QBE native). All three use integer-only sine approximation — no floats needed.

Plasma (sine interference) Diamond (Manhattan distance) XOR Fractal (classic x^y)
Plasma Diamond XOR
isin(x*scale + t*speed) (abs(dx) + abs(dy)) & 255 (x+t) ^ (y+t/2) & 255

256x192, 256-color palette, rendered by ObjC @protocol Effect with dynamic dispatch via vtables. Source: examples/objc/plasma.m. Also compiles to native x86-64 via mzn -o plasma examples/objc/plasma.m.

Sphere Raymarcher (Nanz)
Sphere

Latest

  • FAT16 Support — FAT12/FAT16 auto-detection at mount. Full E2E on 16MB FAT16 image: create, read, multi-cluster (5000B across 3 clusters), delete, overwrite — gcc FatFS R0.16 verifies 11/11. 13 total FatFS tests, all PASS.
  • FAT Cache Fix + Multi-Cluster — Fixed sector-relative FAT cache offsets (was using absolute byte offsets on 512B buffer). Multi-cluster write/read verified: 3000B file spans 2 clusters, FAT chain 2→3→EOC, gcc reads all bytes.
  • E2E Multi-Channel FatFS Verification — 5-channel cross-verification: Nanz writes FAT12 image (text, binary, multi-cluster, delete, overwrite) → verified by Nanz VM, fresh Nanz VM, gcc FatFS R0.16, C89 MIR2 VM, raw byte inspection.
  • Nanz FAT12/16 Read-Write Library — Native stdlib/fs/fat12.minz: mount, find, read, create, delete, overwrite files on FAT12 and FAT16 volumes. write_fat12 implements 12-bit packed read-modify-write. Full CRUD verified: Nanz writes image → gcc-compiled FatFS reads back (14/14). SDCC Z80 comparison test. 28/28 differential vs C89. fat12.minz
  • ABAP on Z80 + Wasm Parser — 7th frontend: ABAP compiles to Z80/QBE/native via embedded Wasm parser (no Node.js). Selection screen with PARAMETERS on CP/M. MARA/MAKT SQLite demo. Zork I runs in mze.
  • ObjC Canvas + Demoscene + Multi-Frontend CLI — Cross-language canvas library (VM + native), 3 demoscene effects in ObjC, all 8 frontends wired into mzv/mzn, CLI flags standardized to --long/-s convention.
  • ABAP Frontend + SQLite + Zork — 7th frontend (ABAP via abaplint), SQLite host functions in MIR2 VM, CP/M file I/O fixed (ROM protection root cause), Zork I (1983) runs in MZE.
  • Nanz Z80 Showcase v2 — 12 verified examples: abs_diff 6B (optimal), swap 1B (bare RET), smaller 0B (EQU), popcount 3-inst LUT, @smc compiled sprites, value pipes constant-folded, iterator DJNZ fusion.
  • C89/ObjC Frontend vs SDCC — C89/C99/ObjC via modernc.org/cc/v4. Identical C source, MinZ 81B vs SDCC 179B (−55%). ObjC adds @protocol vtable dispatch, self->field access. 14 corpus files, 191 asserts.
  • Eight Frontends, Universal Assert — Nanz, Lanz, Lizp, PL/M-80, Pascal, C89, ObjC, ABAP — all compile through one HIR → MIR2 → Z80 pipeline.
  • Nanz Language Book v5.3 — 21 chapters + 8 appendices. New: five-frontend architecture, universal assert syntax, Pascal/Lizp imports, transpilation via --emit.
  • ZX Spectrum Tetris — 853 LOC, 7 tetrominoes, SRS wall kicks, hold/next/ghost piece, T-spin scoring. Attribute-based rendering for fast frame updates.
  • Nanz Language Sprint: 6 features — enums, type aliases, module imports, three string types, pipe/trans named pipelines with DJNZ fusion.
  • Arena allocator + sandbox + sizeof — struct-based bump allocator with ^Arena pointer receiver, arena_split chaining, sizeof(Type) compile-time constant.
  • PreallocCoalesce deliversmapInPlace loop: 5 instructions → 1 DJNZ. factorial_fold: entire mul16 routine eliminated.
  • MOS 6502 backend alive — 35/35 tests, dual-VM oracle (MIR2 VM vs sim6502), console I/O for Apple II/C64/BBC Micro.

Full changelog →

MinZ C89 vs SDCC 4.2.0 — Z80 Codegen Comparison

Identical C source compiled through both toolchains. Binary sizes (code only):

Function MinZ C89 SDCC 4.2.0 Delta Notes
twice(i16)→i16 2B 3B −1B SDCC: EX DE,HL return tax
add(i16,i16)→i16 2B 3B −1B SDCC: EX DE,HL return tax
max(i16,i16)→i16 12B 12B TIE Both clever compare tricks
abs_diff(u8,u8)→u8 9B 11B −2B MinZ: RET Z/RET C conditional return
sum_to(i16)→i16 21B 25B −4B MinZ: no trampoline
clamp8(u8,u8,u8)→u8 10B 30B −20B MinZ: 3-reg ABI + RET Z/C
minmax(u16,u16)→(u16,u16) 19B 61B −42B MinZ: tuple return + RET C/Z
smaller (uses lo) 0B 34B −34B MinZ: EQU minmax (degenerate!)
larger (uses hi) 6B
TOTAL 81B 179B −55% Full report →

MinZ Logo

Modern Programming Language for Vintage Hardware

Version License

Write modern code. Run it on Z80, eZ80, 6502, and more.

Quick Start | Features | Examples | Targets | Toolchain


What is MinZ?

MinZ is a compiler toolchain for retro hardware — primarily Z80 and eZ80, with an experimental MOS 6502 backend.

The primary frontend is Nanz (.nanz) — a minimal, type-safe language that compiles through the HIR → MIR2 → Z80 pipeline with PBQP register allocation. Seven additional frontends — Lanz (S-expressions), Lizp (Lisp dialect), PL/M-80, Pascal, C89, ObjC (protocol vtables + dynamic dispatch), and ABAP — compile through the same backend. Cross-language imports are first-class.

Self-contained toolchain: compiler, assembler, emulator, disassembler, and remote runner. No external dependencies — pure Go.

import stdlib.cpm.bdos;

fun main() -> void {
    @print("Hello from MinZ!");
    let fib_a: u16 = 0;
    let fib_b: u16 = 1;
    for i in 0..10 {
        print_u16(fib_a);
        putchar(32);  // space
        let next = fib_a + fib_b;
        fib_a = fib_b;
        fib_b = next;
    }
}

This compiles to Z80 assembly, assembles to a .com binary, and runs on CP/M:

$ mz fibonacci_cpm.minz -b z80 --target cpm -o fib.a80 && mza fib.a80 -o fib.com
$ mze fib.com -t cpm
Fibonacci:
0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55

Quick Start

Build from Source

git clone https://github.com/oisee/minz.git
cd minz/minzc
make all            # Build all 9 tools
make install-user   # Install to ~/.local/bin/

No external dependencies. Pure Go.

Compile and Run

# Compile MinZ to Z80 assembly
./mz ../examples/hello_print.minz -o hello.a80

# Assemble to binary
./mza hello.a80 -o hello.tap

# Run in emulator
./mze hello.tap

Multi-Target

mz program.minz -b z80 --target spectrum -o prog.a80   # ZX Spectrum
mz program.minz -b z80 --target cpm -o prog.a80        # CP/M
mz program.minz -b z80 --target agon -o prog.a80       # Agon Light 2
mz program.minz -b c -o prog.c                         # C99 (partial — simple programs only)
mz program.minz -b crystal -o prog.cr                  # Crystal (stub — not functional)

Features

Working

Feature Description
Types u8, u16, i8, i16, bool, void, pointers
Functions fun/fn declaration, overloading, multiple returns
Control flow if/else, while, for i in 0..n, loop {}
Structs Declaration, field access, UFCS method syntax
Arrays Declaration, indexing
Globals global counter: u8 = 0;
String interpolation "Hello #{name}!" (Ruby-style)
Inline assembly asm { LD A, 42 } blocks, [addr] bracket indirection
CTIE Compile-Time Interface Execution (trait monomorphization)
True SMC Self-modifying code optimization
@extern FFI extern fun putchar(c: u8) at 0x10; with RST optimization
Operator overloading v1 + v2 via impl blocks
Error propagation @error(code) with CY flag ABI
Enums enum State { IDLE, RUNNING } with values
Module system import stdlib.cpm.bdos;
Lambdas Closure syntax, zero-cost transform
PL/M-80 frontend Parse + HIR lowering for all 26 Intel 80 Tools corpus files (100%); 1338 functions, 11661 statements
Nanz frontend New active source language for the MIR2 backend; arithmetic, control flow, loops, function calls
LUTGen u8<lo..hi> ranged type annotation → compile-time table generation; popcount loop → 3-instruction LUT at runtime
Flag-return ABI Functions returning bool from a comparison pass the result via carry flag — no LD A, 0/1 materialization
Interprocedural CC opt Register class chosen per call-site: params coerced to A/B/C/HL/DE based on callee contract
JRS pseudo-instruction Codegen emits JRS for all branches; MZA picks JR (2B) or JP (3B) based on offset and condition
TUI framework Universal screen/form framework — same code runs on CP/M (BDOS), MZV (ANSI), ZX Spectrum (--zx). Three API levels: procedural, OOP UFCS, declarative DSL
Compile-time metafunctions fun @name(...) runs on MIR2 VM at compile time. Receives DSL blocks as data, emits Nanz source. Lisp-style macros with normal syntax

Partial / In Progress

Feature Status
Pattern matching Syntax parses, codegen partial
Iterator chains 9 ops on Z80 + inline lambda filters + fusion optimizer (inlines callbacks in DJNZ loops). 87+ tests, 11/11 E2E hex-verified, all pass. enumerate/reduce at MIR level (Z80 needs OpPush fix). See Status
MIR interpreter Arrays/structs working, not complete

Known Limitations

  • Register allocator has bugs with overlapping lifetimes in complex loops
  • Some loop/arithmetic combinations produce incorrect code
  • loadToHL can use stale values in multi-expression contexts
  • Loop rerolling can be too aggressive across function call boundaries

These are documented and being worked on. Simple programs (hello world, fibonacci, demos) work correctly. Complex programs with nested loops and heavy arithmetic may hit edge cases.


Code Examples

Nanz: New Active Frontend for MIR2

Nanz is the primary language for the HIR→MIR2→Z80 pipeline. Real compiled output:

fun abs_diff(a: u8, b: u8) -> u8 {
    if a > b { return a - b }
    return b - a
}

fun clamp(x: u8, lo: u8, hi: u8) -> u8 {
    if x < lo { return lo }
    if x > hi { return hi }
    return x
}

Generated Z80 (actual mz output):

abs_diff:
    CP C
    JR Z, .abs_diff_if_join2
    JR C, .abs_diff_if_join2
.abs_diff_if_then1:
    SUB C
    LD C, A
    RET
.abs_diff_if_join2:
    NEG
    ADD A, C
    LD C, A
    RET

clamp:
    CP D                    ; x vs lo
    JR NC, .clamp_if_join2
.clamp_if_then1:
    LD A, D
    RET
.clamp_if_join2:
    CP C                    ; x vs hi
    JR Z, .clamp_if_join4
    JR C, .clamp_if_join4
.clamp_if_then3:
    LD A, C
    RET
.clamp_if_join4:
    RET

PBQP Allocator: Hot Registers in Cheap Slots

(examples/nanz/05_four_pointers.nanz · 06_pbqp_weighted.nanz · 07_ix_load_store.nanz)

The PBQP allocator weights each virtual register's cost by its use count. A register used 10× pays 10× the slot cost, so the solver puts it in the cheapest location — even when that means displacing a low-use register.

Four simultaneously-live pointer registers → HL / DE / BC / IX (no spill):

// examples/nanz/05_four_pointers.nanz
fun four_ptrs(p0: ptr, p1: ptr, p2: ptr, p3: ptr) -> u8 {
    var v0: u8 = p0[0]
    var v1: u8 = p1[0]
    var v2: u8 = p2[0]
    var v3: u8 = p3[0]    // p3 → IX under register pressure
    var s01: u8 = v0 + v1
    var s23: u8 = v2 + v3
    return s01 + s23
}
four_ptrs:
    LD C, (HL)      ; p0 → HL  (cost 0)
    LD D, (DE)      ; p1 → DE  (cost 4)
    LD E, (BC)      ; p2 → BC  (cost 6)
    LD H, (IX+0)    ; p3 → IX  (cost 8) ← (IX+0) not $F0xx memory!
    LD A, C
    ADD A, D
    LD C, A
    LD A, E
    ADD A, H
    ...
    RET

High-use vs low-use — PBQP always puts the hot reg in the cheap slot:

// examples/nanz/06_pbqp_weighted.nanz
fun weighted(x: u8) -> u8 {
    var light: u8 = 1          // used 1×  — displaced to C
    var heavy: u8 = x          // used 10× — stays in A (0T per use)
    heavy = heavy + x          // ... repeated 9 more times
    ...
    return heavy + light
}
weighted:
    LD C, 1          ; light → C  (1× use, forced out of A)
    ADD A, A         ; heavy stays in A throughout (10× use, 0T/use)
    LD D, A
    ADD A, D
    ...              ; 8 more iterations — all in A, zero memory traffic
    ADD A, C         ; final: heavy(A) + light(C)
    RET

IX store/load — undocumented HL→IX copy (16T vs 21T PUSH/POP):

// examples/nanz/07_ix_load_store.nanz
fun roundtrip_ix(hl_ptr: ptr, de_ptr: ptr, bc_ptr: ptr, val: u8) -> u8 {
    bc_ptr[0] = val           // bc_ptr overflows to IX under 4-reg pressure
    var a: u8 = hl_ptr[0]
    var b: u8 = de_ptr[0]
    var back: u8 = bc_ptr[0]
    return a + b + back
}
roundtrip_ix:
    LD IXH, H         ; undocumented DD 67 — copy HL→IX (16T, not PUSH/POP=21T)
    LD IXL, L         ; undocumented DD 6D
    LD (IX+0), C      ; store val through IX pointer
    LD C, (DE)
    LD D, (BC)
    LD E, (HL)
    ...
    RET

LUTGen: Compile-Time Lookup Tables

Annotate with u8<0..255> — the compiler evaluates the function for all 256 values and emits a page-aligned table:

fun popcount(x: u8<0..255>) -> u8 {
    var n: u8 = 0
    var v: u8 = x
    while v != 0 {
        n = n + (v & 1)
        v = v >> 1
    }
    return n
}

The loop above never runs at runtime. Generated Z80:

popcount:
    LD HL, popcount_lut
    LD L, C                 ; C = input (index into table)
    LD A, (HL)              ; table lookup — H unchanged = page base
    RET

    ALIGN 256
popcount_lut:
    DB 0, 1, 1, 2, 1, 2, 2, 3, ...   ; 256 bytes, evaluated at compile time

Structs and Methods (UFCS)

struct Vec2 { x: i16, y: i16 }

impl Vec2 {
    fun add(self, other: Vec2) -> Vec2 {
        return Vec2 { x: self.x + other.x, y: self.y + other.y };
    }
    fun length_sq(self) -> i16 {
        return self.x * self.x + self.y * self.y;
    }
}

fun main() -> void {
    let v1 = Vec2 { x: 3, y: 4 };
    let v2 = Vec2 { x: 1, y: 2 };
    let v3 = v1 + v2;            // Zero-cost: CALL Vec2_add
    let len = v3.length_sq();    // Zero-cost: CALL Vec2_length_sq
}

Compile-Time Execution (CTIE)

@ctie
fun fibonacci(n: u8) -> u8 {
    if n <= 1 { return n; }
    return fibonacci(n-1) + fibonacci(n-2);
}

let fib10 = fibonacci(10);  // Becomes: LD A, 55 (no runtime cost)

Inline Assembly

asm fun fast_clear_screen() {
    LD HL, $4000
    LD DE, $4001
    LD BC, 6143
    LD (HL), 0
    LDIR
}

CP/M Program

import stdlib.cpm.bdos;

fun main() -> void {
    @print("Hello, CP/M!");
    putchar(13);
    putchar(10);
    let ch = getchar();
    putchar(ch);
}

ObjC: Protocol Dispatch + Canvas Effects

// examples/objc/plasma.m — Integer sine, no floats needed on Z80!
int isin(int x) {
    int ix = x & 255;
    int quarter = ix & 63;
    int half_val = quarter * 2;
    if (ix < 64)  return half_val;
    if (ix < 128) return 127 - half_val;
    if (ix < 192) return 0 - half_val;
    return half_val - 127;
}

@protocol Effect
-(int)render:(int)t;
@end

@implementation Plasma
-(int)render:(int)t {
    int w = canvas_width();
    int h = canvas_height();
    int y = 0;
    while (y < h) {
        int x = 0;
        while (x < w) {
            int v1 = isin(x * self->scale / 8 + t * self->speed);
            int v2 = isin((x + y) * self->scale / 16 + t);
            int color = (v1 + v2 + 256) / 2;
            canvas_pixel(x, y, color);
            x = x + 1;
        }
        y = y + 1;
    }
    return 0;
}
@end

Canvas API is cross-language — works from Nanz, C89, ObjC, Lizp, Lanz, Pascal, PL/M:

# VM (renders to PNG via Go host functions)
go test ./pkg/c89/ -run TestPlasmaRender -v

# Native binary (QBE → x86-64, renders to PPM)
mzn -o plasma examples/objc/plasma.m && ./plasma

Agon Light 2 Program

import stdlib.agon.mos;
import stdlib.agon.vdp;

fun main() -> void {
    mos_puts("Hello from Agon Light 2!");
    set_mode(3);
    fill_rect(10, 10, 100, 80, 4);
}

Error Handling

enum FileError { None, NotFound, Permission }

fun read_file?(path: u8) -> u8 ? FileError {
    if path == 0 {
        @error(FileError.NotFound);
    }
    return path;
}

Self-Modifying Code (True SMC)

@abi("smc")
fun draw_pixel(x: u8, y: u8) -> void {
    // Parameters patched directly into instruction immediates
    // Single-byte opcode changes: 7-20 T-states vs 44+ for memory reads
    let screen_addr = y * 32 + x;
    // ...
}

Zero-Cost Iterator Chains & Lambda Fusion (In Development)

MinZ aims to bring functional-style iterator chains to Z80 — with zero runtime overhead. The compiler fuses chains like .map().filter().forEach() into a single tight loop, inlining all lambdas and using DJNZ where possible.

Target syntax:

// Functional iterator chain — compiles to ONE loop, zero allocations
scores.iter()
    .map(|x| x + 5)
    .filter(|x| x >= 90)
    .forEach(|x| print_u8(x));

// In-place mutation with ! variants
enemies.filter!(|e| e.health > 0);
particles.forEach!(|p| p.update());

// Generators (planned)
gen fibonacci() -> u16 {
    let a: u16 = 0;
    let b: u16 = 1;
    loop {
        yield a;
        let tmp = a + b;
        a = b;
        b = tmp;
    }
}

What the compiler produces — the entire chain fuses into ~25 T-states/element:

; scores.iter().map(|x| x + 5).filter(|x| x >= 90).forEach(|x| print_u8(x))
;
; No intermediate arrays. No function call overhead. Just one DJNZ loop.

    LD HL, scores            ; source pointer
    LD B, scores_len         ; counter in B for DJNZ
.loop:
    LD A, (HL)               ; load element         (7 T)
    ADD A, 5                 ; .map(|x| x + 5)      (4 T)
    CP 90                    ; .filter(|x| x >= 90) (7 T)
    JR C, .skip              ; skip if < 90
    CALL print_u8            ; .forEach(...)
.skip:
    INC HL                   ; next element          (6 T)
    DJNZ .loop               ; dec B, loop          (13 T)

Compare: a naive indexed loop with separate map/filter passes would cost 60-150+ T-states/element and allocate intermediate arrays. The fused version uses O(1) memory and runs 3-5x faster.

Key optimizations:

  • Lambda inlining — closures compile to direct CALL or inline code, never heap-allocated
  • Iterator fusion — multi-stage chains merge into a single loop at compile time
  • DJNZ loops — arrays ≤255 elements use Z80's dedicated loop instruction (13 T-states vs 25+ for compare-jump)
  • Pointer arithmeticHL walks the array with INC HL, no index multiplication

Testing (v0.19.5): 87+ tests across 7 layers — every stage of the pipeline has dedicated coverage:

Layer Tests Status
E2E shell (hex-verified output) 11 all pass
Corpus (full compile to Z80) 18 all pass
Fusion optimizer (callback inlining) 7 all pass
MIR VM (DJNZ execution) 8 all pass
Codegen (Z80 patterns) 7 all pass
Semantic (IR generation) 20 all pass
Parser (chain conversion) 18 all pass

9 operations fully working on Z80: forEach, map, filter, take, skip, peek, inspect, takeWhile, and inline lambda filters (filter(|x| x > N) compiles to CP N+1 + JR C — no function call, ~27 T-states saved per iteration). Fusion optimizer inlines small callbacks directly into DJNZ loop bodies, eliminating CALL/RET overhead and enabling bare DJNZ instruction. enumerate and reduce work at MIR level, Z80 blocked by OpPush routing. See Iterator Implementation Status for details.

Documentation:

Universal TUI Framework & Compile-Time Metafunctions

Three levels of screen abstraction — same code runs on CP/M, MZV, and ZX Spectrum:

Level 1 — Procedural (sel_register / sel_show):

sel_register_str(c"NAME", 20, c"World", @buf)
sel_register_int(c"COUNT", 3)
sel_show()
$ printf 'Z80\n\n' | mze hello_input.com -t cpm
P_NAME [World]:
P_COUNT [3]:

Hello, Z80!

Level 2 — OOP UFCS (Screen.add_field / Screen.show):

scr.init(c"Customer Master")
scr.add_field(c"Customer", 10, &buf)
scr.add_int(c"Count", 5)
scr.add_button(c"Execute", KEY_F8)
scr.show()

Level 3 — Declarative DSL (@screen metafunction):

fun @screen is a Nanz function that runs at compile time on the MIR2 VM. It receives the block { field ... } as structured data, iterates it, and emits Level 2 Nanz code — which the compiler then parses and compiles normally.

// Define the metafunction (compile-time, written in Nanz)
fun @screen(title: ^u8) -> void {
    emit(c"fun _show() -> void {")
    emit(c"    tui_clear()")

    // Title bar
    emit_tui_color(7, 4, 1)
    emit_tui_goto(0, 0)
    emit_tui_puts(str_concat(c"  ", title))
    emit(c"    tui_reset()")

    // Iterate block as typed struct pointers
    var nodes: ^BlockNode = block_nodes()
    var row: u8 = 2
    for node: ^BlockNode in nodes[0..block_len()] {
        if str_eq(node.keyword, c"field") == 1 {
            emit_tui_goto(2, row)
            emit_tui_color(6, 0, 0)
            emit_tui_puts(str_concat(node.label, c"    "))
            emit_tui_color(7, 0, 0)
            emit_tui_puts(c"[__________]")
            emit(c"    tui_reset()")
        }
        if str_eq(node.keyword, c"button") == 1 {
            emit_tui_goto(2, row)
            emit_tui_color(0, 7, 1)
            emit_tui_puts(str_concat(c"[", str_concat(node.label, c"]")))
            emit(c"    tui_reset()")
        }
        row = row + 1
    }

    emit(c"    var key: u8 = tui_read_key()")
    emit(c"}")
}

// Use it — zero runtime overhead, all code generated at compile time
@screen("Material Report") {
    field "Material"
    field "Plant"
    field "Count"
    button "Execute"
}

fun main() -> void {
    _show()
}
$ echo "" | mzv -H examples/nanz/meta_screen.nanz

  Material Report
Material    [__________]
Plant       [__________]
Count       [__________]
[Execute]

The metafunction pipeline: parse block → compile fun @screen to MIR2 → execute on VM → collect emit() output → parse as Nanz → merge into program. Lisp-style macros with normal syntax, written in the language itself.

See stdlib/tui/README.md for full documentation.


Platform Targets

Z80 Targets (Primary)

Target Status Binary Notes
ZX Spectrum Working .tap Main development target, tested via mze + ZXSpeculator
CP/M Working .com BDOS stdlib, tested via mze with CP/M mode
Agon Light 2 Working .bin eZ80/ADL mode, MOS + VDP stdlib, structural testing only
MSX Compiles varies Target config exists, limited testing

Backends

Backend Status Notes
Z80 ✅ Production Full-featured, optimized, 5500+ lines, MIR2 active target
QBE (native) ✅ Working MIR2→QBE IL→arm64/x86_64. Correctness oracle: 4/4 E2E tests. brew install qbe
C99 ⚠️ Partial Produced real binaries; variable redeclaration bug in scoped locals
M68k 🧪 Untested Most complete non-Z80 (28 opcodes, real register allocator); never assembled
i8080 🧪 Untested Structurally correct (all-memory approach); never assembled
6502 ⚠️ Working 35/35 tests, E2E via sim6502 emulator, dual-VM cross-check, console I/O (A2/C64/BBC). Report #067
LLVM ❌ Broken JumpIf fallthrough hardcoded, type errors; llc fails
WASM ❌ Broken Label/jump emit as comments; WAT validation fails
Crystal ❌ Stub Control flow emits comments, function args always empty
Game Boy ❌ Stub Add, Sub, LoadVar, StoreVar all emit only comments

Only Z80 is production-quality. QBE is new (2026-03-09)pkg/mir2qbe translates MIR2 directly to QBE IL, which compiles to native arm64/x86_64 via qbe + cc. Used as a correctness oracle: same MIR2 module → Z80 emulator vs native binary; agreement means the pipeline is correct. See Report #045.

6502 Backend — E2E Verified (NEW)

The MOS 6502 backend compiles MIR2 IR to valid NMOS 6502 assembly, assembled and executed on an in-process emulator. 35/35 tests pass.

MIR2 IR ─┬─→ VM.Call()           → reference ─┐
          │                                     ├→ assert equal (dual-VM oracle)
          └─→ M6502Codegen → asm → sim6502 → A ┘

What works (E2E verified): add, sub, neg, double, and/or/xor, function calls (JSR/RTS), constants, console output (4 platforms).

Console I/O — four OS vectors captured simultaneously (zero conflicts):

Address System Convention
$F001 Bare metal STA $F001 (I/O port)
$FDED Apple II JSR $FDED (COUT)
$FFD2 C64 JSR $FFD2 (CHROUT)
$FFEE BBC Micro JSR $FFEE (OSWRCH)

All share the same calling convention: char in A. In MinZ: @extern("$FFEE") fun putchar(c: u8).

Missing (roadmap): loops, 16-bit math, memory access, SMC. See Report #067 for the full feature matrix and Z80 vs 6502 comparison.

Language Frontends

Eight source languages compile through the same HIR → MIR2 → Z80 backend:

  .nanz ──→ nanz.Parse()     ──┐
  .lanz ──→ lanz.Compile()   ──┤
  .lizp ──→ lizp.Compile()   ──┤
  .plm  ──→ plm.Compile()    ──┼──→ *hir.Module ──→ MIR2 ──→ Z80/6502/QBE
  .pas  ──→ pascal.Compile()  ──┤
  .c    ──→ c89.Compile()    ──┤
  .m    ──→ c89.Compile()    ──┤  ← ObjC: @protocol vtables, dynamic dispatch!
  .abap ──→ abap.Compile()   ──┘  ← ABAP via abaplint!
Frontend Status Purpose Notes
Nanz Primary Modern systems language Full-featured: structs, enums, iterators, lambdas, SMC, LUTGen, flag-return ABI
Lanz Working S-expression IR 1:1 mapping to HIR. Round-trips perfectly. Used by @derive_* metafunctions
Lizp Working Lisp dialect Macros, threading (->, ->>), defmacro/cond/when/dotimes. Desugars to Lanz
PL/M-80 Working Legacy Intel (1976) 26/26 Intel 80 Tools corpus (100%); 1338 functions, 11661 statements
Pascal Working Turbo Pascal WriteLn → CP/M BDOS via inline asm. mz hello.pas -t cpm -o hello.com
C89 Working C89/C99 modernc.org/cc/v4 parser. 16 corpus files, 350 asserts. FatFS R0.16 (7,249 LOC) compiles. −55% vs SDCC
ObjC Working Objective-C subset @protocol, @interface, @implementation, self->field, dynamic dispatch via vtables. Demos →
ABAP NEW SAP ABAP on Z80! abaplint parser (TS). DATA, WRITE, IF, WHILE, DO, FORM, CLASS. Examples →
MinZ Frozen on MIR1 Legacy syntax Old MIR1 path; will be rewired through HIR→MIR2

Eight pipelines, one backend. .nanz, .lanz, .lizp, .plm, .pas, .c, .m, and .abap files all go through compileViaHIR() → HIR → MIR2 → Z80. A function double(x) = x + x written in any of the eight languages produces the same Z80: ADD A, A / RET.

ABAP on Z80 — Yes, Really

REPORT zfibonacci.

DATA: lv_a TYPE i VALUE 0,
      lv_b TYPE i VALUE 1,
      lv_temp TYPE i,
      lv_i TYPE i VALUE 0.

WHILE lv_i < 10.
  WRITE lv_a.
  lv_temp = lv_a + lv_b.
  lv_a = lv_b.
  lv_b = lv_temp.
  lv_i = lv_i + 1.
ENDWHILE.

This compiles through: ABAP → abaplint (TypeScript parser by Lars Hvam Petersen) → JSON AST → Go lowerer → HIR → MIR2 → Z80 assembly. Your ZX Spectrum is now an enterprise-grade ABAP runtime. See 8 examples including FizzBuzz, bubble sort, OOP with interfaces, and a system info report.

Cross-language imports — Nanz can import from any frontend:

import mathlib    // finds mathlib.nanz, .lanz, .lizp, .plm, or .pas
import legacy { PLM_ADD }           // PL/M-80 procedure
import macrolib { lizp_double }     // Lizp function

Universal compile-time assert — all 6 frontends produce the same hir.Assert, verified by dual-VM (MIR2 VM + Z80 binary):

Frontend Syntax
Nanz assert double(5) == 10
Lanz (assert double 5 == 10)
Lizp (assert double 5 == 10)
PL/M-80 ASSERT DOUBLE(5) = 10;
Pascal assert Double(5) = 10;
C89 // assert double(5) == 10

Pascal on CP/M — hello world in one command:

mz hello.pas -t cpm -o hello.com && mze -t cpm hello.com
# Output: Hello from Pascal on Z80!

PL/M-80 coverage (Intel 80 Tools corpus): algolm compiler, BASIC-E compiler/parser/synthesizer, ML80 assembler (l81/l82/l83/m81), TeX, CP/M utilities, Kermit — 1338 functions / 943 globals / 11661 statements lowered to HIR from 26 source files. Handles LITERALLY macro chains, $INCLUDE with CP/M device designators, binary literals, record field access, EXTERNAL procedures, all PL/M-80 statement forms. See ADR-0014.

Pipeline emit flags (works with all frontends):

mz program.plm --emit=nanz       # Transpile PL/M → Nanz (round-trip)
mz program.pas --emit=lanz       # Transpile Pascal → Lanz
mz program.lanz --emit=nanz      # Transpile Lanz → Nanz
mz program.plm --emit=hir        # HIR typed-tree dump
mz program.plm --emit=mir2       # MIR2 after optimisation passes
mz program.plm -o prog.com -t cpm  # Assemble to CP/M binary

The Nanz transpiler is lossless: mz prog.plm --emit=nanz | mz --stdin produces byte-identical assembly to compiling .plm directly.

See Chapter 21 of the Nanz Language Book for the full cross-language import guide.


Toolchain — End-to-End Development Ecosystem

MinZ provides a complete, self-contained development ecosystem. Every tool you need — from source code to running program to screenshot — is a single Go binary with zero external dependencies. No fragile toolchain of third-party assemblers, separate emulators, or external debuggers. One make builds everything.

Source Code                          Running Program
    |                                      |
    v                                      v
  [mz] compile ──> [mza] assemble ──> [mze] run (CP/M, headless)
    |                                  [mzx] run (ZX Spectrum, graphical)
    |                                  [mzrun] run (remote, DZRP)
    |                                      |
    v                                      v
  [mzd] disassemble <──────────────── [mzx --screenshot] capture
Tool Purpose Usage
mz MinZ compiler mz program.minz -o program.a80
mza Z80 assembler (table-driven, all Z80 ops including undocumented, [addr] bracket syntax) mza program.a80 -o program.com
mze Z80 emulator (1335/1335 FUSE tests, profiler, console I/O, stderr port) mze program.com -t cpm --console-io
mzx ZX Spectrum emulator (T-state accurate, AY, profiler, .sna/.tap/.trd/.scl, console I/O) mzx --snapshot game.sna
mzd Z80 disassembler (IDA-like analysis, xrefs, ROM tables) mzd program.bin --org 0x8000
mzrun Remote runner (DZRP protocol) mzrun program.minz --reset
mzv MIR2 VM runner (TUI display, canvas, all 8 frontends) mzv program.nanz / mzv demo.m
mzn Native compiler (MIR2→QBE/C99→x86-64, all 8 frontends) mzn -o bin program.c
mzr Interactive REPL ❌ Broken — compilation pipeline not wired
mzlsp LSP server (diagnostics, hover, goto-def, completion) auto-started by VSCode extension

MZX — ZX Spectrum Emulator

T-state accurate emulation with real display output. Supports 48K and Pentagon 128K models.

# Interactive emulation
mzx --snapshot game.sna
mzx --tap game.tap
mzx --model pentagon --rom 128-0.rom --rom1 trdos.rom --trd game.trd

# Load raw binary and run (no ROM needed)
mzx --load code.bin@8000 --set PC=8000,SP=FFFF,DI
mzx --run code.bin@8000   # shortcut for --load + --set PC + SP + DI

# Bare-metal console I/O (no ROM needed)
mzx --run code.bin@8000 --frames DI:HALT --console-io
# OUT ($23),A → stdout | IN A,($23) → stdin | OUT ($25),A → stderr
# DI + HALT → exit with A register as process exit code

# Console I/O with custom port or AY serial
mzx --run code.bin@8000 --frames DI:HALT --console-to-port '$FF'
mzx --run code.bin@8000 --frames DI:HALT --console-to-port ay

# BASIC console (RST $10, needs ROM)
mzx --snapshot game.sna --console

# Headless screenshots (for CI, automated testing, book illustrations)
mzx --snapshot game.sna --screenshot shot.png --frames 100
mzx --tap game.tap --screenshot shot.png --screenshot-on-stable 3

# Execution profiling (7-channel heatmap + memory snapshot)
mzx --snapshot demo.sna --profile heatmap.json --frames 500
# Profile includes: exec, read, write, stack_push, stack_pop, io, mem_snapshot
mzx --snapshot demo.sna --trace trace.jsonl --trace-frames 100:200

# Debugging
mzx --warn-on-halt --verbose --diag --snapshot game.sna

Features: FrameMap ULA rendering, beeper + AY-3-8912 audio (AYumi), ULA contention, .sna/.tap/.trd/.scl format support, full TR-DOS function dispatch, 7-channel execution profiler (exec/read/write/stack push/pop/IO + memory snapshot), basic-block tracer, conditional screenshots, T-state snapshots, DI+HALT exit with A as exit code, bare-metal console I/O (port $23 stdout, $25 stderr, or AY serial), 48K ROM included.

Live Testing with DZRP

For ZX Spectrum development, mzrun compiles, assembles, and uploads to a running emulator in one command:

# Start ZXSpeculator with DZRP enabled, then:
export DZRP_HOST=localhost DZRP_PORT=11000
mzrun game.minz --reset -v

Debug Flags

mz program.minz --dump-mir       # Show MIR intermediate representation
mz program.minz --dump-ast       # AST in JSON format
mz program.minz --viz out.dot    # MIR visualization (Graphviz)
mz program.minz -d               # Verbose compilation details
mz program.minz --compile-trace  # Structured log of all optimization decisions

Standard Library

Stdlib modules are organized by domain. Quality varies — some modules are well-tested, others are experimental.

Tested and Working

Module Description
cpm/bdos CP/M BDOS calls: putchar, getchar, print_string, file I/O
agon/mos Agon MOS API: mos_putchar, mos_puts, file I/O (eZ80 ADL mode)
agon/vdp Agon VDP graphics: modes, shapes, sprites, buffer commands
text/format Number formatting: u8_to_str, u16_to_hex
mem/copy Fast memory ops: memcpy, memset (LDIR-based)
fs/fat12 FAT12/16 filesystem: mount, find, read, create, delete, overwrite. Full R/W with gcc cross-verification

Available but Less Tested

Module Description
math/fast Sin/cos/sqrt lookup tables (256 entries)
math/random LFSR PRNG, noise functions
graphics/screen Pixel/line/circle drawing (ZX Spectrum)
input/keyboard Keyboard matrix, debouncing
text/string strlen, strcmp, strcpy, strcat
sound/beep Beeper SFX
time/delay Frame timing, delays

Experimental

Module Description
glsl/* GLSL-style shader library: fixed-point math, raymarching, SDFs

Optimization Pipeline

MinZ applies optimizations at multiple levels:

  1. CTIE — Pure functions with constant args execute at compile time
  2. MIR optimizer — Constant folding, strength reduction, dead code elimination
  3. True SMC — Self-modifying code patches parameters into instruction immediates
  4. Loop rerolling — Detects repeated call sequences, collapses to loops
  5. Peephole optimizer — 35+ Z80-specific assembly patterns

Example: fibonacci(10) with CTIE generates LD A, 55 — zero runtime cost.


Project Structure

minz/
  minzc/             Compiler & toolchain (Go, ~90K LOC)
    cmd/               CLI tools
      minzc/             mz — MinZ compiler
      mza/               mza — Z80 assembler
      mze/               mze — Z80 emulator (headless)
      mzx/               mzx — ZX Spectrum emulator (graphical)
      mzd/               mzd — Z80 disassembler
      mzrun/             mzrun — DZRP remote runner
      mzr/               mzr — REPL
    pkg/               Core packages
      parser/            Participle-based parser
      semantic/          Type checking, analysis (~11K lines)
      ir/                Intermediate representation
      codegen/           Z80 (production), C (partial), + 8 experimental backends
      optimizer/         MIR + peephole optimizers
      z80asm/            Z80 assembler engine (table-driven)
      spectrum/          ZX Spectrum emulation (ULA, AY, memory, ports)
      emulator/          Z80 CPU emulation (remogatto/z80, FUSE-tested)
      disasm/            Disassembler with IDA-like analysis
  stdlib/            Standard library (.minz)
    agon/              Agon Light 2 (MOS, VDP)
    cpm/               CP/M (BDOS)
    graphics/          Screen drawing
    math/              Fast math, PRNG
    text/              String, formatting
    ...
  examples/          270+ example programs
  docs/              Technical documentation
  reports/           Progress reports (date-numbered)

Current Status (March 2026)

Active pipeline: Nanz/Lanz/Lizp/PL/M-80/Pascal/C89/ObjC/ABAP → HIR → MIR2 → Z80 (production) / QBE (native) / 6502 (experimental).

Metrics (verified 2026-03-15):

Language frontends 8 (Nanz, Lanz, Lizp, PL/M-80, Pascal, C89, ObjC, ABAP)
Nanz showcase 34/34 compile + verify
Compile-time asserts 191+ across all frontends (C89/ObjC: 14 files, 191 asserts)
Go test packages 26/26 pass
6502 backend 35/35 E2E tests
Z80 emulator 1335/1335 FUSE tests (100%)
PL/M-80 corpus 26/26 Intel 80 Tools files (100%)
MIR2→QBE 4/4 E2E (correctness oracle)
Toolchain 9 working binaries, pure Go, zero deps

Known limitations: strict > codegen on Z80 (flag polarity), struct alloca encoding, as cast syntax not parsed. MinZ .minz frozen on MIR1. See Open Bugs.


Development

See docs/GenPlan.md for the development roadmap and current priorities.


Contributing

# Build all tools
cd minzc
make all

# Run all tests (emulator, assembler, spectrum, parser, etc.)
make test-all

# 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

# Screenshot an example
./mzx --rom roms/48.rom --snapshot demo.sna --screenshot shot.png --frames 50

Report issues at github.com/oisee/minz/issues.


License

MIT. See LICENSE for details.


MinZ: Modern syntax for vintage hardware.

About

Minz /mɪnts/ - Systems programming for Z80. Features TRUE SMC lambdas, revolutionary ABI for seamless ASM integration, Lua metaprogramming. TSMC delivers 14.4% fewer instructions vs C. Optimized Z80 assembly for retro/embedded.

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors