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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions minzc/pkg/mir2/call_spilled_param_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
21 changes: 14 additions & 7 deletions minzc/pkg/mir2/z80codegen_call.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions minzc/pkg/pipeline/call_spilled_param_e2e_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading