Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions docs/Nanz_Language_Book_v4.md
Original file line number Diff line number Diff line change
Expand Up @@ -1165,6 +1165,46 @@ The compiler emits:

The `@z80_*` annotations override PBQP for those parameters.

Externs may also declare which physical Z80 registers their implementation writes:

```nanz
@extern(clobbers: "A, HL, F") fun device_read(addr: u16) -> u8
@extern(0x1234, clobbers: "A, BC, DE, HL, F") fun rom_entry() -> u8
@extern(clobbers: "") fun preserve_all() -> void
```

Without `clobbers`, calls conservatively clobber every allocatable register.
An empty string explicitly promises preservation of all registers except return
registers. Names are case insensitive; supported names are `A`, `F`, `B`, `C`,
`D`, `E`, `H`, `L`, `AF`, `BC`, `DE`, `HL`, `IX`, `IY`, `IXH`, `IXL`, `IYH`,
and `IYL`. Pair and half-register overlaps are respected. Unknown names and
unsupported registers such as `SP` produce a compile error.

`AF'`, the EXX shadow registers (`BC'`, `DE'`, `HL'`), `I`, and `R` are
untracked by these declarations: the allocator and caller-save machinery do not
model their liveness or preservation. `AF'` is rejected because accepting it
would imply a preservation guarantee that caller-save cannot enforce; `AF` names
only the primary accumulator and flags.

Extern symbols have one ABI. An `@extern` cannot share a name with a function
body, and conflicting extern redeclarations (including parameter types, ABI,
address, return types, or clobber contracts) are rejected. Extern overloads are
unsupported. Identical declarations may be repeated, for example by imports;
ambiguous names retain conservative caller saves.

The declaration is a contract the implementer must keep, including writes by
nested calls. Declaring fewer writes than the implementation makes can silently
corrupt live caller values; there is no runtime contract check. Return registers
are always treated as writes. Caller argument setup is accounted for separately;
complex shuffles retain conservative saves. These declarations apply to real
externs, not compiler intrinsics.

`peek` and `poke` in the self-hosting examples are MZV host functions (see
`cmd/mzv/file_host.go`), not inline Z80 intrinsics. Their names alone do not prove
any Z80 preservation guarantee, so their declarations remain unannotated.
Likewise, disassembler ROM/BDOS profiles describe consumed arguments rather than
authoritative preserved-register sets; they are insufficient to narrow calls.

### 10.4 The ABI Is Not Fixed

Unlike traditional languages, Nanz does not have a fixed calling convention. You can observe the chosen convention in the generated assembly comment:
Expand Down
74 changes: 74 additions & 0 deletions minzc/pkg/frill/fix3_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package frill

import (
"github.com/minz/minzc/pkg/pipeline"
"github.com/minz/minzc/pkg/z80asm"
"os"
"path/filepath"
"testing"
)

func TestAdoptedFunctionsEmittedOnce(t *testing.T) {
for _, name := range []string{"functional_demo", "pipe", "showcase", "stdlib_demo"} {
t.Run(name, func(t *testing.T) {
path := "../../../examples/frill/" + name + ".frl"
src, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
hm, err := CompileWithOpts(string(src), path, CompileOpts{BaseDir: filepath.Dir(path)})
if err != nil {
t.Fatal(err)
}
seen := map[string]bool{}
for _, f := range hm.Funcs {
if seen[f.Name] {
t.Fatalf("duplicate function %q", f.Name)
}
seen[f.Name] = true
}
opts := pipeline.DefaultOptions()
opts.AssertMode = "none"
steps, err := pipeline.CompileHIRSteps(hm, opts)
if err != nil {
t.Fatal(err)
}
res, err := z80asm.NewAssembler().AssembleString("ORG 0x8000\n" + steps.Assembly)
if err != nil {
t.Fatal(err)
}
if len(res.Errors) > 0 {
t.Fatal(res.Errors)
}
// MIR2 assertions are independent of emission and register allocation.
if err := pipeline.RunAssertsMIR2(hm, steps.MIR2Module); err != nil {
t.Fatal(err)
}
})
}
}

func TestImportsKeepFirstFunctionBinding(t *testing.T) {
dir := t.TempDir()
for name, src := range map[string]string{
"first.frl": "let step (x : u8) = x + 1",
"second.frl": "let step (x : u8) (y : u8) = x + y",
} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(src), 0600); err != nil {
t.Fatal(err)
}
}
hm, err := CompileWithOpts(`import "first.frl"
import "second.frl"
let main (x : u8) = step x
assert main 4 == 5`, "imports.frl", CompileOpts{BaseDir: dir})
if err != nil {
t.Fatal(err)
}
if len(hm.Funcs) != 2 || len(hm.Funcs[0].Params) != 1 {
t.Fatalf("functions = %+v", hm.Funcs)
}
if _, err := pipeline.CompileHIR(hm); err != nil {
t.Fatal(err)
}
}
22 changes: 20 additions & 2 deletions minzc/pkg/frill/frill.go
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,20 @@ func (p *parser) parseModule() (*hir.Module, error) {
return nil, err
}
if imported != nil {
mod.Funcs = append(mod.Funcs, imported.Funcs...)
// Imports share a namespace. Keep the first binding, matching
// the existing first-definition resolution of imported libraries.
for _, f := range imported.Funcs {
found := false
for _, existing := range mod.Funcs {
if existing.Name == f.Name {
found = true
break
}
}
if !found {
mod.Funcs = append(mod.Funcs, f)
}
}
mod.Structs = append(mod.Structs, imported.Structs...)
mod.Strings = append(mod.Strings, imported.Strings...)
mod.StrKinds = append(mod.StrKinds, imported.StrKinds...)
Expand Down Expand Up @@ -929,6 +942,9 @@ func (p *parser) parseImport() (*hir.Module, error) {
// Register imported function arities and IO status.
// Functions with asm blocks or extern linkage are IO by default.
for _, f := range child.Funcs {
if _, exists := p.arities[f.Name]; exists {
continue
}
p.arities[f.Name] = len(f.Params)
if f.IsIO || f.IsExtern || hasAsmBlock(f) {
p.ioFuncs[f.Name] = true
Expand Down Expand Up @@ -1177,8 +1193,10 @@ func (p *parser) parseLet() (*hir.Func, error) {
// let inc = add 1 → inc becomes __partial_N with its params
if len(fn.Params) == 0 && len(letStmts) == 0 && len(whereStmts) == 0 {
if vr, ok := body.(*hir.VarRefExpr); ok {
for _, af := range p.autoFuncs {
for i, af := range p.autoFuncs {
if af.Name == vr.Name {
// Transfer ownership to the top-level declaration.
p.autoFuncs = append(p.autoFuncs[:i], p.autoFuncs[i+1:]...)
// Adopt: rename partial to fn.Name
af.Name = fn.Name
p.arities[fn.Name] = len(af.Params)
Expand Down
2 changes: 1 addition & 1 deletion minzc/pkg/hir/abi_oracle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ func TestABIRunnerFlagReturn(t *testing.T) {
t.Fatal(err)
}
allocation := mir2.PBQPAllocate(f, mir2.ComputeLiveness(f), mir2.Z80CostTable{})
fixture := hirZ80Fixture{module: m, alloc: allocation, asm: mir2.Z80Codegen(m, allocation)}
fixture := hirZ80Fixture{module: m, alloc: allocation, asm: mustZ80Asm(mir2.Z80Codegen(m, allocation))}
for _, tc := range []struct {
a, b uint8
want int64
Expand Down
8 changes: 8 additions & 0 deletions minzc/pkg/hir/codegen_result_external_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package hir_test

func mustZ80Asm(asm string, err error) string {
if err != nil {
panic(err)
}
return asm
}
17 changes: 9 additions & 8 deletions minzc/pkg/hir/hir.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,15 @@ func (m *Module) FuncByName(name string) *Func {

// Func is a HIR function.
type Func struct {
Name string
Params []Param
RetTy mir2.Ty // mir2.TyVoid for void functions (single-return)
RetTys []mir2.Ty // non-empty → multiple return values (overrides RetTy)
Body *Block // nil for extern
IsExtern bool
ExternAddr uint16 // non-zero → @extern(addr) — call via CALL/RST to fixed address
IsIO bool // true = effectful (IO); false = pure (compile-time safe)
Name string
Params []Param
RetTy mir2.Ty // mir2.TyVoid for void functions (single-return)
RetTys []mir2.Ty // non-empty → multiple return values (overrides RetTy)
Body *Block // nil for extern
ExternClobbers []string // nil = unknown; non-nil = explicit Z80 write contract
IsExtern bool
ExternAddr uint16 // non-zero → @extern(addr) — call via CALL/RST to fixed address
IsIO bool // true = effectful (IO); false = pure (compile-time safe)
}

// Param is a typed, named function parameter.
Expand Down
14 changes: 13 additions & 1 deletion minzc/pkg/hir/lower.go
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,7 @@ func lowerFuncWithFuncNames(m *mir2.Module, f *Func, funcNames map[string]bool,
if f.IsExtern {
mf.Attrs.IsExtern = true
mf.Attrs.ExternAddr = f.ExternAddr
return
mf.Contract.ExternClobbers = f.ExternClobbers
}

// Set return type(s).
Expand All @@ -497,6 +497,18 @@ func lowerFuncWithFuncNames(m *mir2.Module, f *Func, funcNames map[string]bool,
mf.Contract.Returns = []mir2.Return{{Ty: f.RetTy, Class: cls}}
}

if f.IsExtern {
bld := mir2.NewBuilder(mf)
for i, p := range f.Params {
cls := classForParam(p.Ty, i)
if p.RegClass != 0 {
cls = p.RegClass
}
bld.Param(p.Name, p.Ty, cls)
}
return
}

bld := mir2.NewBuilder(mf)
l := &lowerer{
hf: f,
Expand Down
2 changes: 1 addition & 1 deletion minzc/pkg/hir/lower_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func compileHIRFixture(t *testing.T, hm *hir.Module) hirZ80Fixture {
combined.Locs[r] = loc
}
}
return hirZ80Fixture{module: m, alloc: combined, asm: mir2.Z80Codegen(m, combined)}
return hirZ80Fixture{module: m, alloc: combined, asm: mustZ80Asm(mir2.Z80Codegen(m, combined))}
}

// runZ80 assembles and runs; returns (A, HL, error).
Expand Down
6 changes: 5 additions & 1 deletion minzc/pkg/hir/p8_split_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ func TestP8SplitReturnDependency(t *testing.T) {
// Use distinct parameter locations so this tests the forwarding caller
// rather than the codegen's identity-function EQU alias optimization.
ar.Locs[param] = mir2.PhysLoc{Kind: mir2.LocReg, Name: "BC"}
asm := fmt.Sprintf("ORG 0x8000\nLD SP,0xFF00\nLD %s,1234\nCALL compute\nDI\nHALT\n", ar.Loc(param).Name) + mir2.Z80Codegen(mm, ar)
code, cgErr := mir2.Z80Codegen(mm, ar)
if cgErr != nil {
t.Fatalf("codegen: %v", cgErr)
}
asm := fmt.Sprintf("ORG 0x8000\nLD SP,0xFF00\nLD %s,1234\nCALL compute\nDI\nHALT\n", ar.Loc(param).Name) + code
res, e := z80asm.NewAssembler().AssembleString(asm)
if e != nil || len(res.Errors) > 0 {
t.Fatalf("assemble %v %v\n%s", e, res.Errors, asm)
Expand Down
8 changes: 8 additions & 0 deletions minzc/pkg/mir2/codegen_result_external_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package mir2_test

func mustZ80Asm(asm string, err error) string {
if err != nil {
panic(err)
}
return asm
}
8 changes: 8 additions & 0 deletions minzc/pkg/mir2/codegen_result_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package mir2

func mustZ80Asm(asm string, err error) string {
if err != nil {
panic(err)
}
return asm
}
2 changes: 1 addition & 1 deletion minzc/pkg/mir2/condret_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ func TestCondRetSink_Z80Assembly(t *testing.T) {
combined.Locs[r] = loc
}

asm := mir2.Z80Codegen(m, combined)
asm := mustZ80Asm(mir2.Z80Codegen(m, combined))
t.Logf("Z80 output:\n%s", asm)

// Must contain a conditional RET (RET C or RET NC or similar).
Expand Down
6 changes: 3 additions & 3 deletions minzc/pkg/mir2/consteval_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ func TestConstCallElim_PureDouble(t *testing.T) {

lr := mir2.ComputeLiveness(caller)
ar := mir2.Allocate(caller, lr, mir2.Z80CostTable{})
asm := mir2.Z80Codegen(m, ar)
asm := mustZ80Asm(mir2.Z80Codegen(m, ar))
t.Log("\n" + asm)

// caller should now be just: LD A, 84; RET (no CALL double)
Expand Down Expand Up @@ -165,7 +165,7 @@ func TestConstCallElim_FibonacciConstArg(t *testing.T) {
mir2.DeadStoreElim(caller)
lr := mir2.ComputeLiveness(caller)
ar := mir2.Allocate(caller, lr, mir2.Z80CostTable{})
asm := mir2.Z80Codegen(m, ar)
asm := mustZ80Asm(mir2.Z80Codegen(m, ar))
t.Log("\n" + asm)

if strings.Contains(asm, "CALL fibonacci") {
Expand Down Expand Up @@ -204,7 +204,7 @@ func TestConstCallElim_ChainedCalls(t *testing.T) {

lr := mir2.ComputeLiveness(caller)
ar := mir2.Allocate(caller, lr, mir2.Z80CostTable{})
asm := mir2.Z80Codegen(m, ar)
asm := mustZ80Asm(mir2.Z80Codegen(m, ar))
t.Log("\n" + asm)

if strings.Contains(asm, "CALL double") {
Expand Down
8 changes: 4 additions & 4 deletions minzc/pkg/mir2/constfold_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func TestFoldConst_BinaryArith(t *testing.T) {

lr := mir2.ComputeLiveness(f)
ar := mir2.Allocate(f, lr, mir2.Z80CostTable{})
asm := mir2.Z80Codegen(m, ar)
asm := mustZ80Asm(mir2.Z80Codegen(m, ar))
t.Log("\n" + asm)

if !strings.Contains(asm, "LD A, 13") {
Expand Down Expand Up @@ -58,7 +58,7 @@ func TestFoldConst_Chain(t *testing.T) {

lr := mir2.ComputeLiveness(f)
ar := mir2.Allocate(f, lr, mir2.Z80CostTable{})
asm := mir2.Z80Codegen(m, ar)
asm := mustZ80Asm(mir2.Z80Codegen(m, ar))
t.Log("\n" + asm)

if !strings.Contains(asm, "LD A, 7") {
Expand All @@ -83,7 +83,7 @@ func TestFoldConst_Bitwise(t *testing.T) {

lr := mir2.ComputeLiveness(f)
ar := mir2.Allocate(f, lr, mir2.Z80CostTable{})
asm := mir2.Z80Codegen(m, ar)
asm := mustZ80Asm(mir2.Z80Codegen(m, ar))

if !strings.Contains(asm, "LD A, 136") {
t.Errorf("expected LD A, 136 (0xCC & 0xAA folded); got:\n%s", asm)
Expand Down Expand Up @@ -162,7 +162,7 @@ func TestFoldConst_WithPropagation(t *testing.T) {

lr := mir2.ComputeLiveness(f)
ar := mir2.Allocate(f, lr, mir2.Z80CostTable{})
asm := mir2.Z80Codegen(m, ar)
asm := mustZ80Asm(mir2.Z80Codegen(m, ar))
t.Log("\n" + asm)

if !strings.Contains(asm, "LD A, 22") {
Expand Down
4 changes: 2 additions & 2 deletions minzc/pkg/mir2/constprop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func TestConstProp_SinglePredecessor(t *testing.T) {
// The Add instruction should use that constant reg, not p directly.
lr := mir2.ComputeLiveness(f)
ar := mir2.Allocate(f, lr, mir2.Z80CostTable{})
asm := mir2.Z80Codegen(m, ar)
asm := mustZ80Asm(mir2.Z80Codegen(m, ar))
t.Log("\n" + asm)

// The Add instruction should resolve to "ADD A, A" (42+1 doesn't fold,
Expand Down Expand Up @@ -94,7 +94,7 @@ func TestConstProp_BothBranchesConst(t *testing.T) {
mir2.DeadStoreElim(f) // clean up dead uses of original p
lr := mir2.ComputeLiveness(f)
ar := mir2.Allocate(f, lr, mir2.Z80CostTable{})
asm := mir2.Z80Codegen(m, ar)
asm := mustZ80Asm(mir2.Z80Codegen(m, ar))
t.Log("\n" + asm)

// join block should now materialise 7 (not receive p from predecessor).
Expand Down
6 changes: 3 additions & 3 deletions minzc/pkg/mir2/contracts_scale_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func compileOnly(m *mir2.Module) string {
ar.Locs[r] = loc
}
}
return mir2.Z80Codegen(m, ar)
return mustZ80Asm(mir2.Z80Codegen(m, ar))
}

// compileWithOpt runs contract opt then allocates and generates assembly.
Expand All @@ -74,7 +74,7 @@ func compileWithOpt(m *mir2.Module) (string, mir2.ContractSet) {
ar.Locs[r] = loc
}
}
return mir2.Z80Codegen(m, ar), cs
return mustZ80Asm(mir2.Z80Codegen(m, ar)), cs
}

// countLDA counts "LD A," lines in assembly (adapter moves into A).
Expand Down Expand Up @@ -434,7 +434,7 @@ func TestCoalescing_EliminatesMove(t *testing.T) {
ct := mir2.Z80CostTable{}
lr := mir2.ComputeLiveness(f)
ar := mir2.Allocate(f, lr, ct)
asm := mir2.Z80Codegen(m, ar)
asm := mustZ80Asm(mir2.Z80Codegen(m, ar))
t.Logf("assembly:\n%s", asm)

// Coalescing: r2 should land at the same loc as r1 (A).
Expand Down
Loading
Loading