From 6ad130b86b08e3b024da2fc69cae88f870cc2a47 Mon Sep 17 00:00:00 2001 From: Alice Vinogradova Date: Fri, 2 Oct 2026 16:28:11 +0000 Subject: [PATCH 1/2] fix(z80): pass memory parameters through callee spill slots --- minzc/pkg/mir2/call_spilled_param_test.go | 48 +++++++++++++++++++++++ minzc/pkg/mir2/z80codegen_call.go | 21 ++++++---- 2 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 minzc/pkg/mir2/call_spilled_param_test.go diff --git a/minzc/pkg/mir2/call_spilled_param_test.go b/minzc/pkg/mir2/call_spilled_param_test.go new file mode 100644 index 00000000..a0bf9da4 --- /dev/null +++ b/minzc/pkg/mir2/call_spilled_param_test.go @@ -0,0 +1,48 @@ +package mir2 + +import ( + "strings" + "testing" + + "github.com/minz/minzc/pkg/emulator" + "github.com/minz/minzc/pkg/z80asm" +) + +func TestCallArgTargetsCalleeSpillLabel(t *testing.T) { + var out strings.Builder + alloc := &AllocResult{Locs: map[Reg]PhysLoc{ + 1: {Kind: LocReg, Name: "HL"}, + 2: {Kind: LocMem, Name: "mem", Offset: 0xF072}, + }} + caller := &Func{Name: "caller"} + callee := &Func{Name: "callee", Blocks: []*Block{{}}, Contract: Contract{Params: []Param{ + {Reg: 2, Ty: TyU16, Class: ClassPair}, + }}} + g := &z80cg{sb: &out, ar: alloc, fn: caller, physOverride: map[Reg]string{}} + g.emit(" ORG 0x8000") + g.emit(" LD HL, 0x1234") + g.emitCallArgs([]Reg{1}, callee) + g.emit(" LD HL, (_spill_callee_r2)") + g.emit(" DI") + g.emit(" HALT") + g.emit("_spill_callee_r2: DW 0") + asm := out.String() + if strings.Contains(asm, "$F072") || !strings.Contains(asm, "_spill_callee_r2") { + t.Fatalf("argument must target callee spill label:\n%s", asm) + } + assembled, err := z80asm.NewAssembler().AssembleString(asm) + if err != nil || len(assembled.Errors) != 0 { + t.Fatalf("assemble: %v, %v\n%s", err, assembled.Errors, asm) + } + z := emulator.NewRemogattoZ80() + if err := z.LoadMemory(0x8000, assembled.Binary); err != nil { + t.Fatal(err) + } + z.SetPC(0x8000) + if err := z.Run(); err != nil { + t.Fatal(err) + } + if got := z.GetRegisters().HL; got != 0x1234 { + t.Fatalf("callee spill received %04x, want 1234\n%s", got, asm) + } +} diff --git a/minzc/pkg/mir2/z80codegen_call.go b/minzc/pkg/mir2/z80codegen_call.go index 958b937f..437a1bbf 100644 --- a/minzc/pkg/mir2/z80codegen_call.go +++ b/minzc/pkg/mir2/z80codegen_call.go @@ -186,7 +186,7 @@ func (g *z80cg) genCall(inst *Inst) error { g.pushWord(g.loc(inst.Src[0])) } if len(inst.Args) > 0 && callee != nil { - g.emitCallArgs(inst.Args, callee.Contract.Params) + g.emitCallArgs(inst.Args, callee) } if indirect { g.emit(" POP IX") @@ -449,27 +449,34 @@ func (g *z80cg) pickupCallResults(results []parallelCopy, saved []string, flagRe // // Argument locations are transient. Caller values keep their original locations // after caller-save restoration, so argument setup does not change physOverride. -func (g *z80cg) emitCallArgs(args []Reg, params []Param) { +func (g *z80cg) emitCallArgs(args []Reg, callee *Func) { var copies []parallelCopy for i, arg := range args { - if i >= len(params) { + if i >= len(callee.Contract.Params) { break } + param := callee.Contract.Params[i] srcPhys := g.loc(arg) // Use the callee's actual allocated register for the param if available // (from PBQP). Fall back to canonical class-based location otherwise. dstPhys := "" - if loc, ok := g.ar.Locs[params[i].Reg]; ok && loc.Name != "" { - dstPhys = physName(loc) + if loc, ok := g.ar.Locs[param.Reg]; ok && loc.Name != "" { + if loc.Kind == LocMem && callee.Name != "" && len(callee.Blocks) > 0 { + // Production codegen reads the callee's named spill slot, not + // the allocator's provisional $F0xx offset. + dstPhys = Z80SpillLabel(callee.Name, param.Reg) + } else { + dstPhys = physName(loc) + } } if dstPhys == "" { - dstPhys = canonicalReturnLoc(params[i].Class, params[i].Ty) + dstPhys = canonicalReturnLoc(param.Class, param.Ty) } // Always include in copies — even no-ops (src==dst). This ensures the // parallel copy scratch picker knows ALL live arg registers and won't // clobber them when resolving cycles (e.g. B↔C using A as scratch // when A holds arg0). - copies = append(copies, parallelCopy{srcName: srcPhys, dstName: dstPhys, ty: params[i].Ty}) + copies = append(copies, parallelCopy{srcName: srcPhys, dstName: dstPhys, ty: param.Ty}) } if len(copies) == 0 { return From 5cf9c766e4e8c38d56404d9bd98f59ed3aa7de39 Mon Sep 17 00:00:00 2001 From: Alice Vinogradova Date: Fri, 2 Oct 2026 16:40:35 +0000 Subject: [PATCH 2/2] test(z80): reproduce spilled call parameter in Nanz --- .../pipeline/call_spilled_param_e2e_test.go | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 minzc/pkg/pipeline/call_spilled_param_e2e_test.go diff --git a/minzc/pkg/pipeline/call_spilled_param_e2e_test.go b/minzc/pkg/pipeline/call_spilled_param_e2e_test.go new file mode 100644 index 00000000..5457f493 --- /dev/null +++ b/minzc/pkg/pipeline/call_spilled_param_e2e_test.go @@ -0,0 +1,45 @@ +package pipeline + +import ( + "strings" + "testing" + + "github.com/minz/minzc/pkg/hir" + "github.com/minz/minzc/pkg/nanz" +) + +// The mutable global keeps the call dynamic. PBQP spills f's second parameter; +// the caller must write the named slot that f actually reads. +func TestZ80CallIntoSpilledParameter(t *testing.T) { + const source = `global gb: u16 = 6683 +fun f(a: u16, b: u16, c: u16) -> u16 { +let d: u8 = ((a as u8) xor (c as u8)) +let t1: u16 = ((b + 1000) + (a & c)) +let t2: u16 = ((t1 xor b) - (a | c)) +let t3: u16 = ((t2 + a) xor (t1 - c)) +let t4: u16 = ((t3 & b) + (t2 | a)) +return ((a xor 1) + (b xor 38) + (c xor 75) + (t1 xor 112) + (t2 xor 9) + (t3 xor 7) + (t4 xor 5) + ((d as u16) xor 3)) +} +fun g() -> u16 { +gb = gb + 1 +return f(770, gb, 26064) +} +assert g() == 4430 via z80 +` + hm, err := nanz.Parse(source, "call_spilled_param.nanz") + if err != nil { + t.Fatal(err) + } + hm.AssertStats = &hir.AssertStats{} + steps, err := CompileHIRSteps(hm, DefaultOptions()) + if err != nil { + t.Fatal(err) + } + if *hm.AssertStats != (hir.AssertStats{Executed: 1, Passed: 1}) { + t.Fatalf("assert stats = %+v", hm.AssertStats) + } + gStart := strings.Index(steps.Assembly, "\ng:\n") + if gStart < 0 || !strings.Contains(steps.Assembly[gStart:], "LD (_spill_v_f_r") || !strings.Contains(steps.Assembly[gStart:], "JP v_f") { + t.Fatalf("expected dynamic tail call through the callee spill slot:\n%s", steps.Assembly) + } +}