From ccf9c64264584d902268d43cbe3225feb79d555c Mon Sep 17 00:00:00 2001 From: Alice Vinogradova Date: Fri, 2 Oct 2026 12:56:04 +0000 Subject: [PATCH 1/6] feat(nanz): declare external-call register clobbers Carry explicit physical clobber contracts through HIR and MIR2 and preserve live callers using Z80 register overlap. Keep unknown calls conservative, retain external parameter/return ABIs, and account for caller setup and result pickup writes. Document contracts and corpus findings, with assembly judges and mutation-verified regressions. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wrwo36SzxKRZYTgpDo7hzo --- docs/Nanz_Language_Book_v4.md | 28 +++++ minzc/pkg/hir/hir.go | 17 +-- minzc/pkg/hir/lower.go | 14 ++- minzc/pkg/mir2/extern_clobbers_test.go | 138 +++++++++++++++++++++ minzc/pkg/mir2/func.go | 7 +- minzc/pkg/mir2/z80codegen_call.go | 102 ++++++++++++--- minzc/pkg/nanz/extern_clobbers_test.go | 60 +++++++++ minzc/pkg/nanz/parse.go | 45 +++++-- minzc/pkg/nanz/print.go | 18 ++- minzc/pkg/pipeline/extern_clobbers_test.go | 48 +++++++ reports/P9_Extern_Clobbers.md | 115 +++++++++++++++++ 11 files changed, 550 insertions(+), 42 deletions(-) create mode 100644 minzc/pkg/mir2/extern_clobbers_test.go create mode 100644 minzc/pkg/nanz/extern_clobbers_test.go create mode 100644 minzc/pkg/pipeline/extern_clobbers_test.go create mode 100644 reports/P9_Extern_Clobbers.md diff --git a/docs/Nanz_Language_Book_v4.md b/docs/Nanz_Language_Book_v4.md index 6221f5946..4e0c1daa2 100644 --- a/docs/Nanz_Language_Book_v4.md +++ b/docs/Nanz_Language_Book_v4.md @@ -1165,6 +1165,34 @@ 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. + +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/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/mir2/extern_clobbers_test.go b/minzc/pkg/mir2/extern_clobbers_test.go new file mode 100644 index 000000000..abd6aa3d1 --- /dev/null +++ b/minzc/pkg/mir2/extern_clobbers_test.go @@ -0,0 +1,138 @@ +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) + } +} 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/z80codegen_call.go b/minzc/pkg/mir2/z80codegen_call.go index 3c41b9733..8e9baf8a1 100644 --- a/minzc/pkg/mir2/z80codegen_call.go +++ b/minzc/pkg/mir2/z80codegen_call.go @@ -3,6 +3,8 @@ package mir2 import ( "slices" "strings" + + "github.com/minz/minzc/pkg/z80spec" ) // ── Calls ───────────────────────────────────────────────────────────────────── @@ -163,7 +165,7 @@ func (g *z80cg) genCall(inst *Inst) { // 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) @@ -239,7 +241,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. @@ -270,7 +278,7 @@ func (g *z80cg) genCall(inst *Inst) { // 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 +303,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 +614,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 && 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 +684,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 +889,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 && 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/nanz/extern_clobbers_test.go b/minzc/pkg/nanz/extern_clobbers_test.go new file mode 100644 index 000000000..bb5c0efa7 --- /dev/null +++ b/minzc/pkg/nanz/extern_clobbers_test.go @@ -0,0 +1,60 @@ +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) + } + } +} diff --git a/minzc/pkg/nanz/parse.go b/minzc/pkg/nanz/parse.go index d93304757..c07e93f30 100644 --- a/minzc/pkg/nanz/parse.go +++ b/minzc/pkg/nanz/parse.go @@ -784,19 +784,47 @@ 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, err + } + if _, err := p.l.eat(tokColon); err != nil { + return nil, err + } + regs, err := p.l.eat(tokString) + if err != nil { + return nil, err + } + externClobbers = []string{} + if strings.TrimSpace(regs.val) != "" { + for _, name := range strings.Split(regs.val, ",") { + name = strings.ToUpper(strings.TrimSpace(name)) + 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 } @@ -809,6 +837,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) 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/reports/P9_Extern_Clobbers.md b/reports/P9_Extern_Clobbers.md new file mode 100644 index 000000000..ca647907c --- /dev/null +++ b/reports/P9_Extern_Clobbers.md @@ -0,0 +1,115 @@ +# 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. From 920581e2ccdf2fc16feb7ff2f9e06e0fbdec11c9 Mon Sep 17 00:00:00 2001 From: Alice Vinogradova Date: Fri, 2 Oct 2026 13:16:20 +0000 Subject: [PATCH 2/6] Fix ambiguous extern clobber contracts and parser diagnostics Require unique callee names and bodyless externs before narrowing caller saves. Reject extern/body collisions and conflicting extern ABIs, improve attribute errors, and document untracked registers. Add regression judges and record FIX1 validation in the dated report. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wrwo36SzxKRZYTgpDo7hzo --- docs/Nanz_Language_Book_v4.md | 12 +++++ minzc/pkg/mir2/extern_clobbers_test.go | 31 ++++++++++++ minzc/pkg/mir2/z80codegen_call.go | 16 +++++-- minzc/pkg/nanz/extern_clobbers_test.go | 47 +++++++++++++++++++ minzc/pkg/nanz/parse.go | 40 ++++++++++++++-- ...rs.md => 2026-10-02-P9-Extern-Clobbers.md} | 38 +++++++++++++++ 6 files changed, 177 insertions(+), 7 deletions(-) rename reports/{P9_Extern_Clobbers.md => 2026-10-02-P9-Extern-Clobbers.md} (74%) diff --git a/docs/Nanz_Language_Book_v4.md b/docs/Nanz_Language_Book_v4.md index 4e0c1daa2..96c3917c5 100644 --- a/docs/Nanz_Language_Book_v4.md +++ b/docs/Nanz_Language_Book_v4.md @@ -1180,6 +1180,18 @@ registers. Names are case insensitive; supported names are `A`, `F`, `B`, `C`, 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 diff --git a/minzc/pkg/mir2/extern_clobbers_test.go b/minzc/pkg/mir2/extern_clobbers_test.go index abd6aa3d1..78c4ea4f6 100644 --- a/minzc/pkg/mir2/extern_clobbers_test.go +++ b/minzc/pkg/mir2/extern_clobbers_test.go @@ -136,3 +136,34 @@ func TestUnresolvedExternCallPreservesLiveValues(t *testing.T) { t.Fatalf("unresolved call lost live C: %+v", regs) } } + +func TestExternAmbiguousCalleeSaves(t *testing.T) { + for _, compiled := range []bool{false, true} { + g, inst, _ := externCallFixture("C", []string{}) + other := g.mod.AddFunc("ext") + other.Attrs.IsExtern = !compiled + if compiled { + other.Blocks = []*Block{{Label: "entry"}} + } + g.genCall(inst) + asm := g.sb.String() + if !strings.Contains(asm, "PUSH BC") { + t.Fatalf("ambiguous callee must save live C: %s", asm) + } + regs := runExternJudge(t, "ORG 0x8000\nLD C, 42\n"+asm+"LD A, C\nHALT\next:\nLD C, 99\nRET\n") + if regs.A != 42 { + t.Fatalf("live C corrupted: A=%d", regs.A) + } + } +} + +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) + } +} diff --git a/minzc/pkg/mir2/z80codegen_call.go b/minzc/pkg/mir2/z80codegen_call.go index 8e9baf8a1..ea93eabb7 100644 --- a/minzc/pkg/mir2/z80codegen_call.go +++ b/minzc/pkg/mir2/z80codegen_call.go @@ -160,7 +160,17 @@ 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) + // Calls carry a symbol, not a function identity. Ambiguous names cannot + // supply either an ABI or a preservation guarantee. + for _, f := range g.mod.Funcs { + if f.Name == inst.Sym { + if callee != nil { + callee = nil + break + } + callee = f + } + } } // Save live caller values before argument copies, including reused args. @@ -624,7 +634,7 @@ func (g *z80cg) callerSavePairs(inst *Inst, callee *Func) []string { // 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 && callee.Contract.ExternClobbers != nil { + 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) { @@ -900,7 +910,7 @@ var allZ80Clobbers = []string{"A", "B", "BC", "C", "D", "DE", "E", "F", "H", "HL // computeClobbers returns a conservative sorted superset of emitted writes. func computeClobbers(f *Func, ar *AllocResult) []string { - if f != nil && f.Attrs.IsExtern && f.Contract.ExternClobbers != nil { + 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 diff --git a/minzc/pkg/nanz/extern_clobbers_test.go b/minzc/pkg/nanz/extern_clobbers_test.go index bb5c0efa7..34db88357 100644 --- a/minzc/pkg/nanz/extern_clobbers_test.go +++ b/minzc/pkg/nanz/extern_clobbers_test.go @@ -58,3 +58,50 @@ func TestExternClobbersRejectUnknown(t *testing.T) { } } } + +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/parse.go b/minzc/pkg/nanz/parse.go index c07e93f30..fb86c93d9 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" @@ -805,19 +806,22 @@ func (p *parser) parseModule() (*hir.Module, error) { } if needClobbers { if err := p.l.eatIdent("clobbers"); 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.eat(tokColon); err != nil { - return nil, err + 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, err + 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) } @@ -826,7 +830,7 @@ func (p *parser) parseModule() (*hir.Module, error) { } } 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 { @@ -1057,6 +1061,34 @@ 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) + for _, f := range m.Funcs { + 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 && reflect.DeepEqual(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) + } + } + byName[f.Name] = append(byName[f.Name], f) + } + // 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 diff --git a/reports/P9_Extern_Clobbers.md b/reports/2026-10-02-P9-Extern-Clobbers.md similarity index 74% rename from reports/P9_Extern_Clobbers.md rename to reports/2026-10-02-P9-Extern-Clobbers.md index ca647907c..e0a569b08 100644 --- a/reports/P9_Extern_Clobbers.md +++ b/reports/2026-10-02-P9-Extern-Clobbers.md @@ -113,3 +113,41 @@ 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/`. From 51352e2fcd83006f6f935cb9813c26cf6441c61e Mon Sep 17 00:00:00 2001 From: Alice Vinogradova Date: Fri, 2 Oct 2026 13:40:12 +0000 Subject: [PATCH 3/6] Fix extern redeclaration ABI loss and emitted symbol collisions Deduplicate identical bodyless extern declarations after imports merge and compare clobber contracts as sets. Reject colliding emitted Z80 symbols and ambiguous calls instead of dropping argument, return, and address contracts. Add executable regression tests and record critic, mutation, corpus, and gate results, including the unresolved-extern compatibility limitation. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wrwo36SzxKRZYTgpDo7hzo --- minzc/pkg/mir2/extern_clobbers_test.go | 67 +++++++++---- minzc/pkg/mir2/z80codegen.go | 28 ++++++ minzc/pkg/mir2/z80codegen_call.go | 9 +- minzc/pkg/nanz/extern_redeclaration_test.go | 104 ++++++++++++++++++++ minzc/pkg/nanz/parse.go | 26 ++++- minzc/pkg/pipeline/pipeline.go | 6 ++ reports/2026-10-02-P9-Extern-Clobbers.md | 76 ++++++++++++++ 7 files changed, 292 insertions(+), 24 deletions(-) create mode 100644 minzc/pkg/nanz/extern_redeclaration_test.go diff --git a/minzc/pkg/mir2/extern_clobbers_test.go b/minzc/pkg/mir2/extern_clobbers_test.go index 78c4ea4f6..e63f0cba6 100644 --- a/minzc/pkg/mir2/extern_clobbers_test.go +++ b/minzc/pkg/mir2/extern_clobbers_test.go @@ -137,23 +137,24 @@ func TestUnresolvedExternCallPreservesLiveValues(t *testing.T) { } } -func TestExternAmbiguousCalleeSaves(t *testing.T) { - for _, compiled := range []bool{false, true} { - g, inst, _ := externCallFixture("C", []string{}) - other := g.mod.AddFunc("ext") - other.Attrs.IsExtern = !compiled - if compiled { - other.Blocks = []*Block{{Label: "entry"}} - } - g.genCall(inst) - asm := g.sb.String() - if !strings.Contains(asm, "PUSH BC") { - t.Fatalf("ambiguous callee must save live C: %s", asm) - } - regs := runExternJudge(t, "ORG 0x8000\nLD C, 42\n"+asm+"LD A, C\nHALT\next:\nLD C, 99\nRET\n") - if regs.A != 42 { - t.Fatalf("live C corrupted: A=%d", regs.A) - } +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") + } + defer func() { + if recover() == nil { + t.Fatal("ambiguous call did not fail") + } + }() + g.genCall(inst) + }) } } @@ -167,3 +168,35 @@ func TestExternWithBodyStaysConservative(t *testing.T) { 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") + } + defer func() { + if recover() == nil { + t.Fatal("codegen emitted colliding labels") + } + }() + Z80Codegen(m, &AllocResult{}) + }) + } +} diff --git a/minzc/pkg/mir2/z80codegen.go b/minzc/pkg/mir2/z80codegen.go index fbcc942cd..0980d70f6 100644 --- a/minzc/pkg/mir2/z80codegen.go +++ b/minzc/pkg/mir2/z80codegen.go @@ -124,10 +124,38 @@ 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) + add := func(name string) error { + sym := sanitizeIdent(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 + } + 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 { + if err := ValidateZ80Symbols(m); err != nil { + panic(err) + } var opt Z80CodegenOptions if len(opts) > 0 { opt = opts[0] diff --git a/minzc/pkg/mir2/z80codegen_call.go b/minzc/pkg/mir2/z80codegen_call.go index ea93eabb7..750b07112 100644 --- a/minzc/pkg/mir2/z80codegen_call.go +++ b/minzc/pkg/mir2/z80codegen_call.go @@ -1,6 +1,7 @@ package mir2 import ( + "fmt" "slices" "strings" @@ -160,13 +161,11 @@ func (g *z80cg) genCall(inst *Inst) { callee.Contract.Returns = []Return{{Ty: inst.Ty, Class: ClassAcc}} } } else if g.mod != nil { - // Calls carry a symbol, not a function identity. Ambiguous names cannot - // supply either an ABI or a preservation guarantee. + // Resolve on emitted symbols. Never discard an ABI to handle ambiguity. for _, f := range g.mod.Funcs { - if f.Name == inst.Sym { + if sanitizeIdent(f.Name) == sym { if callee != nil { - callee = nil - break + panic(fmt.Errorf("ambiguous Z80 callee %q", sym)) } callee = f } 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/parse.go b/minzc/pkg/nanz/parse.go index fb86c93d9..e9ad9717e 100644 --- a/minzc/pkg/nanz/parse.go +++ b/minzc/pkg/nanz/parse.go @@ -1065,7 +1065,9 @@ func (p *parser) parseModule() (*hir.Module, error) { // 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 @@ -1074,7 +1076,7 @@ func (p *parser) parseModule() (*hir.Module, error) { 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 && reflect.DeepEqual(prev.ExternClobbers, f.ExternClobbers) && len(prev.Params) == len(f.Params) + 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 { @@ -1085,9 +1087,14 @@ func (p *parser) parseModule() (*hir.Module, error) { 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) } - byName[f.Name] = append(byName[f.Name], 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 @@ -1099,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/pipeline/pipeline.go b/minzc/pkg/pipeline/pipeline.go index 02bcfad97..8c6b566e8 100644 --- a/minzc/pkg/pipeline/pipeline.go +++ b/minzc/pkg/pipeline/pipeline.go @@ -336,6 +336,9 @@ 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 } @@ -524,6 +527,9 @@ func CompileHIRWithOptions(hm *hir.Module, opts Options) (string, error) { if err := mir2.ValidateZ80IndirectCalls(m); err != nil { return "", err } + if err := mir2.ValidateZ80Symbols(m); err != nil { + return "", err + } asm := mir2.Z80Codegen(m, combined) // Z80 binary assertion checks (skip "mir2"-only asserts). diff --git a/reports/2026-10-02-P9-Extern-Clobbers.md b/reports/2026-10-02-P9-Extern-Clobbers.md index e0a569b08..d194b2ca0 100644 --- a/reports/2026-10-02-P9-Extern-Clobbers.md +++ b/reports/2026-10-02-P9-Extern-Clobbers.md @@ -151,3 +151,79 @@ 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 in general, not only declared-clobber calls. 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`. From b4a7bf609c84521c6b48b88419d5307bcbfe7ff3 Mon Sep 17 00:00:00 2001 From: Alice Vinogradova Date: Fri, 2 Oct 2026 14:11:06 +0000 Subject: [PATCH 4/6] Fix Frill duplicate emission and return Z80 symbol errors Transfer adopted Frill helpers into one declaration and retain first import bindings. Reserve runtime and generated Z80 symbols, and propagate collision errors through codegen, pipelines, and VIR's PBQP fallback. Add regression tests and document cross-frontend extern ABI effects and the complete corpus audit. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wrwo36SzxKRZYTgpDo7hzo --- minzc/pkg/frill/fix3_test.go | 74 ++++++++++++ minzc/pkg/frill/frill.go | 22 +++- minzc/pkg/hir/abi_oracle_test.go | 2 +- minzc/pkg/hir/codegen_result_external_test.go | 8 ++ minzc/pkg/hir/lower_test.go | 2 +- .../pkg/mir2/codegen_result_external_test.go | 8 ++ .../pkg/mir2/codegen_result_internal_test.go | 8 ++ minzc/pkg/mir2/condret_test.go | 2 +- minzc/pkg/mir2/consteval_test.go | 6 +- minzc/pkg/mir2/constfold_test.go | 8 +- minzc/pkg/mir2/constprop_test.go | 4 +- minzc/pkg/mir2/contracts_scale_test.go | 6 +- minzc/pkg/mir2/contracts_test.go | 4 +- minzc/pkg/mir2/e2e_test.go | 4 +- minzc/pkg/mir2/examples_test.go | 8 +- minzc/pkg/mir2/extern_clobbers_test.go | 18 +-- minzc/pkg/mir2/fix1_z80_test.go | 12 +- minzc/pkg/mir2/fix3_symbols_test.go | 45 ++++++++ minzc/pkg/mir2/layout_test.go | 2 +- minzc/pkg/mir2/lutgen_test.go | 4 +- minzc/pkg/mir2/m6502_showcase_test.go | 2 +- minzc/pkg/mir2/pbqp_test.go | 2 +- minzc/pkg/mir2/precoalesce_test.go | 2 +- minzc/pkg/mir2/signed_wide_z80_test.go | 2 +- minzc/pkg/mir2/z80codegen.go | 64 ++++++++++- minzc/pkg/mir2/z80codegen_call.go | 27 ++--- minzc/pkg/mir2/z80codegen_inst.go | 4 +- minzc/pkg/mir2/z80codegen_test.go | 40 +++---- minzc/pkg/nanz/fix3_runtime_test.go | 25 +++++ minzc/pkg/pipeline/pipeline.go | 11 +- minzc/pkg/plm/codegen_result_external_test.go | 8 ++ minzc/pkg/plm/plm_test.go | 2 +- minzc/pkg/vir/codegen_result_external_test.go | 8 ++ minzc/pkg/vir/compare_test.go | 2 +- minzc/pkg/vir/fix3_test.go | 27 +++++ minzc/pkg/vir/pipeline.go | 6 +- reports/2026-10-02-P9-Extern-Clobbers.md | 106 ++++++++++++++++++ 37 files changed, 492 insertions(+), 93 deletions(-) create mode 100644 minzc/pkg/frill/fix3_test.go create mode 100644 minzc/pkg/hir/codegen_result_external_test.go create mode 100644 minzc/pkg/mir2/codegen_result_external_test.go create mode 100644 minzc/pkg/mir2/codegen_result_internal_test.go create mode 100644 minzc/pkg/mir2/fix3_symbols_test.go create mode 100644 minzc/pkg/nanz/fix3_runtime_test.go create mode 100644 minzc/pkg/plm/codegen_result_external_test.go create mode 100644 minzc/pkg/vir/codegen_result_external_test.go create mode 100644 minzc/pkg/vir/fix3_test.go 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/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/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 index e63f0cba6..7fe4c257b 100644 --- a/minzc/pkg/mir2/extern_clobbers_test.go +++ b/minzc/pkg/mir2/extern_clobbers_test.go @@ -148,12 +148,9 @@ func TestExternAmbiguousCalleeRejected(t *testing.T) { if err := ValidateZ80Symbols(g.mod); err == nil { t.Fatal("accepted ambiguous emitted symbol") } - defer func() { - if recover() == nil { - t.Fatal("ambiguous call did not fail") - } - }() - g.genCall(inst) + if err := g.genCall(inst); err == nil { + t.Fatal("ambiguous call did not fail") + } }) } } @@ -191,12 +188,9 @@ func TestZ80EmittedSymbolUniqueness(t *testing.T) { if err := ValidateZ80Symbols(m); err == nil { t.Fatal("accepted colliding symbol") } - defer func() { - if recover() == nil { - t.Fatal("codegen emitted colliding labels") - } - }() - Z80Codegen(m, &AllocResult{}) + 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/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/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 0980d70f6..c3aa9c163 100644 --- a/minzc/pkg/mir2/z80codegen.go +++ b/minzc/pkg/mir2/z80codegen.go @@ -128,14 +128,67 @@ type Z80CodegenOptions struct { // 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 @@ -152,9 +205,10 @@ func ValidateZ80Symbols(m *Module) error { // 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 { - panic(err) + return "", err } var opt Z80CodegenOptions if len(opts) > 0 { @@ -181,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:] @@ -319,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 750b07112..958b937f1 100644 --- a/minzc/pkg/mir2/z80codegen_call.go +++ b/minzc/pkg/mir2/z80codegen_call.go @@ -10,7 +10,7 @@ import ( // ── 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) ──────────────────────── @@ -32,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). @@ -105,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. @@ -125,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. @@ -138,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) @@ -165,7 +165,7 @@ func (g *z80cg) genCall(inst *Inst) { for _, f := range g.mod.Funcs { if sanitizeIdent(f.Name) == sym { if callee != nil { - panic(fmt.Errorf("ambiguous Z80 callee %q", sym)) + return fmt.Errorf("ambiguous Z80 callee %q", sym) } callee = f } @@ -200,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) @@ -283,6 +283,7 @@ func (g *z80cg) genCall(inst *Inst) { } } + return nil } // pickupCallResults never stores into code. Most calls need only parallel diff --git a/minzc/pkg/mir2/z80codegen_inst.go b/minzc/pkg/mir2/z80codegen_inst.go index 7b804afc0..a8f501426 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. 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/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/pipeline/pipeline.go b/minzc/pkg/pipeline/pipeline.go index 8c6b566e8..2314db90e 100644 --- a/minzc/pkg/pipeline/pipeline.go +++ b/minzc/pkg/pipeline/pipeline.go @@ -342,9 +342,13 @@ func CompileHIRSteps(hm *hir.Module, opts ...Options) (Steps, error) { 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. @@ -530,7 +534,10 @@ func CompileHIRWithOptions(hm *hir.Module, opts Options) (string, error) { if err := mir2.ValidateZ80Symbols(m); err != nil { return "", err } - asm := mir2.Z80Codegen(m, combined) + 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 index d194b2ca0..75d0feae5 100644 --- a/reports/2026-10-02-P9-Extern-Clobbers.md +++ b/reports/2026-10-02-P9-Extern-Clobbers.md @@ -227,3 +227,109 @@ 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`. From ab278f2670fd94983a930b15cf6fa1e3db54a88b Mon Sep 17 00:00:00 2001 From: Alice Vinogradova Date: Fri, 2 Oct 2026 14:15:36 +0000 Subject: [PATCH 5/6] test: adapt P8 regression tests to Z80Codegen error return Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wrwo36SzxKRZYTgpDo7hzo --- minzc/pkg/hir/p8_split_test.go | 6 +++++- minzc/pkg/mir2/p8_fix1_test.go | 6 +++--- minzc/pkg/mir2/p8_invalid_asm_test.go | 18 +++++++++--------- minzc/pkg/mir2/p8_spill_labels_test.go | 2 +- 4 files changed, 18 insertions(+), 14 deletions(-) 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/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) From 170344d95033933514f9f3eee721ec93211efa62 Mon Sep 17 00:00:00 2001 From: Alice Vinogradova Date: Fri, 2 Oct 2026 14:21:05 +0000 Subject: [PATCH 6/6] fix(z80): propagate conditional-call codegen errors; document extern param collisions Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wrwo36SzxKRZYTgpDo7hzo --- minzc/pkg/mir2/z80codegen_inst.go | 4 +++- reports/2026-10-02-P9-Extern-Clobbers.md | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/minzc/pkg/mir2/z80codegen_inst.go b/minzc/pkg/mir2/z80codegen_inst.go index a8f501426..c80ca07bb 100644 --- a/minzc/pkg/mir2/z80codegen_inst.go +++ b/minzc/pkg/mir2/z80codegen_inst.go @@ -914,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/reports/2026-10-02-P9-Extern-Clobbers.md b/reports/2026-10-02-P9-Extern-Clobbers.md index 75d0feae5..6ce895f3e 100644 --- a/reports/2026-10-02-P9-Extern-Clobbers.md +++ b/reports/2026-10-02-P9-Extern-Clobbers.md @@ -170,7 +170,13 @@ 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 in general, not only declared-clobber calls. The critic's single extern +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.