diff --git a/docs/Nanz_Language_Book_v4.md b/docs/Nanz_Language_Book_v4.md index 6221f5946..96c3917c5 100644 --- a/docs/Nanz_Language_Book_v4.md +++ b/docs/Nanz_Language_Book_v4.md @@ -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: diff --git a/minzc/pkg/frill/fix3_test.go b/minzc/pkg/frill/fix3_test.go new file mode 100644 index 000000000..7ec3b43ee --- /dev/null +++ b/minzc/pkg/frill/fix3_test.go @@ -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) + } +} diff --git a/minzc/pkg/frill/frill.go b/minzc/pkg/frill/frill.go index ca508ab70..3c5706620 100644 --- a/minzc/pkg/frill/frill.go +++ b/minzc/pkg/frill/frill.go @@ -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...) @@ -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 @@ -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) diff --git a/minzc/pkg/hir/abi_oracle_test.go b/minzc/pkg/hir/abi_oracle_test.go index 53ffa3d6f..f5a491206 100644 --- a/minzc/pkg/hir/abi_oracle_test.go +++ b/minzc/pkg/hir/abi_oracle_test.go @@ -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 diff --git a/minzc/pkg/hir/codegen_result_external_test.go b/minzc/pkg/hir/codegen_result_external_test.go new file mode 100644 index 000000000..73b374524 --- /dev/null +++ b/minzc/pkg/hir/codegen_result_external_test.go @@ -0,0 +1,8 @@ +package hir_test + +func mustZ80Asm(asm string, err error) string { + if err != nil { + panic(err) + } + return asm +} diff --git a/minzc/pkg/hir/hir.go b/minzc/pkg/hir/hir.go index c6340bacf..9e9e10312 100644 --- a/minzc/pkg/hir/hir.go +++ b/minzc/pkg/hir/hir.go @@ -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. diff --git a/minzc/pkg/hir/lower.go b/minzc/pkg/hir/lower.go index 75f6027c1..2746ec6e4 100644 --- a/minzc/pkg/hir/lower.go +++ b/minzc/pkg/hir/lower.go @@ -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). @@ -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, diff --git a/minzc/pkg/hir/lower_test.go b/minzc/pkg/hir/lower_test.go index 4ed38c82e..9730261fe 100644 --- a/minzc/pkg/hir/lower_test.go +++ b/minzc/pkg/hir/lower_test.go @@ -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). diff --git a/minzc/pkg/hir/p8_split_test.go b/minzc/pkg/hir/p8_split_test.go index 65f93aa2c..5ead1a717 100644 --- a/minzc/pkg/hir/p8_split_test.go +++ b/minzc/pkg/hir/p8_split_test.go @@ -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) diff --git a/minzc/pkg/mir2/codegen_result_external_test.go b/minzc/pkg/mir2/codegen_result_external_test.go new file mode 100644 index 000000000..99185d0e3 --- /dev/null +++ b/minzc/pkg/mir2/codegen_result_external_test.go @@ -0,0 +1,8 @@ +package mir2_test + +func mustZ80Asm(asm string, err error) string { + if err != nil { + panic(err) + } + return asm +} diff --git a/minzc/pkg/mir2/codegen_result_internal_test.go b/minzc/pkg/mir2/codegen_result_internal_test.go new file mode 100644 index 000000000..bbcac7074 --- /dev/null +++ b/minzc/pkg/mir2/codegen_result_internal_test.go @@ -0,0 +1,8 @@ +package mir2 + +func mustZ80Asm(asm string, err error) string { + if err != nil { + panic(err) + } + return asm +} diff --git a/minzc/pkg/mir2/condret_test.go b/minzc/pkg/mir2/condret_test.go index 7671075a8..c93507605 100644 --- a/minzc/pkg/mir2/condret_test.go +++ b/minzc/pkg/mir2/condret_test.go @@ -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). diff --git a/minzc/pkg/mir2/consteval_test.go b/minzc/pkg/mir2/consteval_test.go index c7b4d9fe5..225303796 100644 --- a/minzc/pkg/mir2/consteval_test.go +++ b/minzc/pkg/mir2/consteval_test.go @@ -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) @@ -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") { @@ -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") { diff --git a/minzc/pkg/mir2/constfold_test.go b/minzc/pkg/mir2/constfold_test.go index 9bfc4f655..a2c9551cf 100644 --- a/minzc/pkg/mir2/constfold_test.go +++ b/minzc/pkg/mir2/constfold_test.go @@ -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") { @@ -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") { @@ -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) @@ -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") { diff --git a/minzc/pkg/mir2/constprop_test.go b/minzc/pkg/mir2/constprop_test.go index ab284bdf1..ee72844bc 100644 --- a/minzc/pkg/mir2/constprop_test.go +++ b/minzc/pkg/mir2/constprop_test.go @@ -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, @@ -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). diff --git a/minzc/pkg/mir2/contracts_scale_test.go b/minzc/pkg/mir2/contracts_scale_test.go index f38e3ffdb..4954c4098 100644 --- a/minzc/pkg/mir2/contracts_scale_test.go +++ b/minzc/pkg/mir2/contracts_scale_test.go @@ -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. @@ -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). @@ -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). diff --git a/minzc/pkg/mir2/contracts_test.go b/minzc/pkg/mir2/contracts_test.go index 137278753..c39c1e77f 100644 --- a/minzc/pkg/mir2/contracts_test.go +++ b/minzc/pkg/mir2/contracts_test.go @@ -223,7 +223,7 @@ func TestContractOptimize_PreservesOutput(t *testing.T) { ar.Locs[r] = loc } } - asm := mir2.Z80Codegen(m, ar) + asm := mustZ80Asm(mir2.Z80Codegen(m, ar)) t.Logf("double_sum assembly after contract optimisation:\n%s", asm) cases := [][3]int{{1, 2, 6}, {3, 4, 14}, {0, 5, 10}, {63, 64, 254}} @@ -496,7 +496,7 @@ func TestContractOptimize_BeforeAfterComparison(t *testing.T) { ar.Locs[r] = loc } } - return mir2.Z80Codegen(m, ar) + return mustZ80Asm(mir2.Z80Codegen(m, ar)) } // ── WITHOUT contract optimisation ──────────────────────────────────────── diff --git a/minzc/pkg/mir2/e2e_test.go b/minzc/pkg/mir2/e2e_test.go index 712ab5f00..4d53778f9 100644 --- a/minzc/pkg/mir2/e2e_test.go +++ b/minzc/pkg/mir2/e2e_test.go @@ -33,7 +33,7 @@ func TestE2EFibonacciZ80(t *testing.T) { for _, f := range m.Funcs { lr := mir2.ComputeLiveness(f) ar := mir2.Allocate(f, lr, mir2.Z80CostTable{}) - fibAsm = mir2.Z80Codegen(m, ar) + fibAsm = mustZ80Asm(mir2.Z80Codegen(m, ar)) } t.Logf("generated assembly:\n%s", fibAsm) @@ -225,7 +225,7 @@ func TestE2EFlagReturn(t *testing.T) { ar.Locs[r] = loc } } - fullAsm = mir2.Z80Codegen(m, ar) + fullAsm = mustZ80Asm(mir2.Z80Codegen(m, ar)) t.Logf("flag-return assembly:\n%s", fullAsm) // ── Verify: isLess uses C flag (no AND A / CP 0 in body) ───────────────── diff --git a/minzc/pkg/mir2/examples_test.go b/minzc/pkg/mir2/examples_test.go index 64f322d38..fc9785d9f 100644 --- a/minzc/pkg/mir2/examples_test.go +++ b/minzc/pkg/mir2/examples_test.go @@ -437,7 +437,7 @@ func compileModule(t *testing.T, m *mir2.Module) string { combined.Locs[r] = loc } } - return mir2.Z80Codegen(m, combined) + return mustZ80Asm(mir2.Z80Codegen(m, combined)) } // compileOptModule runs the full optimization pass (CondRetSink + fusionSubCmpInBlock) @@ -469,7 +469,7 @@ func compileOptModule(t *testing.T, m *mir2.Module) string { combined.Locs[r] = loc } } - return mir2.Z80Codegen(m, combined) + return mustZ80Asm(mir2.Z80Codegen(m, combined)) } const testLoadAddr = 0x8000 @@ -1149,7 +1149,7 @@ func TestMax2PipelineZ80(t *testing.T) { for r, loc := range ar.Locs { combined.Locs[r] = loc } - asm := mir2.Z80Codegen(m, combined) + asm := mustZ80Asm(mir2.Z80Codegen(m, combined)) t.Log("\n" + asm) cases := []struct{ a, b, want int }{ @@ -1227,7 +1227,7 @@ func TestGcdCondRetZ80(t *testing.T) { for r, loc := range ar.Locs { combined.Locs[r] = loc } - asm := mir2.Z80Codegen(m, combined) + asm := mustZ80Asm(mir2.Z80Codegen(m, combined)) t.Log("\n" + asm) // The critical test: gcd(12, 0) must return 12, not 0 diff --git a/minzc/pkg/mir2/extern_clobbers_test.go b/minzc/pkg/mir2/extern_clobbers_test.go new file mode 100644 index 000000000..7fe4c257b --- /dev/null +++ b/minzc/pkg/mir2/extern_clobbers_test.go @@ -0,0 +1,196 @@ +package mir2 + +import ( + "reflect" + "strings" + "testing" + + "github.com/minz/minzc/pkg/emulator" + "github.com/minz/minzc/pkg/z80asm" +) + +func externCallFixture(loc string, clobbers []string) (*z80cg, *Inst, *Func) { + m := &Module{Name: "extern_test"} + f := m.AddFunc("caller") + ext := m.AddFunc("ext") + ext.Attrs.IsExtern = true + ext.Contract.ExternClobbers = clobbers + inst := &Inst{Op: OpCall, Sym: "ext", Dst: NoReg} + blk := &Block{Label: "entry", Insts: []*Inst{inst}, Term: &TermRet{Vals: []Reg{1}}} + f.Blocks = []*Block{blk} + g := &z80cg{mod: m, fn: f, curBlock: blk, ar: &AllocResult{Locs: map[Reg]PhysLoc{1: {Kind: LocReg, Name: loc}}}, sb: &strings.Builder{}, physOverride: map[Reg]string{}, holdsPhys: map[string]string{}, callFlags: map[Reg]CmpCond{}} + return g, inst, ext +} + +func TestExternCallerSaveContracts(t *testing.T) { + for _, tc := range []struct { + name, loc string + clobbers []string + want []string + }{ + {"default", "BC", nil, []string{"BC"}}, + {"empty", "BC", []string{}, nil}, + {"byte overlaps pair", "BC", []string{"C"}, []string{"BC"}}, + {"pair overlaps byte", "C", []string{"BC"}, []string{"BC"}}, + {"index overlaps half", "IXH", []string{"IX"}, []string{"IX"}}, + {"half overlaps index", "IY", []string{"IYL"}, []string{"IY"}}, + {"preserved", "DE", []string{"A", "HL", "F"}, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + g, inst, ext := externCallFixture(tc.loc, tc.clobbers) + if got := g.callerSavePairs(inst, ext); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("got %v, want %v", got, tc.want) + } + }) + } + // Return registers and argument setup remain writes under an empty contract. + g, inst, ext := externCallFixture("A", []string{}) + ext.Contract.Returns = []Return{{Ty: TyU8, Class: ClassAcc}} + if got := g.callerSavePairs(inst, ext); !reflect.DeepEqual(got, []string{"AF"}) { + t.Fatal(got) + } + ext.Contract.Returns = nil + ext.Contract.Params = []Param{{Reg: 2, Ty: TyU8, Class: ClassAcc}} + g.ar.Locs[3] = PhysLoc{Kind: LocReg, Name: "C"} + inst.Args = []Reg{3} + if got := g.callerSavePairs(inst, ext); !reflect.DeepEqual(got, []string{"AF"}) { + t.Fatal(got) + } + // An unresolved symbol must remain conservative too. + if got := g.callerSavePairs(inst, nil); !reflect.DeepEqual(got, []string{"AF"}) { + t.Fatal(got) + } +} + +func runExternJudge(t *testing.T, source string) emulator.Registers { + t.Helper() + res, err := z80asm.NewAssembler().AssembleString(source) + if err != nil || len(res.Errors) > 0 { + t.Fatalf("assemble: %v %v\n%s", err, res.Errors, source) + } + z := emulator.NewRemogattoZ80() + z.Reset() + z.LoadMemory(0x8000, res.Binary) + z.SetRegisters(emulator.Registers{PC: 0x8000, SP: 0xff00}) + for n := 0; !z.IsHalted(); n++ { + if n >= 10000 { + t.Fatal("no HALT") + } + z.Step() + } + return z.GetRegisters() +} + +func TestExternAssemblyContractJudge(t *testing.T) { + for _, tc := range []struct { + name string + clobbers []string + stub string + want byte + save bool + }{ + {"declared writes preserved", []string{"C"}, "LD C, 99", 42, true}, + {"preserved register needs no save", []string{"A"}, "LD A, 99", 42, false}, + {"unknown saves everything", nil, "LD C, 99", 42, true}, + // A lying declaration is a user contract violation, not something the + // compiler can detect. Judge the observed wrong value explicitly. + {"contract violation", []string{"A"}, "LD A, 99\nLD C, 99", 99, false}, + } { + t.Run(tc.name, func(t *testing.T) { + g, inst, _ := externCallFixture("C", tc.clobbers) + g.genCall(inst) + asm := g.sb.String() + if strings.Contains(asm, "PUSH BC") != tc.save { + t.Fatalf("caller saves: %s", asm) + } + regs := runExternJudge(t, "ORG 0x8000\nLD C, 42\n"+asm+"LD A, C\nHALT\next:\n"+tc.stub+"\nRET\n") + if regs.A != tc.want || regs.SP != 0xff00 { + t.Fatalf("A=%d SP=%04x; want A=%d SP=ff00\n%s", regs.A, regs.SP, tc.want, asm) + } + }) + } +} + +func TestExternResultPickupPreservesUnsavedLiveScratch(t *testing.T) { + g, inst, ext := externCallFixture("C", []string{"A", "C"}) + inst.Dst, inst.Ty = 3, TyU8 + ext.Contract.Returns = []Return{{Ty: TyU8, Class: ClassAcc}} + g.ar.Locs[2] = PhysLoc{Kind: LocReg, Name: "E"} + g.ar.Locs[3] = PhysLoc{Kind: LocReg, Name: "B"} + g.ar.Locs[4] = PhysLoc{Kind: LocReg, Name: "A"} + g.curBlock.Term = &TermRet{Vals: []Reg{1, 2, 3, 4}} + g.genCall(inst) + asm := g.sb.String() + regs := runExternJudge(t, "ORG 0x8000\nLD A, 17\nLD C, 42\nLD E, 7\n"+asm+"HALT\next:\nLD A, 99\nLD C, 22\nRET\n") + if regs.A != 17 || regs.BC != 99<<8|42 || byte(regs.DE) != 7 { + t.Fatalf("pickup corrupted live register: %+v\n%s", regs, asm) + } +} + +func TestUnresolvedExternCallPreservesLiveValues(t *testing.T) { + g, inst, _ := externCallFixture("C", nil) + inst.Sym = "missing" + g.genCall(inst) + regs := runExternJudge(t, "ORG 0x8000\nLD C, 42\n"+g.sb.String()+"LD A,C\nHALT\nmissing:\nLD C,99\nRET\n") + if regs.A != 42 { + t.Fatalf("unresolved call lost live C: %+v", regs) + } +} + +func TestExternAmbiguousCalleeRejected(t *testing.T) { + for _, name := range []string{"ext", "e$t"} { + t.Run(name, func(t *testing.T) { + g, inst, ext := externCallFixture("C", []string{}) + if name == "e$t" { + ext.Name, inst.Sym = "e_t", "e_t" + } + g.mod.AddFunc(name) + if err := ValidateZ80Symbols(g.mod); err == nil { + t.Fatal("accepted ambiguous emitted symbol") + } + if err := g.genCall(inst); err == nil { + t.Fatal("ambiguous call did not fail") + } + }) + } +} + +func TestExternWithBodyStaysConservative(t *testing.T) { + g, inst, ext := externCallFixture("C", []string{}) + ext.Blocks = []*Block{{Label: "entry"}} + if got := computeClobbers(ext, g.ar); !reflect.DeepEqual(got, allZ80Clobbers) { + t.Fatalf("extern with body narrowed writes: %v", got) + } + if got := g.callerSavePairs(inst, ext); !reflect.DeepEqual(got, []string{"BC"}) { + t.Fatalf("extern with body did not save live C: %v", got) + } +} + +func TestZ80EmittedSymbolUniqueness(t *testing.T) { + for _, tc := range []struct { + name, first, second string + global bool + }{ + {"register rename", "f", "v_f", false}, + {"generated split", "x$split_1", "x_split_1", false}, + {"extern versus data", "ext", "ext", true}, + } { + t.Run(tc.name, func(t *testing.T) { + m := &Module{} + f := m.AddFunc(tc.first) + f.Attrs.IsExtern = true + f.Contract.ExternClobbers = []string{} + if tc.global { + m.Globals = append(m.Globals, Global{Name: tc.second}) + } else { + m.AddFunc(tc.second) + } + if err := ValidateZ80Symbols(m); err == nil { + t.Fatal("accepted colliding symbol") + } + if asm, err := Z80Codegen(m, &AllocResult{}); err == nil || asm != "" { + t.Fatalf("codegen = %q, %v", asm, err) + } + }) + } +} diff --git a/minzc/pkg/mir2/fix1_z80_test.go b/minzc/pkg/mir2/fix1_z80_test.go index e193528df..68a26278f 100644 --- a/minzc/pkg/mir2/fix1_z80_test.go +++ b/minzc/pkg/mir2/fix1_z80_test.go @@ -55,7 +55,7 @@ func TestFix1WordOverlap(t *testing.T) { case mir2.OpXor: want = x ^ y } - asm := "ORG 0x8000\nLD SP,0xFF00\nLD BC,4660\nLD DE,17767\nLD HL,35243\nCALL arith\nDI\nHALT\n" + mir2.Z80Codegen(m, alloc) + asm := "ORG 0x8000\nLD SP,0xFF00\nLD BC,4660\nLD DE,17767\nLD HL,35243\nCALL arith\nDI\nHALT\n" + mustZ80Asm(mir2.Z80Codegen(m, alloc)) r := fix1Execute(t, asm) if r.HL != want { t.Fatalf("%s %s,%s -> %s got %x want %x\n%s", op, lhs, rhs, dst, r.HL, want, asm) @@ -88,7 +88,7 @@ func TestFix1SignedSpillOperands(t *testing.T) { } else { setup += fmt.Sprintf("LD %s,2\n", loc.Name) } - asm := "ORG 0x8000\nLD SP,0xFF00\n" + setup + "CALL arith\nDI\nHALT\n" + mir2.Z80Codegen(m, alloc) + asm := "ORG 0x8000\nLD SP,0xFF00\n" + setup + "CALL arith\nDI\nHALT\n" + mustZ80Asm(mir2.Z80Codegen(m, alloc)) got := fix1Execute(t, asm).HL want := uint16(65533) if op == mir2.OpSMod { @@ -115,7 +115,7 @@ func TestFix1PointerOverlap(t *testing.T) { out := b.PtrAdd(base, off, mir2.ClassPointer) b.Ret(out) alloc := &mir2.AllocResult{Locs: map[mir2.Reg]mir2.PhysLoc{base: {Kind: mir2.LocReg, Name: "BC"}, off: {Kind: mir2.LocReg, Name: "HL"}, out: {Kind: mir2.LocReg, Name: "HL"}}} - asm := "ORG 0x8000\nLD SP,0xFF00\nLD BC,4660\nLD HL,5\nCALL arith\nDI\nHALT\n" + mir2.Z80Codegen(m, alloc) + asm := "ORG 0x8000\nLD SP,0xFF00\nLD BC,4660\nLD HL,5\nCALL arith\nDI\nHALT\n" + mustZ80Asm(mir2.Z80Codegen(m, alloc)) if got := fix1Execute(t, asm).HL; got != 4665 { t.Fatalf("base+offset got %d want 4665\n%s", got, asm) } @@ -144,7 +144,7 @@ func TestFix1NarrowWordCompare(t *testing.T) { b.Ret(zero) alloc := &mir2.AllocResult{Locs: map[mir2.Reg]mir2.PhysLoc{a: {Kind: mir2.LocReg, Name: "D"}, w: {Kind: mir2.LocReg, Name: "HL"}, c: {Kind: mir2.LocReg, Name: "F"}, one: {Kind: mir2.LocReg, Name: "A"}, zero: {Kind: mir2.LocReg, Name: "A"}}} for _, word := range []int{254, 255, 256, 300, 65535} { - asm := fmt.Sprintf("ORG 0x8000\nLD SP,0xFF00\nLD D,255\nLD HL,%d\nCALL arith\nDI\nHALT\n", word) + mir2.Z80Codegen(m, alloc) + asm := fmt.Sprintf("ORG 0x8000\nLD SP,0xFF00\nLD D,255\nLD HL,%d\nCALL arith\nDI\nHALT\n", word) + mustZ80Asm(mir2.Z80Codegen(m, alloc)) want := uint8(0) if word > 255 { want = 1 @@ -169,7 +169,7 @@ func TestFix1ByteResultWithLiveA(t *testing.T) { out := b.Add(a, div, mir2.TyU8, mir2.ClassAcc) b.Ret(out) alloc := &mir2.AllocResult{Locs: map[mir2.Reg]mir2.PhysLoc{x: {Kind: mir2.LocReg, Name: "B"}, a: {Kind: mir2.LocReg, Name: "A"}, two: {Kind: mir2.LocReg, Name: "D"}, div: {Kind: mir2.LocReg, Name: "C"}, out: {Kind: mir2.LocReg, Name: "A"}}} - asm := "ORG 0x8000\nLD SP,0xFF00\nLD B,10\nLD A,7\nCALL arith\nDI\nHALT\n" + mir2.Z80Codegen(m, alloc) + asm := "ORG 0x8000\nLD SP,0xFF00\nLD B,10\nLD A,7\nCALL arith\nDI\nHALT\n" + mustZ80Asm(mir2.Z80Codegen(m, alloc)) if got := fix1Execute(t, asm).A; got != 12 { t.Fatalf("10/2+7: got %d want 12\n%s", got, asm) } @@ -189,7 +189,7 @@ func TestFix1BitPredicateConstantOverlap(t *testing.T) { b.Ret(out) alloc := &mir2.AllocResult{Locs: map[mir2.Reg]mir2.PhysLoc{x: {Kind: mir2.LocReg, Name: "HL"}, one: {Kind: mir2.LocReg, Name: "BC"}, masked: {Kind: mir2.LocReg, Name: "DE"}, zero: {Kind: mir2.LocReg, Name: "HL"}, out: {Kind: mir2.LocReg, Name: "F"}}} for _, v := range []int{0, 1, 2, 7, 255, 256, 257} { - asm := fmt.Sprintf("ORG 0x8000\nLD SP,0xFF00\nLD HL,%d\nCALL arith\nDI\nHALT\n", v) + mir2.Z80Codegen(m, alloc) + asm := fmt.Sprintf("ORG 0x8000\nLD SP,0xFF00\nLD HL,%d\nCALL arith\nDI\nHALT\n", v) + mustZ80Asm(mir2.Z80Codegen(m, alloc)) want := uint8(0) if v%2 == 0 { want = 1 diff --git a/minzc/pkg/mir2/fix3_symbols_test.go b/minzc/pkg/mir2/fix3_symbols_test.go new file mode 100644 index 000000000..37981b1ad --- /dev/null +++ b/minzc/pkg/mir2/fix3_symbols_test.go @@ -0,0 +1,45 @@ +package mir2 + +import "testing" + +func TestZ80RuntimeSymbolsReserved(t *testing.T) { + for _, name := range []string{"__mul8", "__mul16", "__call_ix", "__rotate_0", "__rotate_4", "__rotate_7", "_mir2_str_0", "_mir2_str_999", "@mir2.str.0", "_spill_main_r1", "_tsmc_main_r1_0"} { + for _, global := range []bool{false, true} { + t.Run(name, func(t *testing.T) { + m := &Module{} + if global { + m.Globals = []Global{{Name: name, Ty: TyU8}} + } else { + f := m.AddFunc(name) + f.Attrs.IsExtern = true + f.Contract.ExternClobbers = []string{} + } + if err := ValidateZ80Symbols(m); err == nil { + t.Fatal("accepted reserved symbol") + } + if asm, err := Z80Codegen(m, &AllocResult{}); err == nil || asm != "" { + t.Fatalf("codegen = %q, %v", asm, err) + } + }) + } + } +} + +func TestZ80GeneratedAliasesReserved(t *testing.T) { + t.Run("field", func(t *testing.T) { + m := &Module{Globals: []Global{{Name: "obj", Ty: &StructTy{Name: "S", Fields: []StructField{{Name: "value", Ty: TyU8}}}}}} + m.AddFunc("obj__value") + if err := ValidateZ80Symbols(m); err == nil { + t.Fatal("accepted field alias collision") + } + }) + t.Run("patcher", func(t *testing.T) { + m := &Module{} + f := m.AddFunc("draw") + f.Blocks = []*Block{{Insts: []*Inst{{Op: OpPatchSlot, Sym: "draw$x", Ty: TyU16}}}} + m.AddFunc("draw_set_x") + if err := ValidateZ80Symbols(m); err == nil { + t.Fatal("accepted patcher collision") + } + }) +} diff --git a/minzc/pkg/mir2/func.go b/minzc/pkg/mir2/func.go index c803c015e..ff0208aaf 100644 --- a/minzc/pkg/mir2/func.go +++ b/minzc/pkg/mir2/func.go @@ -21,9 +21,10 @@ type Func struct { // instead of physical register names. Backends map classes to physical // resources for each target. type Contract struct { - Params []Param // ordered parameter slots - Returns []Return // ordered return value slots (multi-return = len > 1) - Clobbers ClassSet // register classes modified by this function (caller-save) + Params []Param // ordered parameter slots + Returns []Return // ordered return value slots (multi-return = len > 1) + ExternClobbers []string // nil = unknown; non-nil = declared physical Z80 writes + Clobbers ClassSet // register classes modified by this function (caller-save) } // Param is one parameter: a named, typed, class-constrained value that arrives diff --git a/minzc/pkg/mir2/layout_test.go b/minzc/pkg/mir2/layout_test.go index 4e68f1c37..fe1d7e551 100644 --- a/minzc/pkg/mir2/layout_test.go +++ b/minzc/pkg/mir2/layout_test.go @@ -198,7 +198,7 @@ func TestLayoutFibonacciHotPathFallThrough(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) diff --git a/minzc/pkg/mir2/lutgen_test.go b/minzc/pkg/mir2/lutgen_test.go index 3071e1c17..559aafa5b 100644 --- a/minzc/pkg/mir2/lutgen_test.go +++ b/minzc/pkg/mir2/lutgen_test.go @@ -79,7 +79,7 @@ func TestLUTGen_LookupBodyCodegen(t *testing.T) { mir2.DeadStoreElim(f) 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) // Body must reference the LUT, not do arithmetic. @@ -131,7 +131,7 @@ func TestLUTGen_PageAlignedFastPath(t *testing.T) { mir2.DeadStoreElim(f) 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) // Fast path uses LD L, not ADD HL, DE. diff --git a/minzc/pkg/mir2/m6502_showcase_test.go b/minzc/pkg/mir2/m6502_showcase_test.go index 4177fd5f7..57c10e4e8 100644 --- a/minzc/pkg/mir2/m6502_showcase_test.go +++ b/minzc/pkg/mir2/m6502_showcase_test.go @@ -28,7 +28,7 @@ func compileDual(t *testing.T, m *mir2.Module) (z80asm, m6502asm string) { z80combined.Locs[r] = loc } } - z80asm = mir2.Z80Codegen(m, z80combined) + z80asm = mustZ80Asm(mir2.Z80Codegen(m, z80combined)) // 6502 m6502combined := &mir2.AllocResult{Locs: make(map[mir2.Reg]mir2.PhysLoc)} diff --git a/minzc/pkg/mir2/p8_fix1_test.go b/minzc/pkg/mir2/p8_fix1_test.go index 8dae633f8..d54fdfb8d 100644 --- a/minzc/pkg/mir2/p8_fix1_test.go +++ b/minzc/pkg/mir2/p8_fix1_test.go @@ -41,7 +41,7 @@ func testConstBlockParamSpill(t *testing.T, ty mir2.Ty, value int64) { f.Contract.Returns = nil b.Ret() ar := &mir2.AllocResult{Locs: map[mir2.Reg]mir2.PhysLoc{p: {Kind: mir2.LocMem}, x: {Kind: mir2.LocDWord, Name: "DE"}}} - code := mir2.Z80Codegen(m, ar) + code := mustZ80Asm(mir2.Z80Codegen(m, ar)) for i := 0; i < mir2.ByteWidth(ty); i++ { // Poison every byte so an omitted store cannot pass via zero-initialised data. pre := "ORG 0x8000\nLD SP,0xFF00\nLD A,255\n" @@ -57,7 +57,7 @@ func testConstBlockParamSpill(t *testing.T, ty mir2.Ty, value int64) { r := b.Move(p, ty, class) b.Ret(r) ar := &mir2.AllocResult{Locs: map[mir2.Reg]mir2.PhysLoc{p: {Kind: mir2.LocMem}, x: {Kind: mir2.LocReg, Name: "DE"}, r: {Kind: mir2.LocReg, Name: "HL"}}} - asm := fmt.Sprintf("ORG 0x8000\nLD SP,0xFF00\nLD HL,0xFFFF\nLD (_spill_arith_r%d),HL\nCALL arith\nDI\nHALT\n", p) + mir2.Z80Codegen(m, ar) + asm := fmt.Sprintf("ORG 0x8000\nLD SP,0xFF00\nLD HL,0xFFFF\nLD (_spill_arith_r%d),HL\nCALL arith\nDI\nHALT\n", p) + mustZ80Asm(mir2.Z80Codegen(m, ar)) if got := fix1Execute(t, asm).HL; got != uint16(value) { t.Fatalf("got %x want %x\n%s", got, value, asm) } @@ -79,7 +79,7 @@ func TestP8AddressSpillIsWord(t *testing.T) { if explicit { ar.Spilled = []mir2.Reg{r} } - asm := mir2.Z80Codegen(m, ar) + asm := mustZ80Asm(mir2.Z80Codegen(m, ar)) slot := fmt.Sprintf("_spill_arith_r%d:", r) if !strings.Contains(asm, slot+" DW 0") && !strings.Contains(asm, slot+" DB 0, 0\n") { t.Fatalf("expected two-byte slot\n%s", asm) diff --git a/minzc/pkg/mir2/p8_invalid_asm_test.go b/minzc/pkg/mir2/p8_invalid_asm_test.go index e86e4d2e7..c61140f66 100644 --- a/minzc/pkg/mir2/p8_invalid_asm_test.go +++ b/minzc/pkg/mir2/p8_invalid_asm_test.go @@ -23,7 +23,7 @@ func TestP8SpilledByteExtension(t *testing.T) { } b.Ret(out) ar := &mir2.AllocResult{Locs: map[mir2.Reg]mir2.PhysLoc{a: {Kind: mir2.LocReg, Name: "C"}, out: {Kind: mir2.LocMem}}} - asm := "ORG 0x8000\nLD SP,0xFF00\nLD C,254\nCALL arith\nDI\nHALT\n" + mir2.Z80Codegen(m, ar) + asm := "ORG 0x8000\nLD SP,0xFF00\nLD C,254\nCALL arith\nDI\nHALT\n" + mustZ80Asm(mir2.Z80Codegen(m, ar)) want := uint16(254) if signed { want = 65534 @@ -48,7 +48,7 @@ func TestP8ByteALUWithAccumulatorRHS(t *testing.T) { out := b.BinOp(op, a, c, mir2.TyU8, mir2.ClassAcc) b.Ret(out) ar := &mir2.AllocResult{Locs: map[mir2.Reg]mir2.PhysLoc{a: {Kind: mir2.LocMem}, c: {Kind: mir2.LocReg, Name: "A"}, out: {Kind: mir2.LocReg, Name: "A"}}} - asm := fmt.Sprintf("ORG 0x8000\nLD SP,0xFF00\nLD A,29\nLD (_spill_arith_r%d),A\nLD A,7\nCALL arith\nDI\nHALT\n", a) + mir2.Z80Codegen(m, ar) + asm := fmt.Sprintf("ORG 0x8000\nLD SP,0xFF00\nLD A,29\nLD (_spill_arith_r%d),A\nLD A,7\nCALL arith\nDI\nHALT\n", a) + mustZ80Asm(mir2.Z80Codegen(m, ar)) want := uint8(36) if op == mir2.OpSub { want = 22 @@ -71,7 +71,7 @@ func TestP8SubtractIndexByte(t *testing.T) { out := b.Sub(a, c, mir2.TyU16, mir2.ClassPair) b.Ret(out) ar := &mir2.AllocResult{Locs: map[mir2.Reg]mir2.PhysLoc{a: {Kind: mir2.LocReg, Name: "HL"}, c: {Kind: mir2.LocIXY8, Name: "IYL"}, out: {Kind: mir2.LocReg, Name: "HL"}}} - asm := "ORG 0x8000\nLD SP,0xFF00\nLD HL,1000\nLD IY,7\nCALL arith\nDI\nHALT\n" + mir2.Z80Codegen(m, ar) + asm := "ORG 0x8000\nLD SP,0xFF00\nLD HL,1000\nLD IY,7\nCALL arith\nDI\nHALT\n" + mustZ80Asm(mir2.Z80Codegen(m, ar)) if got := fix1Execute(t, asm).HL; got != 993 { t.Fatalf("got %d want 993", got) } @@ -92,7 +92,7 @@ func TestP8SpilledConstantPreservesAccumulator(t *testing.T) { if err != nil { t.Fatal(err) } - asm := "ORG 0x8000\nLD SP,0xFF00\nLD A,29\nCALL arith\nDI\nHALT\n" + mir2.Z80Codegen(m, ar) + asm := "ORG 0x8000\nLD SP,0xFF00\nLD A,29\nCALL arith\nDI\nHALT\n" + mustZ80Asm(mir2.Z80Codegen(m, ar)) if got := fix1Execute(t, asm).A; got != uint8(want[0].I) { t.Fatalf("got %d want %d\n%s", got, want[0].I, asm) } @@ -108,7 +108,7 @@ func TestP8WidenIndexByteToSpill(t *testing.T) { out := b.Move(a, mir2.TyU16, mir2.ClassPair) b.Ret(out) ar := &mir2.AllocResult{Locs: map[mir2.Reg]mir2.PhysLoc{a: {Kind: mir2.LocIXY8, Name: "IYL"}, out: {Kind: mir2.LocMem}}} - asm := "ORG 0x8000\nLD SP,0xFF00\nLD IY,7\nCALL arith\nDI\nHALT\n" + mir2.Z80Codegen(m, ar) + asm := "ORG 0x8000\nLD SP,0xFF00\nLD IY,7\nCALL arith\nDI\nHALT\n" + mustZ80Asm(mir2.Z80Codegen(m, ar)) if got := fix1Execute(t, asm).HL; got != 7 { t.Fatalf("got %d want 7", got) } @@ -131,7 +131,7 @@ func TestP8WideSpill(t *testing.T) { if err != nil { t.Fatal(err) } - asm := "ORG 0x8000\nLD SP,0xFF00\nCALL arith\nPUSH HL\nEXX\nPUSH HL\nEXX\nPOP BC\nPOP HL\nDI\nHALT\n" + mir2.Z80Codegen(m, ar) + asm := "ORG 0x8000\nLD SP,0xFF00\nCALL arith\nPUSH HL\nEXX\nPUSH HL\nEXX\nPOP BC\nPOP HL\nDI\nHALT\n" + mustZ80Asm(mir2.Z80Codegen(m, ar)) regs := fix1Execute(t, asm) got := uint32(regs.HL) | uint32(regs.BC)<<16 if got != uint32(want[0].I) { @@ -156,7 +156,7 @@ func TestP8ConditionalIntrinsicAndSanitizedCall(t *testing.T) { c.SwitchToNewBlock("entry") c.Ret() } - asm := "ORG 0x8000\nLD SP,0xFF00\nXOR A\nCALL arith\nDI\nHALT\n" + mir2.Z80Codegen(m, &mir2.AllocResult{Locs: map[mir2.Reg]mir2.PhysLoc{}}) + asm := "ORG 0x8000\nLD SP,0xFF00\nXOR A\nCALL arith\nDI\nHALT\n" + mustZ80Asm(mir2.Z80Codegen(m, &mir2.AllocResult{Locs: map[mir2.Reg]mir2.PhysLoc{}})) fix1Execute(t, asm) }) } @@ -179,7 +179,7 @@ func TestP8WordSpillShift(t *testing.T) { if err != nil { t.Fatal(err) } - asm := "ORG 0x8000\nLD SP,0xFF00\nLD DE,0x9234\nCALL arith\nDI\nHALT\n" + mir2.Z80Codegen(m, ar) + asm := "ORG 0x8000\nLD SP,0xFF00\nLD DE,0x9234\nCALL arith\nDI\nHALT\n" + mustZ80Asm(mir2.Z80Codegen(m, ar)) if got := fix1Execute(t, asm).HL; got != uint16(want[0].I) { t.Fatalf("got %x want %x", got, want[0].I) } @@ -203,7 +203,7 @@ func TestP8WideSpillInPlace(t *testing.T) { if err != nil { t.Fatal(err) } - asm := fmt.Sprintf("ORG 0x8000\nLD SP,0xFF00\nLD HL,0x5678\nLD (_spill_arith_r%d),HL\nLD HL,0x1234\nLD (_spill_arith_r%d+2),HL\nCALL arith\nPUSH HL\nEXX\nPUSH HL\nEXX\nPOP BC\nPOP HL\nDI\nHALT\n", a, a) + mir2.Z80Codegen(m, ar) + asm := fmt.Sprintf("ORG 0x8000\nLD SP,0xFF00\nLD HL,0x5678\nLD (_spill_arith_r%d),HL\nLD HL,0x1234\nLD (_spill_arith_r%d+2),HL\nCALL arith\nPUSH HL\nEXX\nPUSH HL\nEXX\nPOP BC\nPOP HL\nDI\nHALT\n", a, a) + mustZ80Asm(mir2.Z80Codegen(m, ar)) regs := fix1Execute(t, asm) got := uint32(regs.HL) | uint32(regs.BC)<<16 if got != uint32(want[0].I) { diff --git a/minzc/pkg/mir2/p8_spill_labels_test.go b/minzc/pkg/mir2/p8_spill_labels_test.go index d3dda4ecb..e4c06de90 100644 --- a/minzc/pkg/mir2/p8_spill_labels_test.go +++ b/minzc/pkg/mir2/p8_spill_labels_test.go @@ -16,7 +16,7 @@ func TestP8OrphanSpillDefinition(t *testing.T) { out := b.Add(a, x, TyU8, ClassAcc) b.Ret(out) ar := &AllocResult{Locs: map[Reg]PhysLoc{a: {Kind: LocReg, Name: "A"}, x: {Kind: LocMem}, out: {Kind: LocReg, Name: "A"}}} - asm := Z80Codegen(m, ar) + asm := mustZ80Asm(Z80Codegen(m, ar)) res, err := z80asm.NewAssembler().AssembleString(asm) if err != nil || len(res.Errors) > 0 { t.Fatalf("%v %v\n%s", err, res.Errors, asm) diff --git a/minzc/pkg/mir2/pbqp_test.go b/minzc/pkg/mir2/pbqp_test.go index 8282ea0cd..aab109669 100644 --- a/minzc/pkg/mir2/pbqp_test.go +++ b/minzc/pkg/mir2/pbqp_test.go @@ -177,7 +177,7 @@ func TestPBQP_FourPointers_NoSpill(t *testing.T) { lr := mir2.ComputeLiveness(f) ar := mir2.PBQPAllocate(f, lr, mir2.Z80CostTable{}) - asm := mir2.Z80Codegen(m, ar) + asm := mustZ80Asm(mir2.Z80Codegen(m, ar)) t.Log("\n" + asm) if len(ar.Spilled) != 0 { diff --git a/minzc/pkg/mir2/precoalesce_test.go b/minzc/pkg/mir2/precoalesce_test.go index a25454772..7fa7dcd97 100644 --- a/minzc/pkg/mir2/precoalesce_test.go +++ b/minzc/pkg/mir2/precoalesce_test.go @@ -137,7 +137,7 @@ func TestZ80Codegen_SubThroughBlockParamNoSpill(t *testing.T) { ct := mir2.Z80CostTable{} lr := mir2.ComputeLiveness(f) ar := mir2.PBQPAllocate(f, lr, ct) - asm := mir2.Z80Codegen(m, ar) + asm := mustZ80Asm(mir2.Z80Codegen(m, ar)) t.Logf("Z80 output:\n%s", asm) diff --git a/minzc/pkg/mir2/signed_wide_z80_test.go b/minzc/pkg/mir2/signed_wide_z80_test.go index 3d8bdac65..3f3cd5ed2 100644 --- a/minzc/pkg/mir2/signed_wide_z80_test.go +++ b/minzc/pkg/mir2/signed_wide_z80_test.go @@ -11,7 +11,7 @@ import ( func runWideZ80(t *testing.T, m *mir2.Module, alloc *mir2.AllocResult, value uint32, count uint8) uint32 { t.Helper() boot := fmt.Sprintf("ORG 0x8000\nLD SP, 0xFF00\nEXX\nLD HL, %d\nEXX\nLD HL, %d\nLD A, %d\nLD C, %d\nCALL wide\nEXX\nPUSH HL\nEXX\nPOP DE\nDI\nHALT\n", value>>16, value&65535, value&255, count) - asm := boot + mir2.Z80Codegen(m, alloc) + asm := boot + mustZ80Asm(mir2.Z80Codegen(m, alloc)) res, err := z80asm.NewAssembler().AssembleString(asm) if err != nil || len(res.Errors) > 0 { t.Fatalf("assemble %v %v\n%s", err, res.Errors, asm) diff --git a/minzc/pkg/mir2/z80codegen.go b/minzc/pkg/mir2/z80codegen.go index fbcc942cd..c3aa9c163 100644 --- a/minzc/pkg/mir2/z80codegen.go +++ b/minzc/pkg/mir2/z80codegen.go @@ -124,10 +124,92 @@ type Z80CodegenOptions struct { AnnotateTStates bool } +// ValidateZ80Symbols rejects distinct functions/data that emit the same symbol. +// Calls resolve by symbol, so uniqueness is required before using a callee ABI. +func ValidateZ80Symbols(m *Module) error { + seen := make(map[string]string) + // Runtime names and internal namespaces are reserved even when unused. + for _, name := range []string{"__mul8", "__mul16", "__call_ix"} { + seen[name] = "compiler runtime" + } + for i := 0; i <= 7; i++ { + seen[fmt.Sprintf("__rotate_%d", i)] = "compiler runtime" + } + add := func(name string) error { + sym := sanitizeIdent(name) + for _, prefix := range []string{"_mir2_str_", "_spill_", "_tsmc_"} { + if strings.HasPrefix(sym, prefix) { + return fmt.Errorf("reserved Z80 symbol %q: %q", sym, name) + } + } + if prev, ok := seen[sym]; ok { + return fmt.Errorf("ambiguous Z80 symbol %q: %q and %q", sym, prev, name) + } + seen[sym] = name + return nil + } + reserve := func(sym, owner string) error { + if prev, ok := seen[sym]; ok { + return fmt.Errorf("ambiguous Z80 symbol %q: %q and %q", sym, prev, owner) + } + seen[sym] = owner + return nil + } + for _, f := range m.Funcs { + for _, b := range f.Blocks { + for _, inst := range b.Insts { + if inst.Op == OpPatchSlot && inst.Sym != "" { + parts := strings.SplitN(inst.Sym, "$", 2) + patcher := inst.Sym + if len(parts) == 2 { + patcher = parts[0] + "_set_" + parts[1] + } + for _, sym := range []string{patcher, inst.Sym + "$imm"} { + if err := reserve(sym, "generated patch slot for "+f.Name); err != nil { + return err + } + } + } + } + } + } + for _, g := range m.Globals { + if st, ok := g.Ty.(*StructTy); ok && ByteWidth(g.Ty) != 0 { + off := 0 + for _, field := range st.Fields { + sym := sanitizeIdent(g.Name) + "__" + field.Name + if field.Name == "" { + sym = fmt.Sprintf("%s__off%d", sanitizeIdent(g.Name), off) + } + if err := reserve(sym, "generated field for "+g.Name); err != nil { + return err + } + off += ByteWidth(field.Ty) + } + } + } + + for _, f := range m.Funcs { + if err := add(f.Name); err != nil { + return err + } + } + for _, g := range m.Globals { + if err := add(g.Name); err != nil { + return err + } + } + return nil +} + // Z80Codegen generates Z80 assembly for the entire module. // An optional Z80CodegenOptions value may be passed as the third argument; // callers that don't need options can omit it entirely. -func Z80Codegen(m *Module, ar *AllocResult, opts ...Z80CodegenOptions) string { +// Invalid symbols return an error and no assembly. +func Z80Codegen(m *Module, ar *AllocResult, opts ...Z80CodegenOptions) (string, error) { + if err := ValidateZ80Symbols(m); err != nil { + return "", err + } var opt Z80CodegenOptions if len(opts) > 0 { opt = opts[0] @@ -153,6 +235,9 @@ func Z80Codegen(m *Module, ar *AllocResult, opts ...Z80CodegenOptions) string { for _, f := range funcs { startLen := sb.Len() cg.genFunc(f) + if cg.err != nil { + return "", cg.err + } sb.WriteByte('\n') // Z80 validation: catch invalid instructions at compile time. funcAsm := sb.String()[startLen:] @@ -291,12 +376,13 @@ func Z80Codegen(m *Module, ar *AllocResult, opts ...Z80CodegenOptions) string { result += sled.String() } - return result + return result, nil } // ── Internal codegen state ──────────────────────────────────────────────────── type z80cg struct { + err error // first emission error, returned by Z80Codegen ar *AllocResult sb *strings.Builder mod *Module // parent module (for callee contract lookup at call sites) diff --git a/minzc/pkg/mir2/z80codegen_call.go b/minzc/pkg/mir2/z80codegen_call.go index 3c41b9733..958b937f1 100644 --- a/minzc/pkg/mir2/z80codegen_call.go +++ b/minzc/pkg/mir2/z80codegen_call.go @@ -1,13 +1,16 @@ package mir2 import ( + "fmt" "slices" "strings" + + "github.com/minz/minzc/pkg/z80spec" ) // ── Calls ───────────────────────────────────────────────────────────────────── -func (g *z80cg) genCall(inst *Inst) { +func (g *z80cg) genCall(inst *Inst) error { clear(g.holdsPhys) // calls clobber all volatile registers // ── Built-in intrinsics (inlined, no CALL emitted) ──────────────────────── @@ -29,26 +32,26 @@ func (g *z80cg) genCall(inst *Inst) { // A, F, HL clobbered — invalidate holdsPhys entries for these. g.invalidate("A") g.invalidate("HL") - return + return nil case "@mir.io.print.u8": // Emit single OUT ($23), A. The value must already be in A. g.emit(" OUT (0x23), A") g.invalidate("A") - return + return nil case "@mir.io.print.char": // Print ASCII character in A via OUT ($23). g.emit(" OUT (0x23), A") g.invalidate("A") - return + return nil case "@mir.io.print.nl": // Emit newline (\n = 0x0A) via OUT ($23), A. g.emit(" LD A, 0x0A") g.emit(" OUT (0x23), A") g.invalidate("A") - return + return nil case "@mir.io.print.dec": // Print u8 value in A as decimal ASCII digits via OUT ($23). @@ -102,19 +105,19 @@ func (g *z80cg) genCall(inst *Inst) { g.invalidate("B") g.invalidate("C") g.invalidate("D") - return + return nil case "@mir.io.console.log": // console_log(n: u8) — OUT ($23), A (mze/mzx stdout port) g.emit(" OUT (0x23), A") g.invalidate("A") - return + return nil case "@mir.io.console.err": // console_err(n: u8) — OUT ($25), A (mze/mzx stderr port) g.emit(" OUT (0x25), A") g.invalidate("A") - return + return nil case "@error": // @error(N) — set carry flag, error code in A, return. @@ -122,7 +125,7 @@ func (g *z80cg) genCall(inst *Inst) { g.emit(" SCF") // CY = 1 (error) g.emit(" RET") // return to caller with CY set + A = code g.invalidate("A") - return + return nil case "@check": // @check — jump to error handler if carry set. @@ -135,13 +138,13 @@ func (g *z80cg) genCall(inst *Inst) { // For simple propagation, emit RET: g.emit(" RET") // propagate error (CY + A intact) g.emitf(".check_ok_%d:", idx) - return + return nil case "@propagate": // @propagate — conditional return on carry. 1 byte, 5T. // If CY=1 (error from previous call), return immediately. g.emit(" RET C") - return + return nil } sym := sanitizeIdent(inst.Sym) @@ -158,12 +161,20 @@ func (g *z80cg) genCall(inst *Inst) { callee.Contract.Returns = []Return{{Ty: inst.Ty, Class: ClassAcc}} } } else if g.mod != nil { - callee = g.mod.FuncByName(inst.Sym) + // Resolve on emitted symbols. Never discard an ABI to handle ambiguity. + for _, f := range g.mod.Funcs { + if sanitizeIdent(f.Name) == sym { + if callee != nil { + return fmt.Errorf("ambiguous Z80 callee %q", sym) + } + callee = f + } + } } // Save live caller values before argument copies, including reused args. var callerSavePairs []string - if callee != nil && inst != g.tailCallInst { + if inst != g.tailCallInst { callerSavePairs = g.callerSavePairs(inst, callee) for _, pair := range callerSavePairs { g.emitf(" PUSH %s", pair) @@ -189,7 +200,7 @@ func (g *z80cg) genCall(inst *Inst) { } else { g.emitf(" JP %s", sym) } - return // genTerm will skip RET + return nil // genTerm will skip RET } // Check if callee has a fixed address (ExternAddr) @@ -239,7 +250,13 @@ func (g *z80cg) genCall(inst *Inst) { } results = append(results, parallelCopy{srcName: canonicalReturnLoc(cls, ty), dstName: g.loc(r), ty: ty}) } - g.pickupCallResults(results, callerSavePairs, flagResult) + var protected []string + for r := range g.regsLiveAfterInst(inst) { + if r != inst.Dst && !slices.Contains(inst.ExtraRets, r) { + protected = append(protected, g.loc(r)) + } + } + g.pickupCallResults(results, callerSavePairs, flagResult, protected...) // CALL and argument setup invalidate cached physical contents, including // registers used only as scratch by the emitted callee/runtime sequences. @@ -266,11 +283,12 @@ func (g *z80cg) genCall(inst *Inst) { } } + return nil } // pickupCallResults never stores into code. Most calls need only parallel // moves; overlapping destinations use a short-lived, reentrant stack frame. -func (g *z80cg) pickupCallResults(results []parallelCopy, saved []string, flagResult bool) { +func (g *z80cg) pickupCallResults(results []parallelCopy, saved []string, flagResult bool, protected ...string) { savedPair := map[string]bool{} for _, p := range saved { savedPair[p] = true @@ -295,14 +313,14 @@ func (g *z80cg) pickupCallResults(results []parallelCopy, saved []string, flagRe } return } - - // A byte result can use a pair that is absent from the restore list. - // This scratch lifetime ends at pickup, so later allocation cannot reuse - // it while an override remains live. + // A byte result can use an unsaved pair only when its scratch byte does + // not hold a live value preserved by a narrow extern contract. if !flagResult && len(results) == 1 && results[0].ty.Width() <= 8 && regToPairMap[results[0].dstName] != "" { r := results[0] for _, scratch := range []string{"E", "H", "L", "D", "B", "C"} { - if !savedPair[regToPairMap[scratch]] { + if !savedPair[regToPairMap[scratch]] && !slices.ContainsFunc(protected, func(name string) bool { + return z80spec.Overlaps(name, scratch) + }) { g.emitLD8(scratch, r.srcName) for i := len(saved) - 1; i >= 0; i-- { g.emitf(" POP %s", saved[i]) @@ -606,18 +624,44 @@ func isRecursive(f *Func) bool { // caller that remain live after the call, including arguments reused later. // The call results are excluded because their definitions begin after CALL. func (g *z80cg) callerSavePairs(inst *Inst, callee *Func) []string { - if callee == nil { - return nil - } - - // The emitter uses implicit scratch registers, runtime helpers and nested - // calls. Preserve every live physical register conservatively until a - // post-emission write-set analysis can prove a smaller clobber contract. + // Unknown and compiled callees remain conservative. Only an explicit + // extern implementation contract can narrow the callee's write set. clobberedRegs := map[string]bool{} for _, name := range computeClobbers(callee, g.ar) { clobberedRegs[name] = true } + // The declaration describes the callee, not the argument shuffle. Include + // its destination writes as well. Complex copies can use implicit scratch + // registers; retain the conservative contract for those call sites. + if callee != nil && callee.Attrs.IsExtern && len(callee.Blocks) == 0 && callee.Contract.ExternClobbers != nil { + moves := 0 + for i, arg := range inst.Args { + if i >= len(callee.Contract.Params) { + break + } + p := callee.Contract.Params[i] + dst := canonicalReturnLoc(p.Class, p.Ty) + if loc, ok := g.ar.Locs[p.Reg]; ok && loc.Name != "" { + dst = physName(loc) + } + src := g.loc(arg) + if src == dst { + continue + } + moves++ + clobberedRegs[dst] = true + if isSpill(src) || src == "F" || dst == "F" { + moves += 2 // implicit ALU/spill scratch + } + } + if moves > 1 || len(inst.ExtraRets) > 0 { + for _, name := range allZ80Clobbers { + clobberedRegs[name] = true + } + } + } + // Compute which virtual regs are live AFTER this call instruction. // A reg is live-across-call if it's used by any instruction after the call // in the same block, or by the block's terminator, or by a successor block @@ -650,8 +694,11 @@ func (g *z80cg) callerSavePairs(inst *Inst, callee *Func) []string { if isSpill(locName) { continue // spilled to memory — not a register } - if clobberedRegs[locName] { - livePhys[locName] = true + for name := range clobberedRegs { + if z80spec.Overlaps(name, locName) { + livePhys[locName] = true + break + } } } @@ -852,12 +899,37 @@ func classPhysRegs(cls RegClass) []string { return nil } +// ValidZ80Clobber reports whether a register is supported by caller-save. +// SP, PC, special and shadow registers cannot be preserved by this ABI. +func ValidZ80Clobber(name string) bool { + _, ok := regToPairMap[name] + return ok +} + +var allZ80Clobbers = []string{"A", "B", "BC", "C", "D", "DE", "E", "F", "H", "HL", "IX", "IXH", "IXL", "IY", "IYH", "IYL", "L"} + // computeClobbers returns a conservative sorted superset of emitted writes. func computeClobbers(f *Func, ar *AllocResult) []string { + if f != nil && f.Attrs.IsExtern && len(f.Blocks) == 0 && f.Contract.ExternClobbers != nil { + var names []string + for _, name := range f.Contract.ExternClobbers { + // Other frontends must not accidentally turn invalid contracts into + // preservation guarantees. + if !ValidZ80Clobber(name) { + return allZ80Clobbers + } + names = append(names, name) + } + // Outputs are writes even when omitted from the declaration. + for _, ret := range f.Contract.Returns { + names = append(names, canonicalReturnLoc(ret.Class, ret.Ty)) + } + sortStrings(names) + return slices.Compact(names) + } // Allocation destinations omit implicit writes by ALU lowering, parallel - // copies, caller saves, runtime helpers and transitive calls. ABI-owned - // registers are also clobbered: ownership does not imply preservation. - return []string{"A", "B", "BC", "C", "D", "DE", "E", "F", "H", "HL", "IX", "IXH", "IXL", "IY", "IYH", "IYL", "L"} + // copies, runtime helpers and transitive calls. Keep the default conservative. + return allZ80Clobbers } func sortStrings(ss []string) { diff --git a/minzc/pkg/mir2/z80codegen_inst.go b/minzc/pkg/mir2/z80codegen_inst.go index 7b804afc0..c80ca07bb 100644 --- a/minzc/pkg/mir2/z80codegen_inst.go +++ b/minzc/pkg/mir2/z80codegen_inst.go @@ -897,7 +897,9 @@ func (g *z80cg) genInst(inst *Inst) { g.lastFlagsLhs = "" g.lastFlagsRhs = "" g.comment(fmt.Sprintf("genCall: %s dst=%v", inst.Sym, inst.Dst)) - g.genCall(inst) + if err := g.genCall(inst); err != nil && g.err == nil { + g.err = err + } case OpCallCond: // Conditional CALL: the condition flag is already set by a preceding CP/AND. @@ -912,7 +914,9 @@ func (g *z80cg) genInst(inst *Inst) { skip := fmt.Sprintf(".%s_callcond%d", sanitizeIdent(g.fn.Name), g.trampIdx) g.trampIdx++ g.emitf(" JRS %s, %s", invertCC(cc), skip) - g.genCall(inst) + if err := g.genCall(inst); err != nil && g.err == nil { + g.err = err + } g.emitf("%s:", skip) clear(g.holdsPhys) // calls clobber all volatile registers diff --git a/minzc/pkg/mir2/z80codegen_test.go b/minzc/pkg/mir2/z80codegen_test.go index b9c6ecb45..37ad753c9 100644 --- a/minzc/pkg/mir2/z80codegen_test.go +++ b/minzc/pkg/mir2/z80codegen_test.go @@ -19,7 +19,7 @@ func TestZ80CodegenFibonacci(t *testing.T) { for _, f := range m.Funcs { 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) @@ -56,7 +56,7 @@ func TestZ80CodegenSMCCounter(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) @@ -84,7 +84,7 @@ func TestZ80CodegenExt(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) @@ -122,7 +122,7 @@ func TestZ80Codegen_IXPointer(t *testing.T) { loaded: {Kind: mir2.LocReg, Name: "A"}, }} - asm := mir2.Z80Codegen(m, ar) + asm := mustZ80Asm(mir2.Z80Codegen(m, ar)) t.Log("\n" + asm) if !strings.Contains(asm, "(IX+0)") { @@ -152,7 +152,7 @@ func TestZ80Codegen_IX16bitLoadStore(t *testing.T) { loaded: {Kind: mir2.LocReg, Name: "HL"}, }} - asm := mir2.Z80Codegen(m, ar) + asm := mustZ80Asm(mir2.Z80Codegen(m, ar)) t.Log("\n" + asm) // Should use (IX+0) and (IX+1) — no INC/DEC IX. @@ -187,7 +187,7 @@ func TestZ80Codegen_IXYHalvesAreFirstClassForMoves(t *testing.T) { retv: {Kind: mir2.LocReg, Name: "A"}, }} - asm := mir2.Z80Codegen(m, ar) + asm := mustZ80Asm(mir2.Z80Codegen(m, ar)) t.Log("\n" + asm) fnAsm := extractFuncAsm(asm, "half_moves") @@ -226,7 +226,7 @@ func TestZ80Codegen_IXYHalvesCompareAsNormal8BitRegs(t *testing.T) { z: {Kind: mir2.LocReg, Name: "A"}, }} - asm := mir2.Z80Codegen(m, ar) + asm := mustZ80Asm(mir2.Z80Codegen(m, ar)) t.Log("\n" + asm) fnAsm := extractFuncAsm(asm, "half_cmp_zero") @@ -272,7 +272,7 @@ func TestZ80Codegen_IXAllocUnderPressure(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) // Verify at least one of the four pointers is in IX or IY — the allocator @@ -454,7 +454,7 @@ func TestBitStorePattern_RES_IX(t *testing.T) { ar := mir2.Allocate(f, lr, mir2.Z80CostTable{}) ar.Locs[ptr] = mir2.PhysLoc{Kind: mir2.LocIXY, Name: "IX"} - asm := mir2.Z80Codegen(m, ar) + asm := mustZ80Asm(mir2.Z80Codegen(m, ar)) t.Log("\n" + asm) fnAsm := extractFuncAsm(asm, "reset_bit2") @@ -483,7 +483,7 @@ func TestBitStorePattern_SET_U16_IXHighByte(t *testing.T) { ar := mir2.Allocate(f, lr, mir2.Z80CostTable{}) ar.Locs[ptr] = mir2.PhysLoc{Kind: mir2.LocIXY, Name: "IX"} - asm := mir2.Z80Codegen(m, ar) + asm := mustZ80Asm(mir2.Z80Codegen(m, ar)) t.Log("\n" + asm) fnAsm := extractFuncAsm(asm, "set_bit10") @@ -561,7 +561,7 @@ func TestBitRegPattern_SET_U8_B(t *testing.T) { ar.Locs[val] = mir2.PhysLoc{Kind: mir2.LocReg, Name: "B"} ar.Locs[next] = mir2.PhysLoc{Kind: mir2.LocReg, Name: "B"} - asm := mir2.Z80Codegen(m, ar) + asm := mustZ80Asm(mir2.Z80Codegen(m, ar)) t.Log("\n" + asm) fnAsm := extractFuncAsm(asm, "set_bit5_reg") @@ -591,7 +591,7 @@ func TestBitRegPattern_DirectBitReset_U8_IXL(t *testing.T) { ar.Locs[val] = mir2.PhysLoc{Kind: mir2.LocIXY8, Name: "IXL"} ar.Locs[next] = mir2.PhysLoc{Kind: mir2.LocIXY8, Name: "IXL"} - asm := mir2.Z80Codegen(m, ar) + asm := mustZ80Asm(mir2.Z80Codegen(m, ar)) t.Log("\n" + asm) fnAsm := extractFuncAsm(asm, "reset_bit2_direct") @@ -622,7 +622,7 @@ func TestBitRegPattern_RES_U8_IXL(t *testing.T) { ar.Locs[val] = mir2.PhysLoc{Kind: mir2.LocIXY8, Name: "IXL"} ar.Locs[next] = mir2.PhysLoc{Kind: mir2.LocIXY8, Name: "IXL"} - asm := mir2.Z80Codegen(m, ar) + asm := mustZ80Asm(mir2.Z80Codegen(m, ar)) t.Log("\n" + asm) fnAsm := extractFuncAsm(asm, "reset_bit1_reg") @@ -660,7 +660,7 @@ func TestBitCmpPattern_DirectBitGet_U16_IXRegPair(t *testing.T) { ar := mir2.Allocate(f, lr, mir2.Z80CostTable{}) ar.Locs[val] = mir2.PhysLoc{Kind: mir2.LocIXY, Name: "IX"} - asm := mir2.Z80Codegen(m, ar) + asm := mustZ80Asm(mir2.Z80Codegen(m, ar)) t.Log("\n" + asm) fnAsm := extractFuncAsm(asm, "test_bit11_direct") @@ -687,7 +687,7 @@ func TestBitRegPattern_SET_U16_IXHigh(t *testing.T) { ar.Locs[val] = mir2.PhysLoc{Kind: mir2.LocIXY, Name: "IX"} ar.Locs[next] = mir2.PhysLoc{Kind: mir2.LocIXY, Name: "IX"} - asm := mir2.Z80Codegen(m, ar) + asm := mustZ80Asm(mir2.Z80Codegen(m, ar)) t.Log("\n" + asm) fnAsm := extractFuncAsm(asm, "set_bit14_reg") @@ -714,7 +714,7 @@ func TestBitRegPattern_RES_U16_IYLow(t *testing.T) { ar.Locs[val] = mir2.PhysLoc{Kind: mir2.LocIXY, Name: "IY"} ar.Locs[next] = mir2.PhysLoc{Kind: mir2.LocIXY, Name: "IY"} - asm := mir2.Z80Codegen(m, ar) + asm := mustZ80Asm(mir2.Z80Codegen(m, ar)) t.Log("\n" + asm) fnAsm := extractFuncAsm(asm, "reset_bit3_reg") @@ -850,7 +850,7 @@ func TestBitCmpPattern_ShiftedBitRead_U16_IXRegPair(t *testing.T) { ar := mir2.Allocate(f, lr, mir2.Z80CostTable{}) ar.Locs[val] = mir2.PhysLoc{Kind: mir2.LocIXY, Name: "IX"} - asm := mir2.Z80Codegen(m, ar) + asm := mustZ80Asm(mir2.Z80Codegen(m, ar)) t.Log("\n" + asm) fnAsm := extractFuncAsm(asm, "test_bit13") @@ -1154,7 +1154,7 @@ func TestDWord_Const(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) // EXX must appear (for hi16 load). @@ -1187,7 +1187,7 @@ func TestDWord_Add32(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) // Must use EXX (for shadow bank access). @@ -1220,7 +1220,7 @@ func TestDWord_Sub32(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, "EXX") { diff --git a/minzc/pkg/nanz/extern_clobbers_test.go b/minzc/pkg/nanz/extern_clobbers_test.go new file mode 100644 index 000000000..34db88357 --- /dev/null +++ b/minzc/pkg/nanz/extern_clobbers_test.go @@ -0,0 +1,107 @@ +package nanz_test + +import ( + "reflect" + "strings" + "testing" + + "github.com/minz/minzc/pkg/hir" + "github.com/minz/minzc/pkg/mir2" + "github.com/minz/minzc/pkg/nanz" +) + +func TestExternClobberContract(t *testing.T) { + for _, tc := range []struct { + attr string + regs []string + addr uint16 + }{ + {`@extern`, nil, 0}, + {`@extern(clobbers: "")`, []string{}, 0}, + {`@extern(clobbers: "a, HL, F, IXH")`, []string{"A", "HL", "F", "IXH"}, 0}, + {`@extern(0x1234, clobbers: "DE")`, []string{"DE"}, 0x1234}, + {`@extern(0x10)`, nil, 0x10}, + } { + t.Run(tc.attr, func(t *testing.T) { + m, err := nanz.Parse(tc.attr+` fun ext(@z80_hl addr: u16, value: u8) -> u8`, "test") + if err != nil { + t.Fatal(err) + } + check := func(m *hir.Module) { + t.Helper() + f := hir.LowerModule(m).FuncByName("ext") + if !f.Attrs.IsExtern || f.Attrs.ExternAddr != tc.addr || !reflect.DeepEqual(f.Contract.ExternClobbers, tc.regs) { + t.Fatalf("lost extern contract: %+v", f) + } + if len(f.Blocks) != 0 || len(f.Contract.Params) != 2 || f.Contract.Params[0].Class != mir2.ClassPointer || len(f.Contract.Returns) != 1 || f.Contract.Returns[0].Class != mir2.ClassAcc { + t.Fatalf("lost extern ABI: %+v", f.Contract) + } + } + check(m) + // Use ordinary parameters for printer round-trip (register annotations + // are not currently printed by the source formatter). + m.Funcs[0].Params[0].RegClass = 0 + round, err := nanz.Parse(nanz.Print(m), "round") + if err != nil { + t.Fatal(err) + } + check(round) + }) + } +} + +func TestExternClobbersRejectUnknown(t *testing.T) { + for _, reg := range []string{"QQ", "SP", "PC", "A'", "I", "R", "A,", "mem0"} { + _, err := nanz.Parse(`@extern(clobbers: "`+reg+`") fun ext()`, "test") + if err == nil || !strings.Contains(err.Error(), "clobber register") { + t.Errorf("%q: expected invalid register error, got %v", reg, err) + } + } +} + +func TestExternRedeclarationConflicts(t *testing.T) { + for _, tc := range []struct{ name, source, want string }{ + {"body after extern", `@extern(clobbers: "") fun g(a: u8) -> u8 +fun g(a: u8) -> u8 { return a + 2 } +fun main() -> u8 { return g(1) + 12 }`, "shares a name with a function body"}, + {"extern after body", `fun g(a: u8) -> u8 { return a } +@extern(clobbers: "") fun g(a: u8) -> u8`, "shares a name with a function body"}, + {"extern overload", `@extern(clobbers: "") fun f(a: u8) -> void +@extern fun f(a: u16) -> void +fun main() -> void { f(0x1234) }`, "conflicting @extern redeclarations"}, + {"contract", `@extern(clobbers: "") fun f() +@extern(clobbers: "C") fun f()`, "conflicting @extern redeclarations"}, + {"address", `@extern(0x10) fun f() +@extern(0x18) fun f()`, "conflicting @extern redeclarations"}, + {"return", `@extern fun f() -> u8 +@extern fun f() -> u16`, "conflicting @extern redeclarations"}, + {"parameter ABI", `@extern fun f(@z80_a a: u8) +@extern fun f(@z80_c a: u8)`, "conflicting @extern redeclarations"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := nanz.Parse(tc.source, "test") + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("want %q, got %v", tc.want, err) + } + }) + } + // Repeated imports may supply identical declarations with different parameter names. + if _, err := nanz.Parse("@extern fun f(a: u8)\n@extern fun f(b: u8)", "test"); err != nil { + t.Fatal(err) + } +} + +func TestExternClobberSyntaxDiagnostics(t *testing.T) { + for _, attr := range []string{`@extern(clobbers "A")`, `@extern(0x10 clobbers: "A")`, `@extern(clobbers: A)`, `@extern(writes: "A")`} { + _, err := nanz.Parse(attr+" fun f()", "test") + if err == nil || !strings.Contains(err.Error(), `@extern(clobbers: "A, HL")`) || !strings.Contains(err.Error(), `@extern(0x1234, clobbers: "...")`) || strings.Contains(err.Error(), "token kind") { + t.Errorf("%s: got %v", attr, err) + } + } + for _, regs := range []string{"A,", ",HL", "A,,HL", "A, ,HL"} { + _, err := nanz.Parse(`@extern(clobbers: "`+regs+`") fun f()`, "test") + if err == nil || !strings.Contains(err.Error(), "empty clobber register entry") { + t.Errorf("%q: got %v", regs, err) + } + } +} diff --git a/minzc/pkg/nanz/extern_redeclaration_test.go b/minzc/pkg/nanz/extern_redeclaration_test.go new file mode 100644 index 000000000..bde41eafe --- /dev/null +++ b/minzc/pkg/nanz/extern_redeclaration_test.go @@ -0,0 +1,104 @@ +package nanz_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/minz/minzc/pkg/emulator" + "github.com/minz/minzc/pkg/nanz" + "github.com/minz/minzc/pkg/pipeline" + "github.com/minz/minzc/pkg/z80asm" +) + +func TestExternRedeclarationABI(t *testing.T) { + decl := "@extern fun ext(a: u8, b: u8) -> u8\n" + for _, mode := range []string{"repeated", "address", "imports"} { + t.Run(mode, func(t *testing.T) { + dir := t.TempDir() + source := decl + decl + "fun twice(x: u8) -> u8 { return ext(x, x) }\nfun main() -> u8 { return twice(3) }" + if mode == "address" { + source = strings.ReplaceAll(source, "@extern fun", "@extern(0x1234) fun") + } + if mode == "imports" { + for _, name := range []string{"moda", "modb"} { + if err := os.WriteFile(filepath.Join(dir, name+".nanz"), []byte(strings.ReplaceAll(decl, "@extern fun", "@extern(0x1234) fun")+"fun twice_"+name+"(x: u8) -> u8 { return ext(x, x) }"), 0600); err != nil { + t.Fatal(err) + } + } + source = "import moda\nimport modb\nfun main() -> u8 { return twice_moda(3) }" + } + hm, err := nanz.ParseWithOpts(source, filepath.Join(dir, "main.nanz"), nanz.ParseOpts{BaseDir: dir}) + if err != nil { + t.Fatal(err) + } + n := 0 + for _, f := range hm.Funcs { + if f.Name == "ext" { + n++ + } + } + if n != 1 { + t.Fatalf("extern count=%d, want 1", n) + } + opts := pipeline.DefaultOptions() + opts.AssertMode = "none" + steps, err := pipeline.CompileHIRSteps(hm, opts) + if err != nil { + t.Fatal(err) + } + asm := steps.Assembly + stub := "ext:\n ADD A,C\n RET\n" + if mode == "address" || mode == "imports" { + if !strings.Contains(asm, "JP 0x1234") || strings.Contains(asm, "JP ext\n") { + t.Fatalf("lost extern address:\n%s", asm) + } + asm = strings.ReplaceAll(asm, "0x1234", "provided_ext") + asm += "\nprovided_ext:\n ADD A,C\n RET\n" + } else { + asm = strings.Replace(asm, "ext:\n", stub, 1) + } + result, err := z80asm.NewAssembler().AssembleString("ORG 0x8000\n LD SP,0xFF00\n CALL main\n HALT\n" + asm) + if err != nil || len(result.Errors) > 0 { + t.Fatalf("assembly: %v %v", err, result.Errors) + } + z := emulator.NewRemogattoZ80() + z.Reset() + z.LoadMemory(0x8000, result.Binary) + z.SetRegisters(emulator.Registers{PC: 0x8000, SP: 0xff00}) + for i := 0; !z.IsHalted(); i++ { + if i == 10000 { + t.Fatal("execution did not halt") + } + z.Step() + } + if got := z.GetRegisters().A; got != 6 { + t.Fatalf("A=%d, want 6", got) + } + }) + } +} + +func TestExternClobberSetRedeclaration(t *testing.T) { + m, err := nanz.Parse("@extern(clobbers: \"A, HL, A\") fun ext()\n@extern(clobbers: \"HL, A\") fun ext()", "test") + if err != nil { + t.Fatal(err) + } + if len(m.Funcs) != 1 { + t.Fatal("identical clobber sets not deduplicated") + } +} + +func TestExternEmittedSymbolCollision(t *testing.T) { + hm, err := nanz.Parse("fun f(a: u8) -> u8 { return a + 1 }\n@extern(clobbers: \"\") fun v_f(a: u8) -> u8\nfun main() -> u8 { return v_f(3) + f(5) }", "test") + if err != nil { + t.Fatal(err) + } + opts := pipeline.DefaultOptions() + opts.AssertMode = "none" + _, err = pipeline.CompileHIRSteps(hm, opts) + if err == nil || !strings.Contains(err.Error(), "ambiguous Z80 symbol") { + t.Fatalf("want emitted symbol error, got %v", err) + } +} diff --git a/minzc/pkg/nanz/fix3_runtime_test.go b/minzc/pkg/nanz/fix3_runtime_test.go new file mode 100644 index 000000000..3c32e13ba --- /dev/null +++ b/minzc/pkg/nanz/fix3_runtime_test.go @@ -0,0 +1,25 @@ +package nanz_test + +import ( + "github.com/minz/minzc/pkg/nanz" + "github.com/minz/minzc/pkg/pipeline" + "strings" + "testing" +) + +func TestExternCannotClaimMultiplyRuntime(t *testing.T) { + for _, attr := range []string{"@extern", `@extern(clobbers: "")`} { + t.Run(attr, func(t *testing.T) { + hm, err := nanz.Parse(attr+` fun __mul8(a: u8, b: u8) -> u8 +fun g(x: u8, y: u8) -> u8 { return x * y } +fun main() -> u8 { return g(3, 5) + __mul8(2, 3) }`, "runtime.nanz") + if err != nil { + t.Fatal(err) + } + _, err = pipeline.CompileHIR(hm) + if err == nil || !strings.Contains(err.Error(), "__mul8") { + t.Fatalf("compile error = %v", err) + } + }) + } +} diff --git a/minzc/pkg/nanz/parse.go b/minzc/pkg/nanz/parse.go index d93304757..e9ad9717e 100644 --- a/minzc/pkg/nanz/parse.go +++ b/minzc/pkg/nanz/parse.go @@ -42,6 +42,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "sort" "strconv" "strings" @@ -784,21 +785,52 @@ func (p *parser) parseModule() (*hir.Module, error) { attr := p.l.peek() if attr.kind == tokIdent && attr.val == "extern" { p.l.next() - // Optional address: @extern(0xNNNN) fun ... + // Optional address and explicit physical clobber contract. var externAddr uint16 + var externClobbers []string if p.l.is(tokLParen) { p.l.next() - addrTok, err := p.l.eat(tokInt) - if err != nil { - return nil, fmt.Errorf("line %d: expected address in @extern(...)", attr.line) + needClobbers := true + if p.l.is(tokInt) { + addrTok := p.l.next() + addr64, err := strconv.ParseUint(addrTok.val, 0, 16) + if err != nil { + return nil, fmt.Errorf("line %d: invalid address %q: %v", addrTok.line, addrTok.val, err) + } + externAddr = uint16(addr64) + if p.l.is(tokComma) { + p.l.next() + } else { + needClobbers = false + } } - addr64, err := strconv.ParseUint(addrTok.val, 0, 16) - if err != nil { - return nil, fmt.Errorf("line %d: invalid address %q: %v", addrTok.line, addrTok.val, err) + if needClobbers { + if err := p.l.eatIdent("clobbers"); err != nil { + return nil, fmt.Errorf("line %d: expected @extern(clobbers: \"A, HL\") or @extern(0x1234, clobbers: \"...\")", t.line) + } + if _, err := p.l.eat(tokColon); err != nil { + return nil, fmt.Errorf("line %d: expected @extern(clobbers: \"A, HL\") or @extern(0x1234, clobbers: \"...\")", t.line) + } + regs, err := p.l.eat(tokString) + if err != nil { + return nil, fmt.Errorf("line %d: expected @extern(clobbers: \"A, HL\") or @extern(0x1234, clobbers: \"...\")", t.line) + } + externClobbers = []string{} + if strings.TrimSpace(regs.val) != "" { + for _, name := range strings.Split(regs.val, ",") { + name = strings.ToUpper(strings.TrimSpace(name)) + if name == "" { + return nil, fmt.Errorf("line %d: empty clobber register entry; omit extra commas (use clobbers: \"\" to preserve all registers)", regs.line) + } + if !mir2.ValidZ80Clobber(name) { + return nil, fmt.Errorf("line %d: unknown or unsupported clobber register %q", regs.line, name) + } + externClobbers = append(externClobbers, name) + } + } } - externAddr = uint16(addr64) if _, err := p.l.eat(tokRParen); err != nil { - return nil, err + return nil, fmt.Errorf("line %d: expected @extern(clobbers: \"A, HL\") or @extern(0x1234, clobbers: \"...\")", t.line) } } if err := p.l.eatIdent("fun"); err != nil { @@ -809,6 +841,7 @@ func (p *parser) parseModule() (*hir.Module, error) { return nil, err } f.ExternAddr = externAddr + f.ExternClobbers = externClobbers m.Funcs = append(m.Funcs, f) } else if attr.kind == tokIdent && attr.val == "screen" && p.metaFuncs["screen"] == "" { // Built-in @screen metafunction (only if no user-defined @screen) @@ -1028,6 +1061,41 @@ func (p *parser) parseModule() (*hir.Module, error) { // Append auto-generated helpers (__tag, __payload, match payload wrappers). m.Funcs = append(m.Funcs, p.autoFuncs...) + // Extern symbols have one ABI; ordinary function overloads do not define + // overload semantics for host/assembly symbols. Validate after imports and + // generated helpers have been merged as well as local declarations. + byName := make(map[string][]*hir.Func) + unique := make([]*hir.Func, 0, len(m.Funcs)) + for _, f := range m.Funcs { + duplicate := false + for _, prev := range byName[f.Name] { + if !prev.IsExtern && !f.IsExtern { + continue + } + if !prev.IsExtern || !f.IsExtern || prev.Body != nil || f.Body != nil { + return nil, fmt.Errorf("@extern %q shares a name with a function body", f.Name) + } + same := reflect.DeepEqual(prev.RetTy, f.RetTy) && reflect.DeepEqual(prev.RetTys, f.RetTys) && + prev.ExternAddr == f.ExternAddr && sameClobberSet(prev.ExternClobbers, f.ExternClobbers) && len(prev.Params) == len(f.Params) + if same { + for i, param := range prev.Params { + if !reflect.DeepEqual(param.Ty, f.Params[i].Ty) || param.RegClass != f.Params[i].RegClass || param.SMC != f.Params[i].SMC { + same = false + } + } + } + if !same { + return nil, fmt.Errorf("conflicting @extern redeclarations for %q; extern overloads are unsupported", f.Name) + } + duplicate = true + } + if !duplicate { + byName[f.Name] = append(byName[f.Name], f) + unique = append(unique, f) + } + } + m.Funcs = unique + // Fix forward-referenced call types: any CallExpr with Ty==TyVoid whose // target function actually returns a value needs its Ty patched. This // happens when function A calls function B that is declared later in the @@ -1038,6 +1106,21 @@ func (p *parser) parseModule() (*hir.Module, error) { return m, nil } +// Preserve nil (unknown clobbers) versus an explicitly empty preservation contract. +func sameClobberSet(a, b []string) bool { + if (a == nil) != (b == nil) { + return false + } + set := func(regs []string) map[string]bool { + out := make(map[string]bool) + for _, r := range regs { + out[strings.ToUpper(strings.TrimSpace(r))] = true + } + return out + } + return reflect.DeepEqual(set(a), set(b)) +} + // fixForwardCallTypes walks all functions and patches CallExpr nodes whose Ty // is TyVoid but whose target function actually returns a value. This fixes // forward references: when fun A (line 100) calls fun B (line 200), B's diff --git a/minzc/pkg/nanz/print.go b/minzc/pkg/nanz/print.go index e4652df90..ce559a0c1 100644 --- a/minzc/pkg/nanz/print.go +++ b/minzc/pkg/nanz/print.go @@ -119,7 +119,7 @@ func (p *printer) global(g mir2.Global) { case *mir2.ArrayTy: if g.InitString && ty.Elem == mir2.TyU8 { if value, ok := printableCString(g.Init); ok { - p.write(" = c\"") + p.write(" = c\"") p.write(value) p.write("\"") break @@ -178,7 +178,21 @@ func (p *printer) writeInitBytes(data []byte, ty mir2.Ty) { func (p *printer) function(f *hir.Func) { if f.IsExtern { - p.write("@extern ") + p.write("@extern") + if f.ExternAddr != 0 || f.ExternClobbers != nil { + p.write("(") + if f.ExternAddr != 0 { + p.writef("0x%04X", f.ExternAddr) + if f.ExternClobbers != nil { + p.write(", ") + } + } + if f.ExternClobbers != nil { + p.writef("clobbers: %q", strings.Join(f.ExternClobbers, ", ")) + } + p.write(")") + } + p.write(" ") } p.write("fun ") p.write(f.Name) diff --git a/minzc/pkg/pipeline/extern_clobbers_test.go b/minzc/pkg/pipeline/extern_clobbers_test.go new file mode 100644 index 000000000..300533583 --- /dev/null +++ b/minzc/pkg/pipeline/extern_clobbers_test.go @@ -0,0 +1,48 @@ +package pipeline + +import ( + "strings" + "testing" + + "github.com/minz/minzc/pkg/nanz" +) + +// These are real externs, not name-based intrinsics. The attached Z80 stubs +// establish the narrow contracts used by this judge. +func TestExternPeekPokeZ80Contract(t *testing.T) { + hm, err := nanz.Parse(` +@extern(clobbers: "A") fun peek(@z80_hl addr: u16) -> u8 +@extern(clobbers: "") fun poke(@z80_hl addr: u16, @z80_c val: u8) -> void +fun judge() -> u8 { + let v: u8 = peek(0x9000) + poke(0x9001, v) + return v + peek(0x9001) +} +`, "extern_judge") + if err != nil { + t.Fatal(err) + } + opts := DefaultOptions() + opts.AssertMode = "none" + steps, err := CompileHIRSteps(hm, opts) + if err != nil { + t.Fatal(err) + } + asm := steps.Assembly + for _, pair := range []string{"BC", "DE", "HL", "IX", "IY"} { + if strings.Contains(asm, "PUSH "+pair) { + t.Fatalf("needless %s save under exact peek/poke contracts:\n%s", pair, asm) + } + } + for name, body := range map[string]string{"peek": " LD A, (HL)\n RET", "poke": " LD (HL), C\n RET"} { + stub := name + ":\n" + if !strings.Contains(asm, stub) { + t.Fatalf("missing %s stub:\n%s", name, asm) + } + asm = strings.Replace(asm, stub, name+":\n"+body+"\n", 1) + } + bootstrap := "ORG 0x8000\nLD SP, 0xff00\nLD A, 21\nLD (0x9000), A\nCALL judge\nHALT\n" + if got := runZ80GetA(t, bootstrap+asm, 0x8000); got != 42 { + t.Fatalf("live value across peek/poke: got %d, want 42\n%s", got, asm) + } +} diff --git a/minzc/pkg/pipeline/pipeline.go b/minzc/pkg/pipeline/pipeline.go index 02bcfad97..2314db90e 100644 --- a/minzc/pkg/pipeline/pipeline.go +++ b/minzc/pkg/pipeline/pipeline.go @@ -336,12 +336,19 @@ func CompileHIRSteps(hm *hir.Module, opts ...Options) (Steps, error) { tr.Backend = "PBQP" } } + if err := mir2.ValidateZ80Symbols(m); err != nil { + return s, err + } if err := mir2.ValidateZ80IndirectCalls(m); err != nil { return s, err } - s.Assembly = mir2.Z80Codegen(m, combined, mir2.Z80CodegenOptions{ + var codegenErr error + s.Assembly, codegenErr = mir2.Z80Codegen(m, combined, mir2.Z80CodegenOptions{ AnnotateTStates: opt.AnnotateTStates, }) + if codegenErr != nil { + return s, codegenErr + } // Emit stubs for @extern functions not already defined in the assembly. // Ensure CALL targets for extern functions resolve. @@ -524,7 +531,13 @@ func CompileHIRWithOptions(hm *hir.Module, opts Options) (string, error) { if err := mir2.ValidateZ80IndirectCalls(m); err != nil { return "", err } - asm := mir2.Z80Codegen(m, combined) + if err := mir2.ValidateZ80Symbols(m); err != nil { + return "", err + } + asm, err := mir2.Z80Codegen(m, combined) + if err != nil { + return "", err + } // Z80 binary assertion checks (skip "mir2"-only asserts). if err := RunAssertsZ80(hm, m, combined, asm); err != nil { diff --git a/minzc/pkg/plm/codegen_result_external_test.go b/minzc/pkg/plm/codegen_result_external_test.go new file mode 100644 index 000000000..6796cfa86 --- /dev/null +++ b/minzc/pkg/plm/codegen_result_external_test.go @@ -0,0 +1,8 @@ +package plm_test + +func mustZ80Asm(asm string, err error) string { + if err != nil { + panic(err) + } + return asm +} diff --git a/minzc/pkg/plm/plm_test.go b/minzc/pkg/plm/plm_test.go index 77fe57c76..4eac10045 100644 --- a/minzc/pkg/plm/plm_test.go +++ b/minzc/pkg/plm/plm_test.go @@ -46,7 +46,7 @@ func compileHIR(t *testing.T, hm *hir.Module) string { combined.Locs[r] = loc } } - return mir2.Z80Codegen(m, combined) + return mustZ80Asm(mir2.Z80Codegen(m, combined)) } // runZ80 assembles src and runs it; returns (A, HL, err). diff --git a/minzc/pkg/vir/codegen_result_external_test.go b/minzc/pkg/vir/codegen_result_external_test.go new file mode 100644 index 000000000..9da303cd8 --- /dev/null +++ b/minzc/pkg/vir/codegen_result_external_test.go @@ -0,0 +1,8 @@ +package vir_test + +func mustZ80Asm(asm string, err error) string { + if err != nil { + panic(err) + } + return asm +} diff --git a/minzc/pkg/vir/compare_test.go b/minzc/pkg/vir/compare_test.go index 67c7976f9..3177597c8 100644 --- a/minzc/pkg/vir/compare_test.go +++ b/minzc/pkg/vir/compare_test.go @@ -224,7 +224,7 @@ func compilePBQP(t *testing.T, src, name string) (int, string) { ct := mir2.Z80CostTable{} alloc = mir2.Allocate(f, lr, ct) } - asm := mir2.Z80Codegen(m, alloc, mir2.Z80CodegenOptions{}) + asm := mustZ80Asm(mir2.Z80Codegen(m, alloc, mir2.Z80CodegenOptions{})) return countInstructions(asm), asm } diff --git a/minzc/pkg/vir/fix3_test.go b/minzc/pkg/vir/fix3_test.go new file mode 100644 index 000000000..1766c2cb8 --- /dev/null +++ b/minzc/pkg/vir/fix3_test.go @@ -0,0 +1,27 @@ +package vir + +import ( + "github.com/minz/minzc/pkg/mir2" + "path/filepath" + "strings" + "testing" +) + +func TestPBQPFallbackReportsSymbolCollision(t *testing.T) { + m := &mir2.Module{} + for _, name := range []string{"f", "v_f"} { + f := m.AddFunc(name) + b := f.NewBlock("entry") + b.Seal(&mir2.TermRet{}) + } + // A missing solver forces the production PBQP fallback without requiring Z3. + _, results := CodegenModule(m, SolverOptions{Z3Path: filepath.Join(t.TempDir(), "missing-z3"), PBQPAlloc: &mir2.AllocResult{}}) + if len(results) != 2 { + t.Fatalf("results = %+v", results) + } + for _, r := range results { + if r.OK || !strings.Contains(r.Error, "ambiguous Z80 symbol") { + t.Fatalf("result = %+v", r) + } + } +} diff --git a/minzc/pkg/vir/pipeline.go b/minzc/pkg/vir/pipeline.go index 64d304ef3..4ec738042 100644 --- a/minzc/pkg/vir/pipeline.go +++ b/minzc/pkg/vir/pipeline.go @@ -183,9 +183,11 @@ func CodegenModule(m *mir2.Module, opts SolverOptions) (string, []FuncResult) { // generate PBQP ASM for this function as safety net. if opts.PBQPAlloc != nil { // Generate full PBQP module, extract this function's ASM - fullPBQP := mir2.Z80Codegen(m, opts.PBQPAlloc) + fullPBQP, codegenErr := mir2.Z80Codegen(m, opts.PBQPAlloc) pbqpASM := extractFuncFromASM(fullPBQP, f.Name) - if pbqpASM != "" { + if codegenErr != nil { + r.Error = codegenErr.Error() + } else if pbqpASM != "" { r.OK = true r.ASM = pbqpASM sb.WriteString("; VIR→PBQP fallback for " + f.Name + "\n") diff --git a/reports/2026-10-02-P9-Extern-Clobbers.md b/reports/2026-10-02-P9-Extern-Clobbers.md new file mode 100644 index 000000000..6ce895f3e --- /dev/null +++ b/reports/2026-10-02-P9-Extern-Clobbers.md @@ -0,0 +1,341 @@ +# P9: explicit external-call clobber contracts + +Baseline: `origin/main` at `6b8fc3964ca690a14a9ef4b42c22af63e56cf4e1`. + +## Resolution investigation + +Nanz `@extern` declarations are lowered to bodyless MIR2 functions. Z80 +`genCall` emits a symbolic CALL/JP, or CALL/RST to an explicit address. +`peek` and `poke` are not special names in Nanz, HIR, MIR2, or this emitter. +Their self-hosting examples run on MZV, whose implementations are host callbacks +in `minzc/cmd/mzv/file_host.go` (heap read/write), not emitted Z80 instructions. +No Z80 preservation contract follows from those callbacks. Thus the three +self-hosting examples remain unannotated, and inline-intrinsic write derivation +and a self_lanz_parser byte-count reduction do not apply to these calls. + +The actual inline cases in `z80codegen_call.go` are `@mir.io.print.*`, +`@mir.io.console.log/err`, and the error-control operations `@error`, `@check`, +and `@propagate`. These are separate compiler operations; extern declarations +cannot narrow them. Their emission is unchanged by this change. + +The active Nanz example externs include MZV file/heap, SQLite, canvas, TUI and +selection-screen host APIs; `net_read/write` and `ctl_read/write`; and test +externs such as `process` and `add_to_acc`. They all remain real external symbols +in Z80 emission. There is no name-based intrinsic substitution. + +## Contracts and correctness + +`@extern(clobbers: "A, HL, F")` and +`@extern(0x1234, clobbers: "A, F")` carry a nullable physical register list +through HIR and the MIR2 callee contract. Nil means unknown/all registers; +non-nil empty means preservation except return registers. Unknown/unsupported +registers fail parsing. Register-pair overlap uses `pkg/z80spec.Overlaps`. +Normal functions, indirect calls and unresolved symbols retain conservative +clobbers. Invalid contracts supplied directly by another frontend fall back to +all registers. + +HIR previously returned immediately upon seeing an extern, dropping both its +parameter and return contracts. Those contracts now survive lowering. Caller +argument destination writes are counted separately, and complex shuffles keep +all-register saves. Result pickup avoids unsaved live scratch registers. + +The assembly judges exercise an exact declared write set, default preservation, +an implementation that deliberately violates its contract (and visibly corrupts +a value), live values through real peek/poke assembly stubs, result pickup, and +unresolved callees. A declaration that omits actual writes is a user contract +violation and can cause silent wrong code. No optional runtime canary mode was +added. + +## ROM/BDOS annotations + +None. `pkg/disasm/analysis/abi.go` profiles provide syscall arguments and +no-return information, not authoritative clobber/preservation sets. +`regtrack.go` conservatively produces A/F/BC/DE/HL after CALL/RST; its ABI-aware +logic describes *consumption*, not preservation. `stdlib/cpm/bdos.minz` uses +assembly wrappers around CALL 0x0005, rather than Nanz extern declarations. +ZX console helpers likewise have assembly bodies. No narrower ROM/BDOS contract +was inferred from these sources. + +## Corpus comparison + +`/tmp/p9_corpus.go` was run separately from the branch and a `git archive +origin/main` checkout in `/tmp/p9-main`. It walks active `.nanz` and `.minz` +examples, excludes `_archive`, `aspirational`, `experimental`, `invalid` and +`working`, and uses the Nanz frontend and the default PBQP pipeline with asserts +disabled during compilation. Successfully assembled binaries are measured at +ORG 0x8000. Each top-level assertion is then judged separately on its requested +backend(s). Sandbox assertion prefixes retain shared-state semantics. +`assert_matrix.py` is absent from this baseline. Raw results are +`/tmp/p9-{main,branch}-corpus.json` (the final line is JSON; compiler diagnostics +may precede it), with logs beside them. + +| Measurement | origin/main | branch | +|---|---:|---:| +| Active source files | 206 | 206 | +| Successfully assembled files | 94 | 94 | +| Total assembled bytes | 50,757 | 51,409 | +| Nanz-only assembled bytes | 48,311 | 48,963 | +| Passing assertion/backend checks | 522 | 522 | +| Failing assertion/backend checks | 10 | 10 | +| self_lanz_parser bytes | 2,092 | 2,154 | + +Zero newly failing assertions and zero newly failing compile/assembly outcomes. +The 652-byte increase is the correctness cost of restoring extern ABI setup and +preserving live values at unresolved calls. Real externs without declarations +still clobber everything. Narrow contracts remove saves in the dedicated +assembly judges; no unsupported contracts were added to the existing corpus. + +The existing failing assertion checks are: + +| File | Check | Baseline diagnostic | +|---|---|---| +| `examples/nanz/hello_cpm_fib.nanz` | `top/1/z80` | line 47: assert "assert gcd(12, 8) == 4" [z80]: got 8, want 4 | +| `examples/tests/asm_caller_callee.nanz` | `top/2/z80` | line 28: assert "assert test_abc() == 60 via z80" [z80]: got 70, want 60 | +| `examples/tests/asm_caller_callee.nanz` | `top/7/z80` | line 67: assert "assert test_multi1() == 6 via z80" [z80]: got 7, want 6 | +| `examples/tests/fib_parallel_copy.nanz` | `top/3/z80` | line 17: assert "assert fib(3) == 2 via z80" [z80]: got 1, want 2 | +| `examples/tests/fib_parallel_copy.nanz` | `top/4/z80` | line 18: assert "assert fib(4) == 3 via z80" [z80]: got 1, want 3 | +| `examples/tests/fib_parallel_copy.nanz` | `top/5/z80` | line 19: assert "assert fib(5) == 5 via z80" [z80]: got 1, want 5 | +| `examples/tests/fib_parallel_copy.nanz` | `top/6/z80` | line 20: assert "assert fib(6) == 8 via z80" [z80]: got 1, want 8 | +| `examples/tests/fib_parallel_copy.nanz` | `top/7/z80` | line 21: assert "assert fib(7) == 13 via z80" [z80]: got 1, want 13 | +| `examples/zx/plasma.nanz` | `top/4/z80` | line 73: assert "assert color_to_attr_s(200, 200, 200) == 7" [z80]: got 71, want 7 | +| `examples/zx/plasma.nanz` | `top/5/z80` | line 74: assert "assert color_to_attr_s(0, 0, 0) == 0" [z80]: got 64, want 0 | + +## Validation + +Required gates use `set -o pipefail`, `GOCACHE=/tmp/minz-go-cache`, and +`GOFLAGS=-buildvcs=false`, sequentially: + +- `go build ./pkg/... ./cmd/...`: exit 0. +- `go test ./pkg/hir ./pkg/mir2 ./pkg/nanz -count=1 -skip '^TestShowcaseCompileAssemble$'`: exit 0. +- `go test -short ./pkg/pipeline/... ./pkg/c89/... -count=1`: exit 0. + +Both corpus runners exited 0. A mutation audit separately disables declared +clobbers, argument-write accounting, pair overlap, safe pickup scratch selection, +unresolved-call saves, and extern ABI lowering. Each mutation must make its +specific regression test fail with exit 1, then the original source is restored. + +## FIX1: ambiguous symbols and diagnostics + +MIR2 calls carry a symbol name (`Inst.Sym`), not an exact function identity. +Code generation now resolves an ABI only for a unique name. Duplicate names +fall back to conservative saves; an extern with MIR2 blocks cannot narrow +clobbers either. Nanz rejects extern/body name collisions in either declaration +order and conflicting extern redeclarations, including overloaded parameter +types, register ABI, return types, address, and clobber contracts. Existing +ordinary operator overloads do not establish overload semantics for unmangled +extern symbols. Identical extern redeclarations remain accepted for imports; +parameter names do not affect signature equality. + +Malformed extern attributes now show both supported clobber syntax forms. +An extra comma in a register list reports an empty clobber register entry. +The language book explains untracked shadow registers, I and R, and why AF' +is rejected. This report now follows the date-prefixed reports convention. + +New regressions cover both critic scenarios, declaration order, additional +contract/ABI conflicts, ambiguous MIR2 callees with executable clobber stubs, +externs with blocks, malformed syntax, and empty register entries. Restoring +pre-fix implementation files made each of four regression groups fail with +exit 1; the mutation audit exited 0. Logs: `/tmp/p9-fix1-mutations.log`. + +Required gates ran sequentially with `set -o pipefail`, +`GOCACHE=/tmp/minz-go-cache`, and `GOFLAGS=-buildvcs=false`: + +- `go build ./pkg/... ./cmd/...`: exit 0. +- `go test ./pkg/hir ./pkg/mir2 ./pkg/nanz -count=1 -skip '^TestShowcaseCompileAssemble$'`: exit 0. +- `go test -short ./pkg/pipeline/... ./pkg/c89/... -count=1`: exit 0. + +Critic probes reran using a compiler rebuilt from this worktree, original +sources and stubs, and the supplied assembler/emulator. p1 compiles/assembles +with exit 0 and returns A=10; p2 and unannotated control p2c compile/assemble +with exit 0 and return A=55. Emulator process exits equal those return values. +p3 rejects overloaded externs with compile exit 1. p4 and its unannotated +control p4m reject extern/body collisions with compile exit 1. The probe audit +exited 0; artifacts and full command logs: `/tmp/p9-fix1-critic/`. + +## FIX2: retain extern ABIs and validate emitted symbols + +Identical bodyless extern redeclarations now produce exactly one `Module.Funcs` +entry after local declarations, imports, and generated helpers are merged. +Parameter names do not affect equivalence. Clobbers compare as sets, ignoring +order and repeated register entries, while nil (unknown writes) remains distinct +from an explicitly empty contract. Conflicting declarations still fail. + +Z80 symbol validation uses the emitter's `sanitizeIdent`, including register-name +prefixing (`f` becomes `v_f`) and generated-name punctuation (`x$split_1` becomes +`x_split_1`). Both pipeline entry points report collisions before emission. +Function/function and function/global collisions fail rather than reaching label +deduplication. Direct codegen also rejects invalid modules. Call resolution uses +emitted symbols and fails on ambiguity; it never substitutes a nil callee and +loses argument setup, result pickup, or a fixed address. A preservation decision +cannot discard the rest of a callee's ABI. + +`hir/lower.go` now gives externs a **Params contract**, fixing extern argument +passing for small arities, not only declared-clobber calls. **Known gap:** each +extern parameter class maps straight to one fixed register, so signatures with +several parameters of the same class collide (e.g. `(u16, u16, u16)` -> HL, DE, +DE; four `u8` -> A, C, B, C; `canvas_line` in `examples/frill/graphics.frl`) and +the call site silently loses an argument. origin/main set up no extern arguments +at all, so this is not a regression; distinct-register assignment for externs is +a follow-up. The critic's single extern +`ps1` returns 3 on origin/main and 6 on this branch with the same `ADD A,C; RET` +stub. This changes the extern ABI for other frontends using HIR lowering: callers +now copy arguments into the contract registers and pick up the declared return. +External implementations must obey those parameter/return contracts as well as +any declared clobbers. + +The optional removal of empty labels for bodyless externs without an address is +**deferred for corpus compatibility**. `self_lanz_parser.nanz` still calls the +unprovided Z80 host symbols `peek` and `poke`; its current output assembles with +exit 0, but deleting those two labels makes the same assembler fail with exit 1 +and undefined-symbol diagnostics. The pipeline's `emitExternStubs` can also +reintroduce missing labels with RET stubs, so removing codegen labels alone would +not establish a reliable unresolved-extern error. Existing empty labels may still +fall through if no external implementation is supplied; they are not valid +implementations. Changing this behavior requires an explicit linking/stub policy +and corpus migration. No preservation guarantee is inferred from those labels. +Evidence: `/tmp/p9-fix2-critic/{with,no}-extern-labels.log`. + +Regression tests execute repeated declarations (`ps`), fixed-address tail calls +(`pt`), and two imported declarations (`pe`), expecting A=6. Address tests also +require `JP 0x1234` and reject `JP ext`. Further tests reject the `f`/`v_f` +collision (`pv`), generated split-name and global collisions, and ambiguous +MIR2 callees. Disabling deduplication, clobber-set comparison, emitted-symbol +validation, and ambiguous-call rejection separately makes the corresponding +regression fail (exit 1 for each); audit exit 0. Restored-source focused tests +exit 0. Log: `/tmp/p9-fix2-mutations.log`. + +Required gates ran sequentially with `set -o pipefail`, +`GOCACHE=/tmp/minz-go-cache`, and `GOFLAGS=-buildvcs=false`: + +- `go build ./pkg/... ./cmd/...`: exit 0. +- `go test ./pkg/hir ./pkg/mir2 ./pkg/nanz -count=1 -skip '^TestShowcaseCompileAssemble$'`: exit 0. +- `go test -short ./pkg/pipeline/... ./pkg/c89/... -count=1`: exit 0. + +Rebuilt compiler and original critic sources/assembler/emulator: + +| Suite | Repro | Result | +|---|---|---| +| critic-P9 | p1 | A=10 | +| critic-P9 | p2, p2c | A=55 each | +| critic-P9 | p3 | compile exit 1, conflicting externs | +| critic-P9 | p4, p4m | compile exit 1, extern/body collisions | +| critic-P9b | ps, ps1 | A=6 each | +| critic-P9b | pt, pe | A=6 each, fixed address retained | +| critic-P9b | pv | compile exit 1, ambiguous emitted symbol | + +Successful repros compile and assemble with exit 0; emulator exit codes equal A. +For pt/pe the harness redirects literal `0x1234` to a supplied assembly stub, +after first verifying the original assembly's literal address and absence of +`JP ext`. Probe audit exit 0, artifacts: `/tmp/p9-fix2-critic/`. + +The critic-style sweep covers all 64 top-level `examples/nanz/*.nanz` files with +the supplied origin/main compiler and the rebuilt branch compiler: 63 compile +successfully on each, zero changed compile statuses, runner exit 0. The shared +failure is `hello_cpm_fib.nanz`'s known Z80 gcd assertion. Logs and JSON: +`/tmp/p9-fix2-sweep/`, `/tmp/p9-fix2-sweep.log`. + +## FIX3: Frill registration, reserved helpers, and codegen errors + +Frill transfers an adopted curried/composed helper out of `autoFuncs` into its +single top-level declaration. Imports keep the first function binding, including +its arity and IO status. This preserves main's first-definition behavior for +shared library names: `functional_demo.frl` imports both functional and math +libraries, which repeat `max`, `min`, `clamp`, `is_even`, and `is_odd`. In +particular, the two `clamp` bodies differ syntactically. Removing duplicate +frontend registrations allows `functional_demo.frl`, `pipe.frl`, `showcase.frl`, +and `stdlib_demo.frl` to compile and assemble with strict symbol validation. +Their MIR2 assertions pass. Local conflicting definitions still reach the strict +codegen validation; no assembly-label deduplication was restored. + +`ValidateZ80Symbols` reserves `__mul8`, `__mul16`, `__call_ix`, all eight +`__rotate_N` entries, and the `_mir2_str_`, `_spill_`, and `_tsmc_` generated +namespaces. It also reserves actual struct-field EQU aliases and named SMC slot +patchers/aliases using their emission spelling. Functions and globals cannot +claim these names, even when a helper is unused. Both critic multiply repros, +`pm.nanz` and the unannotated `pm0.nanz`, are rejected. A preservation contract +cannot redefine the compiler's multiplication implementation. + +`Z80Codegen` now returns `(string, error)` and no assembly on validation errors. +`genCall` returns an ambiguity error; the emitter propagates its first call error. +Both pipeline entry points and VIR's PBQP fallback consume these errors. The +VIR regression forces the fallback with a missing solver executable and checks +that colliding emitted symbols produce failed `FuncResult`s instead of a panic. +Success-only test callers unwrap results through package-local test helpers. + +### Cross-frontend extern ABI effects + +The critic's original cross-frontend sweep records **27 changed generated +assembly files** from the extern Params/return contract work. This extends beyond +clobber narrowing: Nanz, ABAP, PL/M, and Frill callers now obey HIR extern +parameter and return contracts. After stripping comments, 25 of those outputs +still differ; `02_sum_array_idiomatic.nanz` and `tui_commander_l3.nanz` have only +trace/comment changes in that snapshot. None change compile/assemble status. +The original comparison is in critic-P9c's `sw-main/` and `sw-branch/`. + +For example, `tui_demo.nanz` now emits `LD E,C; LD D,0` before +`CALL sel_register_int`, placing the default value into the extern's DE parameter. +`examples/abap/hello_input.abap` gains argument setup and saves around extern +calls; `examples/plm/hello.plm` likewise changes its external-call ABI code. +External implementations must follow these Params/return contracts, as well as +any declared clobbers. + +**Known pre-existing C issue:** C prototypes are not HIR `@extern` declarations. +The critic's `pc.c` declares `ext(a,b)` and calls `ext(x,x)` through `twice`. +Both origin/main and this branch emit `JP ext` without setting up `b`. Retaining +HIR extern contracts does not repair this separate C prototype lowering issue. +This is not a FIX3 regression. + +### Full corpus status comparison + +Baseline remains origin/main `6b8fc396`; fetch/rebase reported the branch current. +The rebuilt branch compiler and the critic's main compiler were run with +`--asserts none`, followed by the same assembler. The sweep recursively includes +all 510 `.nanz`, `.c`, `.abap`, `.pas`, `.plm`, `.frl`, `.lanz`, `.lizp`, and +`.minz` sources under `examples`, including archived/invalid examples. Both C +directories and root MinZ examples are covered; Lanz has one source outside a +dedicated `examples/lanz` directory. + +| Measurement | origin/main | FIX3 branch | +|---|---:|---:| +| Sources | 510 | 510 | +| Compile successes | 296 | 296 | +| Assemble successes | 233 | 233 | +| Total assembled bytes | 133,714 | 134,283 | + +Zero compile/assemble status changes and zero regressions; sweep exit 0. +The +569-byte aggregate includes the extern ABI repairs and removal of duplicate +Frill helper bodies. Logs, per-file assembly/binaries, and JSON: +`/tmp/p9-fix3-sweep/`, `/tmp/p9-fix3-sweep.log`; runner: +`/tmp/p9-fix3-sweep.py`. + +The isolated assertion harness inspects all 82 Nanz/Frill sources in those two +example directories, resolves stdlib imports, disables assertions during normal +compilation, then runs each top-level assert separately with Via cleared on Z80 +(the `--asserts-force z80` semantics). Sandbox checks retain each shared-state +prefix and also clear Via. Both versions run **3,737 checks: 2,801 pass and 936 +fail**, with **zero newly failing checks**. This includes exhaustive Frill +properties originally intended for MIR2; forcing Z80 exposes existing failures. +`examples/frill/hello.frl` has the same pre-existing parse failure on both and +cannot run its assertions. Both runner exits and the comparison exit are 0. +Data/logs: `/tmp/p9-fix3-{main,branch}-asserts.{json,log}`; runner: +`/tmp/p9-fix3-asserts.go`. + +Seven independently disabled fixes each make their regression test fail with +exit 1: helper ownership, import merging, reserved runtime namespaces, generated +aliases, codegen error returns, call error returns, and VIR error propagation. +The source is restored after each mutation. Audit exit 0; log and runner: +`/tmp/p9-fix3-mutations.log`, `/tmp/p9-fix3-mutations.py`. + +Final gates ran one at a time with `set -o pipefail`, +`GOCACHE=/tmp/minz-go-cache`, and `GOFLAGS=-buildvcs=false`: + +- `go build ./pkg/... ./cmd/...`: exit 0. +- `go test ./pkg/hir ./pkg/mir2 ./pkg/nanz ./pkg/frill -count=1 -skip '^TestShowcaseCompileAssemble$'`: exit 0. +- `go test -short ./pkg/pipeline/... ./pkg/c89/... -count=1`: exit 0. +- Restored-source VIR fallback regression: exit 0. + +Logs: `/tmp/p9-fix3-{build,core,pipeline,vir}-final.log`. +The original critic `pm` and `pm0` probes both reject the reserved helper with +compile exit 1. `pc.c` compiles with exit 0 on both compilers and retains the +same missing second-argument setup. Probe audit exit 0; artifacts and log: +`/tmp/p9-fix3-probes/`, `/tmp/p9-fix3-probes.log`.