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) / RET — no asm needed |
Optimal |
ptr(addr)^ = val poke |
LD (HL), C / RET — language-level I/O |
Optimal |
5 |> double |> inc |
LD A, 11 / RET — constant-folded! |
Optimal |
asm (ret A) (clob A, F) |
ADD A, A / RET — precise clobbers |
Optimal |
asm (clob auto) |
Parses asm text, computes write-set | Safe default |
→ Full report with Tetris architecture, asm design, and 5 showcase examples
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) |
|---|---|---|
![]() |
![]() |
![]() |
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.
- 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_fat12implements 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/-sconvention. - 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_diff6B (optimal),swap1B (bare RET),smaller0B (EQU),popcount3-inst LUT,@smccompiled 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@protocolvtable dispatch,self->fieldaccess. 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
^Arenapointer receiver,arena_splitchaining,sizeof(Type)compile-time constant. - PreallocCoalesce delivers —
mapInPlaceloop: 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.
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 → |
Write modern code. Run it on Z80, eZ80, 6502, and more.
Quick Start | Features | Examples | Targets | Toolchain
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
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 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.tapmz 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)| 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 |
| 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 |
- Register allocator has bugs with overlapping lifetimes in complex loops
- Some loop/arithmetic combinations produce incorrect code
loadToHLcan 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.
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(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
...
RETHigh-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)
RETIX 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)
...
RETAnnotate 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 timestruct 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
}
@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)
asm fun fast_clear_screen() {
LD HL, $4000
LD DE, $4001
LD BC, 6143
LD (HL), 0
LDIR
}
import stdlib.cpm.bdos;
fun main() -> void {
@print("Hello, CP/M!");
putchar(13);
putchar(10);
let ch = getchar();
putchar(ch);
}
// 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;
}
@endCanvas 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 && ./plasmaimport 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);
}
enum FileError { None, NotFound, Permission }
fun read_file?(path: u8) -> u8 ? FileError {
if path == 0 {
@error(FileError.NotFound);
}
return path;
}
@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;
// ...
}
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
CALLor 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 arithmetic —
HLwalks the array withINC 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:
- Iterator Implementation Status — actual compiler output, known bugs, performance reality
- Iterator Reality Check (Report #017) — grounded analysis of T-state costs
- ADR-0008: Flag-Based Boolean ABI —
CP+ flag returns for iterator predicates
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.
| 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 |
| 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 | 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 | 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.
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.
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.
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 binaryThe 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.
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 |
| ❌ Broken — compilation pipeline not wired | ||
| mzlsp | LSP server (diagnostics, hover, goto-def, completion) | auto-started by VSCode extension |
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.snaFeatures: 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.
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 -vmz 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 decisionsStdlib modules are organized by domain. Quality varies — some modules are well-tested, others are experimental.
| 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 |
| 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 |
| Module | Description |
|---|---|
glsl/* |
GLSL-style shader library: fixed-point math, raymarching, SDFs |
MinZ applies optimizations at multiple levels:
- CTIE — Pure functions with constant args execute at compile time
- MIR optimizer — Constant folding, strength reduction, dead code elimination
- True SMC — Self-modifying code patches parameters into instruction immediates
- Loop rerolling — Detects repeated call sequences, collapses to loops
- Peephole optimizer — 35+ Z80-specific assembly patterns
Example: fibonacci(10) with CTIE generates LD A, 55 — zero runtime cost.
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)
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.
See docs/GenPlan.md for the development roadmap and current priorities.
# 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 50Report issues at github.com/oisee/minz/issues.
MIT. See LICENSE for details.
MinZ: Modern syntax for vintage hardware.




