package prog8tests.compiler import io.kotest.core.spec.style.FunSpec import io.kotest.engine.spec.tempdir import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldNotContain import prog8.code.target.C64Target import prog8.code.target.Qemu68kTarget import prog8.intermediate.IRFileReader import prog8.intermediate.IRStStaticVariable import prog8tests.helpers.ErrorReporterForTests import prog8tests.helpers.compileText // M68K stack-frame end-to-end tests: // stack-frame locals for frameable leaf subroutines on m68k targets. class TestStackFrameLocals : FunSpec({ fun compile(src: String, target: prog8.code.core.ICompilationTarget = Qemu68kTarget()): Pair, String> { val outputDir = tempdir().toPath() compileText(target, optimize = false, src, outputDir, writeAssembly = true, assemble = false) val asmFile = outputDir.toFile().listFiles()!!.single { it.name.endsWith(".asm") } val irFile = outputDir.toFile().listFiles()!!.singleOrNull { it.name.endsWith(".p8ir") } return asmFile.readText().lines().map { it.trim() } to (irFile?.readText() ?: "") } // the assembly lines of a single subroutine, using the subroutine boundary markers the // asm peephole optimizer relies on fun subAssembly(lines: List, subLabel: String): List { val start = lines.indexOfFirst { it.startsWith("; ---- Subroutine:") && it.contains(subLabel) } require(start >= 0) { "no assembly found for subroutine $subLabel" } val end = lines.indexOfFirst { it.startsWith("; End of subroutine:") && it.contains(subLabel) } return lines.subList(start, end) } test("frameable leaf subroutine gets an a5 frame") { val src = """ main { ubyte gv = 3 sub leaf() { ubyte a word b ubyte[4] arr a = gv b = a * 2 arr[3] = a } sub start() { leaf() } } """ val (lines, ir) = compile(src) // clean locals grouped first: arr at -4..-1, b at -6..-5, a at -7 -> frame size 8 lines.any { it == "link a5,#-8" } shouldBe true lines.count { it == "unlk a5" } shouldBe 1 val unlkIdx = lines.indexOf("unlk a5") lines[unlkIdx + 1] shouldBe "rts" // accesses lowered to a5 displacements lines.any { "-7(a5)" in it } shouldBe true lines.any { "-6(a5)" in it } shouldBe true lines.any { "-1(a5)" in it } shouldBe true // arr[3] // locals are no longer static storage lines.none { "p8v_a:" in it || "p8v_arr:" in it || "p8v_b:" in it } shouldBe true // IR carries the new syntax and frame size, and is reloadable ir.contains("frame:-7") shouldBe true ir.contains("FRAMESIZE=\"8\"") shouldBe true val reloaded = IRFileReader().read(ir) reloaded.allSubs().first { it.label.endsWith("p8s_leaf") }.frameSize shouldBe 8 } test("subroutine with parameters passes them on the stack") { val src = """ main { sub withp(ubyte a, word b, long c) -> long { return b + c + a } sub start() { g = lsb(withp(1, 2, 3)) } ubyte g } """ val (lines, ir) = compile(src) // incoming-only frame: no locals, but the parameters need a frame pointer lines.any { it == "link a5,#0" } shouldBe true lines.any { it == "unlk a5" } shouldBe true // word-packed slots: long at 8, word slot at 12, byte slot at 14 with the byte at 14 lines.any { it.contains("14(a5)") } shouldBe true // ubyte at slot 14 base byte lines.any { it.contains("12(a5)") } shouldBe true // word at slot 12 lines.any { it.contains("8(a5)") } shouldBe true // long at slot 8 // parameters are no longer static storage lines.none { "p8v_a:" in it || "p8v_b:" in it || "p8v_c:" in it } shouldBe true // the caller pushes every argument into its own slot (byte: single one-instruction // push carrying a "pushed as a word" note), then pops the whole area lines.count { it.startsWith("move.b ") && it.contains(",-(sp)") && it.contains("pushed as a word") } shouldBe 1 lines.count { it.startsWith("move.w ") && it.contains(",-(sp)") } shouldBe 1 lines.count { it == "subq.l #2,sp" } shouldBe 0 // long/pointer slots push with a single move.l ...,-(sp), same shape as PUSH ops elsewhere, // so only require at least the one argument push (exact isolation is covered in TestStackFrameEmission) lines.count { it.startsWith("move.l ") && it.contains(",-(sp)") } shouldBeGreaterThanOrEqual 1 lines.any { it == "addq.l #8,sp" } shouldBe true // IR carries the incoming area size and the frame-slot argument locations ir.contains("INCOMING=\"8\"") shouldBe true ir.contains("frame:14") shouldBe true val reloaded = IRFileReader().read(ir) val sub = reloaded.allSubs().first { it.label.endsWith("p8s_withp") } sub.frameSize shouldBe 0 sub.incomingSize shouldBe 8 } test("many parameters use one cleanup for the whole argument area") { val src = """ main { sub five(ubyte a, uword b, long c, ubyte d, uword e) -> long { return a + b + c + d + e } sub start() { g = lsb(five(1, 2, 3, 4, 5)) } ubyte g } """ val (lines, ir) = compile(src) // no byte slot reservations; byte pushes use a single move.b predecrement lines.count { it == "subq.l #2,sp" } shouldBe 0 lines.any { it == "lea 12(sp),sp" } shouldBe true // 12 bytes: the addq form maxes out at 8 // 5 parameters, word-packed slots, accumulated right-to-left: byte slot at 18, // word slot at 16, long slot at 12, byte slot at 10, word slot at 8 lines.any { it.contains("18(a5)") } shouldBe true // ubyte at slot 18 base byte lines.any { it.contains("16(a5)") } shouldBe true // uword at slot 16 lines.any { it.contains("12(a5)") } shouldBe true // long at slot 12 ir.contains("INCOMING=\"12\"") shouldBe true } test("subroutine that calls another is framed when it is not recursive") { val src = """ main { ubyte gv = 2 sub inner() { gv += 1 } sub caller() { ubyte a inner() a = gv } sub start() { caller() } } """ val (lines, ir) = compile(src) lines.any { it == "link a5,#-2" } shouldBe true lines.none { "p8v_a:" in it } shouldBe true ir.contains("FRAMESIZE=\"2\"") shouldBe true } test("recursive subroutine gets a frame with per-activation virtual registers") { // slice 3: a subroutine on a call-graph cycle moves its virtual registers into the frame, // so two live activations no longer share the flat program-static regfile val src = """ main { sub rec(ubyte n) -> long { if n < 2 return n return rec(n-1) + rec(n-2) } ubyte @shared g = 1 sub start() { g = lsb(rec(10)) } } """ val (lines, ir) = compile(src) val rec = subAssembly(lines, "p8b_main.p8s_rec") rec.any { it.startsWith("link ") } shouldBe true rec.any { it == "unlk a5" } shouldBe true // its parameter arrives in the incoming argument area, its temporary value in the frame ir.contains("VREGSLOTS=") shouldBe true rec.any { it.contains("(a5)") && it.startsWith("move") } shouldBe true // the recursive subroutine's own body never touches the static register file rec.any { it.contains("p8_regfile") } shouldBe false } test("mutually recursive subroutines both get frames") { val src = """ main { sub ping(ubyte n) -> long { if n == 0 return 0 return pong(n-1) } sub pong(ubyte n) -> long { if n == 0 return 0 return ping(n-1) } sub start() { g = lsb(ping(4)) } ubyte @shared g } """ val (lines, _) = compile(src) val ping = subAssembly(lines, "p8b_main.p8s_ping") val pong = subAssembly(lines, "p8b_main.p8s_pong") ping.any { it.startsWith("link ") } shouldBe true pong.any { it.startsWith("link ") } shouldBe true // both keep their state per-activation, so neither uses the static register file ping.any { it.contains("p8_regfile") } shouldBe false pong.any { it.contains("p8_regfile") } shouldBe false } test("indirectly called subroutine keeps the static path") { val src = """ main { sub t1() { g = 1 } sub t2() { g = 2 } sub start() { on 1 call ( t1, t2 ) } ubyte @shared g } """ val (lines, _) = compile(src) // t1/t2 are dispatch-table targets: they could be re-entered through the indirect call lines.any { it.startsWith("link ") } shouldBe false } test("defer-referenced local stays static, deferring sub with parameters keeps the static convention") { val src = """ main { sub leaf() { ubyte a = 1 defer a += 1 } sub parm(ubyte x) { defer x += 1 } sub start() { leaf() parm(1) } } """ val (lines, _) = compile(src) // the defer handler is emitted at block scope and reads the parent local, so that local // keeps static storage (§11 option 2) lines.any { "p8s_leaf.p8v_a:" in it } shouldBe true // a defer body that reads a parameter keeps the whole subroutine on the static convention lines.any { "p8s_parm.p8v_x:" in it } shouldBe true } test("address-taken local stays static, rest of the frame still works") { val src = """ main { sub leaf() { ubyte x ^^ubyte p = &x p^^ = 42 x += p^^ } sub start() { leaf() } } """ val (lines, _) = compile(src) // x is address-taken: keeps its static storage; p (a pointer scalar) gets framed lines.any { "p8v_x:" in it } shouldBe true lines.any { it == "link a5,#-4" } shouldBe true lines.any { "-4(a5)" in it } shouldBe true } test("@shared local keeps static storage even without an escaping address") { val src = """ main { sub leaf() { ubyte @shared sharedx ubyte normal sharedx = 1 normal = sharedx } sub start() { leaf() } } """ val (lines, ir) = compile(src) // sharedx is not address-taken and no inline assembly mentions it, yet the @shared // tag pins it to program-static storage; normal is still framed lines.any { "p8v_sharedx:" in it } shouldBe true lines.none { "p8v_normal:" in it } shouldBe true lines.any { it == "link a5,#-2" } shouldBe true lines.any { "-1(a5)" in it } shouldBe true ir.contains("shared=true") shouldBe true val reloaded = IRFileReader().read(ir) val sharedVar = reloaded.st.lookup("p8b_main.p8s_leaf.p8v_sharedx") as IRStStaticVariable sharedVar.shared shouldBe true reloaded.st.lookup("p8b_main.p8s_leaf.p8v_normal") shouldBe null } test("defer-referenced subroutine is not framed") { val src = """ main { sub leaf() { ubyte a = 1 defer a += 1 } sub start() { leaf() } } """ val (lines, _) = compile(src) // the defer body reads the parent local from a block-scope handler, so that local keeps // static storage; the subroutine itself may still get a frame for its per-activation // defer mask, but the defer-referenced variable must never move lines.any { "p8s_leaf.p8v_a:" in it } shouldBe true } test("oversized local array keeps static storage while other locals are framed") { val src = """ main { sub leaf() { ubyte small ubyte[17000] big small = 1 } sub start() { leaf() } } """ val (lines, _) = compile(src) lines.any { it == "link a5,#-2" } shouldBe true // only `small` is framed lines.any { "p8v_big" in it } shouldBe true // big array remains static } test("slice 3: tail call out of a framed subroutine becomes a call and a return") { // a framed subroutine cannot branch out to another subroutine: its `link` moved the stack // pointer, so the other subroutine's `rts` would return into the local area val src = """ main { sub target(ubyte n) -> ubyte { return n + 1 } sub caller(ubyte n) -> ubyte { ubyte local = n if local == 0 { return 0 } return target(local) } sub start() { g = caller(3) } ubyte @shared g } """ val (lines, ir) = compile(src) val callerAsm = subAssembly(lines, "p8b_main.p8s_caller") callerAsm.any { it.startsWith("link ") } shouldBe true // no outgoing branch to another subroutine is left inside the frame callerAsm.any { it.startsWith("bra") } shouldBe false callerAsm.any { it.startsWith("bsr") } shouldBe true ir.contains("call p8b_main.p8s_target") shouldBe true } test("conditional goto to a subroutine out of a framed subroutine becomes an inverted branch over a tail call") { // the not-taken path must skip the tail call and keep using the frame: the branch // is inverted over a call + return, and the fall-through code moves into a skip chunk val src = """ main { ubyte @shared result sub t1() { result += 10 } sub jumper(ubyte n) { ubyte local local = n if local != 0 { goto t1 } result += 1 } sub start() { result = 0 jumper(1) jumper(0) } } """ val (lines, ir) = compile(src) val jumperAsm = subAssembly(lines, "p8b_main.p8s_jumper") jumperAsm.any { it.startsWith("link ") } shouldBe true // no conditional branch directly targeting the other subroutine remains in the frame // (matching the entry label means the callee's rts would corrupt the machine stack) jumperAsm.filter { Regex("b(?!sr)\\w+\\s+p8b_main\\.p8s_t1$").containsMatchIn(it) } shouldBe emptyList() // inverted branch over the tail call: bstne t1 became bsteq , followed by bsr + rts val tailSkipLabel = jumperAsm.first { Regex("b\\w+\\s+\\S*tailskip").containsMatchIn(it) } tailSkipLabel.isEmpty() shouldBe false jumperAsm.any { it == "bsr p8b_main.p8s_t1" } shouldBe true ir.contains("call p8b_main.p8s_t1") shouldBe true // the not-taken path resumes after the tail call, still inside the frame val skipIdx = jumperAsm.indexOfFirst { Regex("\\S*tailskip\\d+:").matches(it) } (skipIdx > jumperAsm.indexOf("bsr p8b_main.p8s_t1")) shouldBe true } test("conditional goto inside a repeat loop gets the skip chunk inside the loop body") { val src = """ main { ubyte @shared result sub t1() { result += 10 } sub jumper(ubyte n) { ubyte local local = n repeat 2 { if local != 0 { goto t1 } } result += 1 } sub start() { result = 0 jumper(1) jumper(0) } } """ val (lines, ir) = compile(src) val jumperAsm = subAssembly(lines, "p8b_main.p8s_jumper") jumperAsm.any { it.startsWith("link ") } shouldBe true jumperAsm.filter { Regex("b(?!sr)\\w+\\s+p8b_main\\.p8s_t1$").containsMatchIn(it) } shouldBe emptyList() jumperAsm.any { it.startsWith("dbra d7,") } shouldBe true jumperAsm.any { it == "bsr p8b_main.p8s_t1" } shouldBe true ir.contains("call p8b_main.p8s_t1") shouldBe true } test("register-form conditional goto out of a framed subroutine inverts with swapped operands") { val src = """ main { ubyte @shared result ubyte @shared limit sub t1() { result += 10 } sub jumper(ubyte n) { ubyte local local = n if local > limit { goto t1 } result += 1 } sub start() { result = 0 limit = 5 jumper(9) ; taken: result=10 jumper(2) ; not taken: result=11 } } """ val (lines, ir) = compile(src) val jumperAsm = subAssembly(lines, "p8b_main.p8s_jumper") jumperAsm.any { it.startsWith("link ") } shouldBe true // bgtr t1 became bger(swap) -> the inverted condition branches over the tail call jumperAsm.filter { Regex("b(?!sr)\\w+\\s+p8b_main\\.p8s_t1$").containsMatchIn(it) } shouldBe emptyList() jumperAsm.count { it.startsWith("bhs") && Regex("\\S*tailskip").containsMatchIn(it) } shouldBe 1 jumperAsm.any { it == "bsr p8b_main.p8s_t1" } shouldBe true ir.contains("call p8b_main.p8s_t1") shouldBe true } test("copy-in parameter that the body writes keeps its static cell as the live value") { // §6.1: the entry-time copy-in turns the static cell into the parameter's live storage; // the body's writes must land there so external references observe the current value val src = """ main { ubyte @shared out sub parm(ubyte x) { x = 99 defer out = x } sub start() { parm(1) } } """ val (lines, _) = compile(src) val parm = subAssembly(lines, "p8b_main.p8s_parm") parm.any { it == "link a5,#0" } shouldBe true // the entry-time copy-in is still performed parm.any { it.startsWith("move.b 8(a5),") } shouldBe true // the body's write goes to the static cell, not to the incoming frame slot parm.any { it.contains("move.b") && it.contains("p8b_main.p8s_parm.p8v_x") && it.contains("#99") } shouldBe true // the defer handler reads the static cell, so it observes the body's written value val defers = subAssembly(lines, "p8s_prog8_invoke_defers") defers.any { it.contains("move.b") && it.contains("p8b_main.p8s_parm.p8v_x") } shouldBe true // the static cell is kept in BSS lines.any { "p8b_main.p8s_parm.p8v_x:" in it } shouldBe true } test("slice 3: recursive subroutine with an address-taken local is rejected") { val src = """ main { sub rec(ubyte n) -> ubyte { ubyte buf = 1 pointer p = &buf if n == 0 { return 1 } if p != 0 { return rec(n-1) } return rec(n-1) } sub start() { g = rec(3) } ubyte @shared g } """ val errors = ErrorReporterForTests(keepMessagesAfterReporting = true) compileText(Qemu68kTarget(), optimize = false, src, tempdir().toPath(), writeAssembly = true, assemble = false, errors = errors) shouldBe null errors.errors.size shouldBe 1 errors.errors[0] shouldContain "shares data between activations" errors.errors[0] shouldContain "address is taken" } test("slice 3: recursive subroutine that keeps static storage is rejected, other targets are not") { val src = """ main { sub rec(ubyte n) -> ubyte { ubyte[4] buf = [1, 2, 3, 4] buf[0] = n if n == 0 { return buf[0] } return rec(n-1) + buf[0] } sub start() { g = rec(3) } ubyte @shared g } """ val errors = ErrorReporterForTests(keepMessagesAfterReporting = true) compileText(Qemu68kTarget(), optimize = false, src, tempdir().toPath(), writeAssembly = true, assemble = false, errors = errors) shouldBe null errors.errors.size shouldBe 1 errors.errors[0] shouldContain "shares data between activations" errors.errors[0] shouldContain "static initializer" // the same program compiles unchanged for a target without stack frames val otherErrors = ErrorReporterForTests(keepMessagesAfterReporting = true) compileText(C64Target(), optimize = false, src, tempdir().toPath(), writeAssembly = false, assemble = false, errors = otherErrors) shouldNotBe null otherErrors.errors.size shouldBe 0 } test("recursion warning is only emitted for targets without stack frames") { val src = """ main { sub rec(ubyte n) -> ubyte { if n == 0 { return 0 } return rec(n-1) + 1 } sub start() { g = rec(3) } ubyte @shared g } """ val m68kErrors = ErrorReporterForTests(keepMessagesAfterReporting = true) compileText(Qemu68kTarget(), optimize = false, src, tempdir().toPath(), writeAssembly = false, assemble = false, errors = m68kErrors) shouldNotBe null m68kErrors.errors.size shouldBe 0 m68kErrors.warnings.any { it.contains("recursive subroutine") } shouldBe false val otherErrors = ErrorReporterForTests(keepMessagesAfterReporting = true) compileText(C64Target(), optimize = false, src, tempdir().toPath(), writeAssembly = false, assemble = false, errors = otherErrors) shouldNotBe null otherErrors.errors.size shouldBe 0 otherErrors.warnings.any { it.contains("recursive subroutine") } shouldBe true } test("slice 3: defer in a recursive subroutine is rejected on m68k only") { val src = """ main { sub rec(ubyte n) -> ubyte { ubyte hits = 0 defer hits = hits + 1 if n == 0 { return hits } return rec(n-1) } sub start() { g = rec(3) } ubyte @shared g } """ val errors = ErrorReporterForTests(keepMessagesAfterReporting = true) compileText(Qemu68kTarget(), optimize = false, src, tempdir().toPath(), writeAssembly = false, assemble = false, errors = errors) shouldBe null errors.errors.size shouldBe 1 errors.errors[0] shouldContain "defer in a recursive subroutine" val otherErrors = ErrorReporterForTests(keepMessagesAfterReporting = true) compileText(C64Target(), optimize = false, src, tempdir().toPath(), writeAssembly = false, assemble = false, errors = otherErrors) shouldNotBe null otherErrors.errors.size shouldBe 0 } test("slice 3: recursive subroutine using only its own state is accepted") { val src = """ main { sub rec(ubyte n) -> ubyte { ubyte local = n + 1 if n == 0 { return local } return rec(n-1) + local } sub start() { g = rec(3) } ubyte @shared g } """ val (lines, ir) = compile(src) ir.contains("VREGSLOTS=") shouldBe true val rec = subAssembly(lines, "p8b_main.p8s_rec") rec.any { it.startsWith("link ") } shouldBe true rec.any { it.contains("p8_regfile") } shouldBe false } test("other targets are unaffected") { val src = """ main { sub leaf() { ubyte a a = 5 } sub start() { leaf() } } """ val (lines, _) = compile(src, C64Target()) lines.any { "link " in it } shouldBe false lines.any { "(a5)" in it } shouldBe false } test("slice 5: defer-referenced parameter is framed with an entry-time copy-in") { // §6.1: the positive incoming slot is the parameter storage; a parameter referenced // from a defer handler keeps its static cell and gets an entry-time copy-in val src = """ main { sub parm(ubyte x) { defer x += 1 } sub start() { parm(1) } } """ val (lines, _) = compile(src) val parm = subAssembly(lines, "p8b_main.p8s_parm") // the subroutine is framed (only the incoming slot, no locals) parm.any { it == "link a5,#0" } shouldBe true parm.any { it == "unlk a5" } shouldBe true // entry-time copy-in: load the incoming slot, store into the static cell, before any clearing parm.any { it.startsWith("move.b 8(a5),") } shouldBe true parm.any { it.contains("p8b_main.p8s_parm.p8v_x") } shouldBe true // the static cell is kept so the block-scope defer handler can still see it lines.any { "p8b_main.p8s_parm.p8v_x:" in it } shouldBe true } test("slice 5: inline-asm-referenced parameter is framed with an entry-time copy-in") { val src = """ main { sub rec(ubyte x) -> ubyte { return x + 2 } sub start() { g = rec(3) %asm {{ ; this assembly reads the parameter's static cell move.b p8b_main.p8s_rec.p8v_x,d0 nop }} } ubyte @shared g } """ val (lines, _) = compile(src) val rec = subAssembly(lines, "p8b_main.p8s_rec") rec.any { it == "link a5,#0" } shouldBe true rec.any { it.startsWith("move.b 8(a5),") } shouldBe true rec.any { it.contains("p8b_main.p8s_rec.p8v_x") } shouldBe true // the static cell is kept for the assembly reference; the body itself also uses the // static cell (the entry-time copy-in made it the current value) lines.any { "p8b_main.p8s_rec.p8v_x:" in it } shouldBe true rec.any { it.startsWith("move.b p8b_main.p8s_rec.p8v_x,") } shouldBe true } test("inline assembly does not prevent framing: asm subroutine is framed by default") { val src = """ main { sub leaf(ubyte a) -> ubyte { %asm {{ nop }} return a + 1 } sub start() { g = leaf(3) } ubyte @shared g } """ val (lines, _) = compile(src) val leaf = subAssembly(lines, "p8b_main.p8s_leaf") leaf.any { it.startsWith("link") } shouldBe true leaf.any { it.contains("(a5)") } shouldBe true } test("inline assembly subroutine with %option noframe keeps the static convention") { val src = """ main { sub leaf(ubyte a) -> ubyte { %option noframe %asm {{ nop }} return a + 1 } sub start() { g = leaf(3) } ubyte @shared g } """ val (lines, _) = compile(src) val leaf = subAssembly(lines, "p8b_main.p8s_leaf") leaf.any { it.startsWith("link") } shouldBe false leaf.any { it.contains("p8b_main.p8s_leaf.p8v_a") } shouldBe true } test("slice 5: static-initialized local is framed and initialized at entry") { val src = """ main { sub leaf() { ubyte x = 5 x += 1 } sub start() { leaf() } } """ val (lines, _) = compile(src) val leaf = subAssembly(lines, "p8b_main.p8s_leaf") leaf.any { it.startsWith("link ") } shouldBe true leaf.any { it == "move.b #5,-1(a5)" } shouldBe true // the initialized local no longer lives in static storage lines.none { "p8v_x:" in it } shouldBe true } test("slice 5: float static-initialized local is framed") { val src = """ %option enable_floats main { sub leaf() { float f = 1.5 f += 1.0 } sub start() { leaf() } } """ val (lines, _) = compile(src) val leaf = subAssembly(lines, "p8b_main.p8s_leaf") leaf.any { it.startsWith("link ") } shouldBe true leaf.any { it.contains("fmove.s fp0,-4(a5)") } shouldBe true lines.none { "p8v_f:" in it } shouldBe true } test("slice 5: float array local is framed and zero-filled in the prologue") { val src = """ %option enable_floats main { sub leaf() { float[2] arr arr[0] = 1.5 } sub start() { leaf() } } """ val (lines, _) = compile(src) val leaf = subAssembly(lines, "p8b_main.p8s_leaf") leaf.any { it == "link a5,#-8" } shouldBe true // every float element is zero-filled in the prologue (STOREZM float) leaf.any { it.contains("fmove.s fp0,-8(a5)") } shouldBe true leaf.any { it.contains("fmove.s fp0,-4(a5)") } shouldBe true lines.none { "p8v_arr:" in it } shouldBe true } test("slice 5: recursive subroutine with a defer-referenced parameter is rejected") { val src = """ main { sub rec(ubyte n) -> ubyte { defer n = n + 1 if n == 0 { return 0 } return rec(n-1) } sub start() { g = rec(3) } ubyte @shared g } """ val errors = ErrorReporterForTests(keepMessagesAfterReporting = true) compileText(Qemu68kTarget(), optimize = false, src, tempdir().toPath(), writeAssembly = true, assemble = false, errors = errors) shouldBe null // the defer rule rejects this at the compiler level; the static cell would be // shared between live activations of the recursive subroutine errors.errors.any { it.contains("recursive") } shouldBe true } test("slice 5: recursive subroutine with an inline-asm-referenced parameter is rejected") { val src = """ main { sub rec(ubyte n) -> ubyte { return rec(1) } sub start() { g = rec(3) %asm {{ move.b p8b_main.p8s_rec.p8v_n,d0 nop }} } ubyte @shared g } """ val errors = ErrorReporterForTests(keepMessagesAfterReporting = true) compileText(Qemu68kTarget(), optimize = false, src, tempdir().toPath(), writeAssembly = true, assemble = false, errors = errors) shouldBe null errors.errors.any { it.contains("shares data between activations") } shouldBe true errors.errors.any { it.contains("shared with other code") } shouldBe true } test("slice 5: recursive subroutine with a static-initialized local now compiles") { val src = """ main { sub rec(ubyte n) -> ubyte { ubyte local = 5 if n == 0 { return local + n } return rec(n-1) + local } sub start() { g = rec(3) } ubyte @shared g } """ val (lines, ir) = compile(src) // the static-initialized local is now frameable, so nothing keeps static storage ir.contains("VREGSLOTS=") shouldBe true val rec = subAssembly(lines, "p8b_main.p8s_rec") rec.any { it.startsWith("link ") } shouldBe true rec.any { it.contains("p8_regfile") } shouldBe false } test("slice 5: vreg slots of a recursive subroutine are reused when lifetimes don't overlap") { // two recursive call results are each stored right after their call, so their lifetimes // don't overlap and they can share one frame slot; the frame shrinks from 16 to 10 bytes val src = """ main { sub rec(ubyte n) -> uword { if n == 0 return 1 uword a = rec(n-1) uword b = rec(n-2) return a + b } ubyte @shared g = 1 sub start() { g = lsb(rec(10)) } } """ val (_, ir) = compile(src) val subLine = ir.lineSequence().first { it.contains("SUB NAME=\"p8b_main.p8s_rec\"") } subLine.contains("FRAMESIZE=\"10\"") shouldBe true val slots = subLine.substringAfter("VREGSLOTS=\"").substringBefore('"').split(',').associate { it.substringBefore(':').toInt() to it.substringAfter(':').toInt() } // the two call results inside rec (the call results whose registers appear in the slot // map; the call from start uses a register outside it) share one slot val callResults = Regex("call p8b_main\\.p8s_rec\\([^)]*\\):r(\\d+)\\.w").findAll(ir) .map { it.groupValues[1].toInt() } .filter { it in slots } .toList() callResults.size shouldBe 2 slots.getValue(callResults[0]) shouldBe slots.getValue(callResults[1]) // reuse happened: fewer distinct slots than virtual registers slots.values.toSet().size shouldBe 3 slots.size shouldBe 7 // the layout is deterministic across compilations val (_, ir2) = compile(src) val subLine2 = ir2.lineSequence().first { it.contains("SUB NAME=\"p8b_main.p8s_rec\"") } subLine2.substringAfter("FRAMESIZE") shouldBe subLine.substringAfter("FRAMESIZE") } test("slice 5: a recursive subroutine with a loop keeps one slot per virtual register") { // the loop's back edge can keep values live across it, so plain last-use intervals // would be unsound and the safe one-slot-per-register layout is kept val src = """ main { sub rec(ubyte n) -> uword { uword total = 0 for i in 0 to 5 { total += 1 } if n == 0 return total return total + rec(n-1) } ubyte @shared g = 1 sub start() { g = lsb(rec(10)) } } """ val (_, ir) = compile(src) val subLine = ir.lineSequence().first { it.contains("SUB NAME=\"p8b_main.p8s_rec\"") } subLine.contains("FRAMESIZE=\"12\"") shouldBe true subLine.contains("VREGSLOTS=\"1:-3,2:-6,3:-7,4:-10,5:-12\"") shouldBe true val slots = subLine.substringAfter("VREGSLOTS=\"").substringBefore('"').split(',').associate { it.substringBefore(':').toInt() to it.substringAfter(':').toInt() } // every register has its own dedicated slot: all offsets are distinct slots.values.toSet().size shouldBe slots.size } test("slice 5: simultaneously-live virtual registers get distinct frame slots") { // both recursive call results are live at the addition, so they cannot share a slot val src = """ main { sub rec(ubyte n) -> uword { if n < 2 return n return rec(n-1) + rec(n-2) } ubyte @shared g = 1 sub start() { g = lsb(rec(10)) } } """ val (_, ir) = compile(src) val subLine = ir.lineSequence().first { it.contains("SUB NAME=\"p8b_main.p8s_rec\"") } val slots = subLine.substringAfter("VREGSLOTS=\"").substringBefore('"').split(',').associate { it.substringBefore(':').toInt() to it.substringAfter(':').toInt() } val callResults = Regex("call p8b_main\\.p8s_rec\\([^)]*\\):r(\\d+)\\.w").findAll(ir) .map { it.groupValues[1].toInt() } .filter { it in slots } .toList() callResults.size shouldBe 2 slots.getValue(callResults[0]) shouldNotBe slots.getValue(callResults[1]) } test("slice 5: non-recursive subroutines keep the static register file and no vreg slots") { // only re-entrant subroutines relocate their virtual registers into the frame val src = """ main { ubyte gv = 2 sub inner() { gv += 1 } sub caller() { ubyte a inner() a = gv } sub start() { caller() } } """ val (lines, ir) = compile(src) ir.contains("VREGSLOTS") shouldBe false val caller = subAssembly(lines, "p8b_main.p8s_caller") caller.any { it == "link a5,#-2" } shouldBe true caller.any { it.contains("p8_regfile") } shouldBe true } test("slice 5: compiler-generated temporaries inside a subroutine are framed already") { // the desugarer of `on ... goto` creates its own auto_heap_value variables; plain // scalar ones are ordinary subroutine locals and are framed without any extra work val src = """ main { sub pick(ubyte n) -> ubyte { on n goto(smaller, bigger) return 0 smaller: return 1 bigger: return 2 } sub start() { g = pick(1) } ubyte @shared g } """ val (lines, ir) = compile(src) val pick = subAssembly(lines, "p8b_main.p8s_pick") pick.any { it.startsWith("link ") || it.startsWith("bra") } shouldBe true // the jump table auto-variable keeps static storage on purpose: it is read-only // constant data, and framing it would add an initialization store on every call ir.contains("auto_heap_value") shouldBe true } test("zero-clear coalescing: byte[17] prologue clears with five stores") { val src = """ main { sub check() { ubyte[17] a a[0] = a[16] } sub start() { check() } } """ val (lines, _) = compile(src) val check = subAssembly(lines, "p8b_main.p8s_check") // slot -17, run [-17, 0): one odd head byte, then four longwords check.any { it == "link a5,#-18" } shouldBe true check.any { it == "clr.b -17(a5)" } shouldBe true check.count { it.startsWith("clr.l") } shouldBe 4 check.any { it == "clr.l -16(a5)" } shouldBe true check.any { it == "clr.l -4(a5)" } shouldBe true check.count { it.startsWith("clr.") } shouldBe 5 } test("zero-clear coalescing: uword[8] prologue clears with four longword stores") { val src = """ main { sub check() { uword[8] b b[0] = b[7] } sub start() { check() } } """ val (lines, _) = compile(src) val check = subAssembly(lines, "p8b_main.p8s_check") // slot -16, run [-16, 0): four longwords, no word stores left check.any { it == "link a5,#-16" } shouldBe true check.count { it.startsWith("clr.l") } shouldBe 4 check.none { it.startsWith("clr.w") } shouldBe true check.none { it.startsWith("clr.b") } shouldBe true } test("zero-clear coalescing: adjacent byte locals coalesce across variables") { val src = """ main { ubyte @shared g sub check() { ubyte a ubyte b a = 1 b = 2 g = a + b } sub start() { check() } } """ val (lines, _) = compile(src) val check = subAssembly(lines, "p8b_main.p8s_check") // a at -1, b at -2: merged run [-2, 0) clears with a single word store check.count { it == "clr.w -2(a5)" } shouldBe 1 check.count { it.startsWith("clr.") } shouldBe 1 } test("zero-clear coalescing: clean locals are grouped to avoid alignment gaps") { val src = """ main { sub check() { ubyte a uword w a = 1 w = 2 } sub start() { check() } } """ val (lines, _) = compile(src) val check = subAssembly(lines, "p8b_main.p8s_check") // w placed before a so the clean region is contiguous: a at -3, w at -2..-1 check.count { it == "clr.b -3(a5)" } shouldBe 1 check.count { it == "clr.w -2(a5)" } shouldBe 1 check.none { it.startsWith("clr.l") } shouldBe true } test("zero-clear coalescing: @dirty locals are not cleared") { val src = """ main { ubyte @shared g sub check() { ubyte @dirty d ubyte c c = g d = g g = c + d } sub start() { check() } } """ val (lines, _) = compile(src) val check = subAssembly(lines, "p8b_main.p8s_check") check.count { it == "clr.b -1(a5)" } shouldBe 1 check.none { it.startsWith("clr.") && "-2(a5)" in it } shouldBe true } test("zero-clear coalescing: initialized locals keep their init store") { val src = """ main { ubyte @shared g sub check() { ubyte a ubyte x = 42 a = g x += 1 g = a + x } sub start() { check() } } """ val (lines, _) = compile(src) val check = subAssembly(lines, "p8b_main.p8s_check") // x at -2 keeps its init store; a's [-1, 0) run merges with x's byte (zeroed // first, then set by the init store: declaration initializers lower to a body // store, so the slot counts as clean) check.any { it == "move.b #42,-2(a5)" } shouldBe true check.count { it == "clr.w -2(a5)" } shouldBe 1 check.count { it.startsWith("clr.") } shouldBe 1 check.none { it.startsWith("clr.l") } shouldBe true } test("zero-clear coalescing: float arrays keep per-element FPU clears") { val src = """ %option enable_floats main { sub check() { float[3] f f[0] = 1.5 } sub start() { check() } } """ val (lines, _) = compile(src) val check = subAssembly(lines, "p8b_main.p8s_check") // three prologue clears plus the body store to f[0], all FPU, no integer clears check.any { it == "fmove.s fp0,-12(a5)" } shouldBe true check.any { it == "fmove.s fp0,-8(a5)" } shouldBe true check.any { it == "fmove.s fp0,-4(a5)" } shouldBe true check.none { it.startsWith("clr.") } shouldBe true } test("zero-clear coalescing: dirty neighbour splits a run") { val src = """ main { sub check() { ubyte[4] c ubyte[4] @dirty d c[0] = 1 d[0] = 2 } sub start() { check() } } """ val (lines, _) = compile(src) val check = subAssembly(lines, "p8b_main.p8s_check") check.count { it == "clr.l -4(a5)" } shouldBe 1 check.count { it.startsWith("clr.") } shouldBe 1 } test("zero-clear loop: ubyte[128] prologue clears with a dbra loop") { val src = """ main { sub check() { ubyte[128] big big[0] = big[127] } sub start() { check() } } """ val (lines, _) = compile(src) val check = subAssembly(lines, "p8b_main.p8s_check") // 32 longwords hit the loop threshold: no straight clears remain check.any { it == "lea -128(a5),a0" } shouldBe true check.any { it == "moveq #31,d1" } shouldBe true check.any { it == "move.l d0,(a0)+" } shouldBe true check.count { it.startsWith("dbra d1,zeroloop_") } shouldBe 1 check.none { it.startsWith("clr.") } shouldBe true } test("stack argument pushes carry the parameter name as a comment") { val src = """ main { sub withp(ubyte first, word second, long third) -> long { return second + third + first } sub start() { g = lsb(withp(1, 2, 3)) } ubyte g } """ val (lines, _) = compile(src) val start = subAssembly(lines, "p8b_main.p8s_start") // each push store is annotated with the short parameter name it fills; // the byte slot-reserving subq lines stay absent (byte pushes are single moves) start.any { it.startsWith("move.b ") && it.contains(",-(sp)") && it.contains("; first") && it.contains("pushed as a word") } shouldBe true start.any { it.startsWith("move.w ") && it.contains(",-(sp)") && it.endsWith("; second") } shouldBe true start.any { it.startsWith("move.l ") && it.contains(",-(sp)") && it.endsWith("; third") } shouldBe true start.count { it == "subq.l #2,sp" } shouldBe 0 } test("re-entrant subroutines that share data are rejected in plain language") { val src = """ main { sub start() { spawn(&task1) } sub spawn(pointer task) { ubyte x = 1 x++ dispatch(task) } sub dispatch(pointer task) { call(task) } sub task1() { ubyte counter = 0 counter++ spawn(&task1) } } """ val errors = ErrorReporterForTests(keepMessagesAfterReporting = true) compileText(Qemu68kTarget(), optimize = false, src, tempdir().toPath(), writeAssembly = true, assemble = false, errors = errors) shouldBe null errors.errors.size shouldBe 3 val all = errors.errors.joinToString("\n") all shouldContain "recursive (or mutually recursive)" all shouldContain "shares data between activations" all shouldContain "Make the data private" all shouldContain "%option noframe" all shouldContain "variable 'x'" all shouldContain "variable 'counter'" all shouldContain "temporary value" } test("%option noframe on a subroutine exempts it from the reentrancy static-state error") { val src = """ main { sub start() { spawn(&task1) } sub spawn(pointer task) { %option noframe dispatch(task) } sub dispatch(pointer task) { %option noframe call(task) } sub task1() { %option noframe ubyte counter = 0 counter++ spawn(&task1) } } """ val errors = ErrorReporterForTests(keepMessagesAfterReporting = true) val outputDir = tempdir().toPath() compileText(Qemu68kTarget(), optimize = false, src, outputDir, writeAssembly = true, assemble = false, errors = errors) shouldNotBe null errors.errors shouldBe emptyList() // spawn takes a parameter so it would normally get a frame; the noframe mark forces static convention val (lines, _) = compile(src, Qemu68kTarget()) for (sub in listOf("p8b_main.p8s_spawn", "p8b_main.p8s_dispatch", "p8b_main.p8s_task1")) { subAssembly(lines, sub).none { it.startsWith("link") } shouldBe true } } test("coroutine tasks with frame-private state need no mark and get stack frames") { val src = """ %import coroutines main { sub start() { void coroutines.add(task1, 0) void coroutines.add(task2, 0) coroutines.run(0) } sub task1() { ubyte count = 0 repeat 3 { count += helper(count) void coroutines.yield() } } sub helper(ubyte v) -> ubyte { return v + 1 } sub task2() { ubyte n = 0 repeat 2 { n += 10 void coroutines.yield() } } } """ val errors = ErrorReporterForTests(keepMessagesAfterReporting = true) val outputDir = tempdir().toPath() compileText(Qemu68kTarget(), optimize = false, src, outputDir, writeAssembly = true, assemble = false, errors = errors) shouldNotBe null errors.errors shouldBe emptyList() // each task runs on its own private stack, so locals live in frames: no marks needed val (lines, _) = compile(src, Qemu68kTarget()) for (sub in listOf("p8b_main.p8s_task1", "p8b_main.p8s_task2", "p8b_main.p8s_helper")) { subAssembly(lines, sub).any { it.startsWith("link") } shouldBe true } } test("%option with a non-noframe argument is rejected in a subroutine") { val src = """ main { sub start() { %option force_output } } """ val errors = ErrorReporterForTests(keepMessagesAfterReporting = true) compileText(Qemu68kTarget(), optimize = false, src, tempdir().toPath(), writeAssembly = false, assemble = false, errors = errors) shouldBe null errors.errors.size shouldBe 1 errors.errors[0] shouldContain "not valid for subroutines" } })