package prog8tests.codegen.m68k import io.kotest.core.spec.style.FunSpec import io.kotest.engine.spec.tempdir import io.kotest.matchers.shouldBe import prog8.code.core.* import prog8.code.target.Qemu68kTarget import prog8.codegen.m68k.AsmGen import prog8.intermediate.* import prog8tests.helpers.DummyStringEncoder import prog8tests.helpers.ErrorReporterForTests import kotlin.io.path.exists import kotlin.io.path.readText // Vertical Slice Prototype: m68k backend lowering of FrameSlot memory references // and the link/unlk frame prologue/epilogue. class TestStackFrameEmission : FunSpec({ val tempRoot = tempdir().toPath() fun generateAsmWithIncoming( frameSize: Int, instructions: List, incomingSize: Int = 0, subLabel: String = "test.start", frameVregSlots: Map = emptyMap(), errors: ErrorReporterForTests? = null, params: List? = null ): List { val options = CompilationOptions.builder(Qemu68kTarget()) .output(OutputType.RAW) .zeropage(ZeropageType.DONTUSE) .floats(false) .compilerVersion("test") .memtopAddress(0xffffu) .optimize(true) .build() val program = IRProgram("test", IRSymbolTable(), options, DummyStringEncoder) program.options.outputDir = tempRoot val chunk = IRCodeChunk(subLabel, null) chunk.instructions.addAll(instructions) // word-packed slots: give callers a way to declare each parameter's real type, // since the default derivation from the incoming area size assumes uniform slots val paramList = params ?: if (incomingSize > 0) List(incomingSize / 2) { IRSubroutine.IRParam("p8v_p$it", DataType.UBYTE) } else emptyList() val sub = IRSubroutine(subLabel, paramList, emptyList(), Position.DUMMY, frameSize, incomingSize, frameVregSlots) sub.chunks.add(chunk) val block = IRBlock("test", false, IRBlock.Options(), Position.DUMMY) block.children.add(sub) program.blocks.add(block) tempRoot.toFile().deleteRecursively() tempRoot.toFile().mkdirs() AsmGen(program, Qemu68kTarget(), errors).generate() val asmFile = tempRoot.resolve("test.asm") check(asmFile.exists()) { "Assembly file not written: $asmFile" } return asmFile.readText().lines().map { it.trim() } } fun generateAsm(frameSize: Int, instructions: List): List = generateAsmWithIncoming(frameSize, instructions, 0, "test.start") // builds a subroutine with a chunk per extra chunk label, for jump-target scope tests fun sub(label: String, frameSize: Int, instructions: List, extraChunks: List = emptyList()): IRSubroutine { val first = IRCodeChunk(label, null) first.instructions.addAll(instructions) val sub = IRSubroutine(label, emptyList(), emptyList(), Position.DUMMY, frameSize, 0, emptyMap()) sub.chunks.add(first) sub.chunks.addAll(extraChunks) return sub } fun generateAsmForSubs(vararg subs: IRSubroutine): List { val options = CompilationOptions.builder(Qemu68kTarget()) .output(OutputType.RAW) .zeropage(ZeropageType.DONTUSE) .floats(false) .compilerVersion("test") .memtopAddress(0xffffu) .optimize(true) .build() val program = IRProgram("test", IRSymbolTable(), options, DummyStringEncoder) program.options.outputDir = tempRoot val block = IRBlock("test", false, IRBlock.Options(), Position.DUMMY) block.children.addAll(subs) program.blocks.add(block) program.linkChunks() tempRoot.toFile().deleteRecursively() tempRoot.toFile().mkdirs() AsmGen(program, Qemu68kTarget(), null).generate() return tempRoot.resolve("test.asm").readText().lines().map { it.trim() } } test("frameless subroutine emits no frame setup") { val lines = generateAsm(0, listOf( IRInstructions.loadMemory(Opcode.LOADM, IRDataType.BYTE, 1, IRMemory.direct("some.var")), IRInstructions.simple(Opcode.RETURN) )) lines.any { it.startsWith("link") } shouldBe false lines.any { it.startsWith("unlk") } shouldBe false } test("framed subroutine emits link prologue and unlk epilogue") { val lines = generateAsm(16, listOf( IRInstructions.storeZero(Opcode.STOREZM, IRDataType.BYTE, IRMemory.frameDirect(-1)), IRInstructions.simple(Opcode.RETURN) )) lines.any { it == "link a5,#-16" } shouldBe true lines.count { it == "unlk a5" } shouldBe 1 lines.any { "-1(a5)" in it } shouldBe true // epilogue must directly precede the subroutine's return lines[lines.indexOf("unlk a5") + 1] shouldBe "rts" } test("frame slot direct references lower to displacements relative to a5") { val lines = generateAsm(16, listOf( IRInstructions.storeImmediate(IRDataType.WORD, 5, IRMemory.frameDirect(-4)), IRInstructions.loadMemory(Opcode.LOADM, IRDataType.BYTE, 1, IRMemory.frameDirect(-1)), IRInstructions.memoryOpImmediate(Opcode.ADDIM, IRDataType.BYTE, IRMemory.frameDirect(-1), 3), IRInstructions.simple(Opcode.RETURN) )) lines.any { it.contains("-4(a5)") && it.startsWith("move.w") } shouldBe true lines.any { it.contains("-1(a5)") } shouldBe true lines.any { it.startsWith("addq.b") || it.startsWith("add.b") } shouldBe true } test("indexed frame slot access uses lea of the slot base plus index") { val lines = generateAsm(16, listOf( IRInstructions.loadMemory(Opcode.LOADX, IRDataType.BYTE, 1, IRMemory.frameIndexed(-8, 3, IRDataType.WORD, scale = 1)), IRInstructions.storeZero(Opcode.STOREZX, IRDataType.BYTE, IRMemory.frameIndexed(-8, 3, IRDataType.WORD, scale = 1)), IRInstructions.simple(Opcode.RETURN) )) lines.any { it.startsWith("lea") && "-8(a5)" in it } shouldBe true lines.count { it.contains("(a0,d0.w)") } shouldBe 2 } test("frame displacement combines slot offset and reference displacement") { val lines = generateAsm(16, listOf( IRInstructions.storeZero(Opcode.STOREZM, IRDataType.BYTE, IRMemory.frameDirect(-8, 4)), IRInstructions.simple(Opcode.RETURN) )) // -8 slot + 4 displacement = -4(a5), a single combined displacement lines.any { it.contains("-4(a5)") } shouldBe true lines.any { it.contains("-8(a5)+4") } shouldBe false } test("incoming-only frame still emits link and unlk") { val lines = generateAsmWithIncoming(0, listOf(IRInstructions.simple(Opcode.RETURN)), incomingSize = 12) lines.any { it == "link a5,#0" } shouldBe true lines.any { it == "unlk a5" } shouldBe true } test("stack arguments are pushed word-aligned into their slots and popped by the caller") { val calls = listOf( IRInstructions.call(CallSite( target = CallTarget.Direct(CodeReference.Label("test.sub")), arguments = listOf( Calls.argument(1, IRDataType.BYTE, CallLocation.FrameSlot(16)), Calls.argument(2, IRDataType.WORD, CallLocation.FrameSlot(12)), Calls.argument(3, IRDataType.LONG, CallLocation.FrameSlot(8)) ) )), IRInstructions.simple(Opcode.RETURN) ) val lines = generateAsmWithIncoming(0, calls, incomingSize = 12, subLabel = "test.caller") // word-packed pushes: byte arguments use the single move.b predecrement instruction // (the byte lands at the slot's base byte), word and long push as themselves 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.startsWith("move.l ") && it.contains(",-(sp)") } shouldBe 1 lines.none { it == "subq.l #2,sp" } shouldBe true lines.any { it.startsWith("bsr") } shouldBe true lines.any { it == "addq.l #8,sp" } shouldBe true // 2+2+4 bytes // small argument areas use the short addq form val smallCalls = listOf( IRInstructions.call(CallSite( target = CallTarget.Direct(CodeReference.Label("test.sub")), arguments = listOf(Calls.argument(1, IRDataType.BYTE, CallLocation.FrameSlot(8))) )), IRInstructions.simple(Opcode.RETURN) ) val smallLines = generateAsmWithIncoming(0, smallCalls, incomingSize = 4, subLabel = "test.caller") smallLines.any { it == "addq.l #2,sp" } shouldBe true } test("status-flag results use CCR-preserving cleanup") { val calls = listOf( IRInstructions.call(CallSite( target = CallTarget.Direct(CodeReference.Label("test.sub")), arguments = listOf(Calls.argument(1, IRDataType.BYTE, CallLocation.FrameSlot(8))), results = listOf(CallResult(null, CallLocation.StatusFlag(Statusflag.Pz))) )), IRInstructions.simple(Opcode.RETURN) ) val lines = generateAsmWithIncoming(0, calls, incomingSize = 4, subLabel = "test.caller") // a single byte argument (2-byte slot) would normally use addq.l #2,sp, but that // clobbers CCR, so status-flag returns must use lea instead. lines.any { it == "lea 2(sp),sp" } shouldBe true lines.none { it == "addq.l #2,sp" } shouldBe true } test("float stack arguments travel through the FPU accumulator") { val calls = listOf( IRInstructions.call(CallSite( target = CallTarget.Direct(CodeReference.Label("test.sub")), arguments = listOf(Calls.argument(1, IRDataType.FLOAT, CallLocation.FrameSlot(8))) )), IRInstructions.simple(Opcode.RETURN) ) val lines = generateAsmWithIncoming(0, calls, incomingSize = 4, subLabel = "test.caller") // the 68881 has no absolute-long addressing mode: the value must pass through fp0, // and the store itself reserves the slot via predecrement lines.any { it.startsWith("fmove.s p8_fregfile+0,fp0") } shouldBe true lines.any { it == "fmove.s fp0,-(sp)" } shouldBe true lines.none { it == "subq.l #4,sp" } shouldBe true } test("per-activation virtual registers are addressed through the frame") { // a re-entrant subroutine keeps its virtual registers in its own frame (slice 3), so they // are addressed relative to a5 instead of the flat program-static register file val lines = generateAsmWithIncoming(8, listOf( IRInstructions.loadMemory(Opcode.LOADM, IRDataType.WORD, 5, IRMemory.direct("main.var")), IRInstructions.returnRegister(IRDataType.WORD, 5) ), frameVregSlots = mapOf(5 to -2, 6 to -4)) lines.any { it.startsWith("link ") } shouldBe true lines.any { it.startsWith("move") && it.contains("-2(a5)") } shouldBe true lines.none { it.startsWith("move") && it.contains("p8_regfile") } shouldBe true } test("virtual registers without a frame slot keep using the static register file") { val lines = generateAsmWithIncoming(8, listOf( IRInstructions.loadMemory(Opcode.LOADM, IRDataType.WORD, 5, IRMemory.direct("main.var")), IRInstructions.returnRegister(IRDataType.WORD, 5) ), frameVregSlots = mapOf(6 to -4)) // register 5 has no frame slot, so it stays in the static register file lines.any { it.startsWith("move") && it.contains("p8_regfile") } shouldBe true lines.any { it.startsWith("move") && it.contains("(a5)") && it.contains("p8_regfile") } shouldBe false } test("a byte-sized frame-resident register is addressed with its byte offset") { val lines = generateAsmWithIncoming(4, listOf( IRInstructions.loadMemory(Opcode.LOADM, IRDataType.BYTE, 5, IRMemory.direct("main.var")), IRInstructions.returnRegister(IRDataType.BYTE, 5) ), frameVregSlots = mapOf(5 to -4)) lines.any { it.startsWith("move.b") && it.contains("-4(a5)") } shouldBe true } test("sub-byte extraction of a frame-resident register uses a valid a5 displacement") { // MSIGB on a long goes through regAddrByte, which must not emit "$(-4)(a5)" val lines = generateAsmWithIncoming(4, listOf( IRInstructions.binary(Opcode.MSIGB, IRDataType.LONG, 6, 5), IRInstructions.returnRegister(IRDataType.BYTE, 6) ), frameVregSlots = mapOf(5 to -4)) lines.any { it.startsWith("move.b") && it.contains("-4(a5)") } shouldBe true lines.none { it.contains("\$(-4)(a5)") } shouldBe true } test("framed subroutine emits a stack frame listing comment") { val lines = generateAsmWithIncoming(24, listOf( IRInstructions.storeZero(Opcode.STOREZM, IRDataType.BYTE, IRMemory.frameDirect(-1)), IRInstructions.simple(Opcode.RETURN) ), incomingSize = 8) lines.any { it == "; stack frame: 24 bytes of locals, 8 bytes of arguments" } shouldBe true } test("incoming-only frame emits a stack frame listing comment with zero locals") { val lines = generateAsmWithIncoming(0, listOf(IRInstructions.simple(Opcode.RETURN)), incomingSize = 8) lines.any { it == "; stack frame: 0 bytes of locals, 8 bytes of arguments" } shouldBe true } test("frameless subroutine emits no stack frame listing comment") { val lines = generateAsm(0, listOf( IRInstructions.loadMemory(Opcode.LOADM, IRDataType.BYTE, 1, IRMemory.direct("some.var")), IRInstructions.simple(Opcode.RETURN) )) lines.none { it.startsWith("; stack frame:") } shouldBe true } test("subroutine boundary markers are emitted exactly once per subroutine") { val lines = generateAsm(16, listOf( IRInstructions.storeZero(Opcode.STOREZM, IRDataType.BYTE, IRMemory.frameDirect(-1)), IRInstructions.simple(Opcode.RETURN) )) lines.count { it == "; ---- Subroutine: test.start ----" } shouldBe 1 lines.count { it == "; End of subroutine: test.start" } shouldBe 1 } test("large frames report an informational message") { val errors = ErrorReporterForTests() generateAsmWithIncoming(900, listOf( IRInstructions.storeZero(Opcode.STOREZM, IRDataType.BYTE, IRMemory.frameDirect(-1)), IRInstructions.simple(Opcode.RETURN) ), subLabel = "p8b_main.p8s_worker", errors = errors) errors.infos.any { it.contains("subroutine 'main.worker' uses a 900 byte stack frame; deep recursion multiplies this per call") } shouldBe true } test("small frames produce no informational message") { val errors = ErrorReporterForTests() generateAsmWithIncoming(24, listOf( IRInstructions.storeZero(Opcode.STOREZM, IRDataType.BYTE, IRMemory.frameDirect(-1)), IRInstructions.simple(Opcode.RETURN) ), incomingSize = 8, errors = errors) errors.infos shouldBe emptyList() } test("a long run of longword zero-clears lowers to a dbra loop") { val stores = (0 until 17).map { i -> IRInstructions.storeZero(Opcode.STOREZM, IRDataType.LONG, IRMemory.frameDirect(-68 + i * 4)) } val lines = generateAsm(68, stores + IRInstructions.simple(Opcode.RETURN)) lines.any { it == "moveq #0,d0" } shouldBe true lines.any { it == "lea -68(a5),a0" } shouldBe true lines.any { it == "moveq #16,d1" } shouldBe true lines.any { it == "move.l d0,(a0)+" } shouldBe true lines.count { it.startsWith("dbra d1,zeroloop_") } shouldBe 1 lines.none { it.startsWith("clr.l") } shouldBe true } test("a short run of longword zero-clears keeps straight stores") { val stores = (0 until 15).map { i -> IRInstructions.storeZero(Opcode.STOREZM, IRDataType.LONG, IRMemory.frameDirect(-60 + i * 4)) } val lines = generateAsm(60, stores + IRInstructions.simple(Opcode.RETURN)) lines.count { it.startsWith("clr.l") } shouldBe 15 lines.none { it.startsWith("dbra") } shouldBe true } test("an interleaved store splits the zero-clear run") { val first = (0 until 8).map { i -> IRInstructions.storeZero(Opcode.STOREZM, IRDataType.LONG, IRMemory.frameDirect(-68 + i * 4)) } val second = (0 until 8).map { i -> IRInstructions.storeZero(Opcode.STOREZM, IRDataType.LONG, IRMemory.frameDirect(-32 + i * 4)) } val instructions = first + IRInstructions.storeImmediate(IRDataType.BYTE, 0, IRMemory.frameDirect(-36)) + second + IRInstructions.simple(Opcode.RETURN) val lines = generateAsm(68, instructions) // two runs of 8 stay below the loop threshold lines.none { it.startsWith("dbra") } shouldBe true lines.count { it.startsWith("clr.l") } shouldBe 16 lines.any { it == "clr.b -36(a5)" } shouldBe true } test("dispatch jump in a framed subroutine pops the frame before jumping") { // an on..goto leaves the subroutine for good, so the target's rts must find our caller's // return address: leaving our frame behind would make it pop garbage val lines = generateAsm(16, listOf( IRInstructions.jumpIndirect(5), IRInstructions.simple(Opcode.RETURN) )) val unlkIdx = lines.indexOf("unlk a5") unlkIdx shouldBe lines.indexOfFirst { it.startsWith("jmp") } - 1 } test("dispatch jump in a frameless subroutine emits no frame teardown") { val lines = generateAsm(0, listOf( IRInstructions.jumpIndirect(5), IRInstructions.simple(Opcode.RETURN) )) lines.none { it.startsWith("unlk") } shouldBe true } test("jump to an absolute address pops the frame before jumping") { // a fixed address can't be resolved to a chunk of this subroutine, so it must be assumed // to leave for good and the frame must be gone before the target's eventual rts val lines = generateAsm(16, listOf( IRInstructions.jump(codeAddress(0x1234u)), IRInstructions.simple(Opcode.RETURN) )) val jmpIdx = lines.indexOfFirst { it.startsWith("jmp") } (jmpIdx > 0) shouldBe true lines[jmpIdx - 1] shouldBe "unlk a5" } test("jump to an absolute address in a frameless subroutine emits no frame teardown") { val lines = generateAsm(0, listOf( IRInstructions.jump(codeAddress(0x1234u)), IRInstructions.simple(Opcode.RETURN) )) lines.none { it.startsWith("unlk") } shouldBe true } test("conditional branch to an absolute address tears down only the taken path") { val lines = generateAsm(16, listOf( IRInstructions.branch(Opcode.BSTEQ, codeAddress(0x1234u)), IRInstructions.simple(Opcode.RETURN) )) val branchIdx = lines.indexOfFirst { it.startsWith("bne") } val jmpIdx = lines.indexOfFirst { it.startsWith("jmp") && "$1234" in it } (branchIdx >= 0) shouldBe true (jmpIdx > branchIdx) shouldBe true lines[jmpIdx - 1] shouldBe "unlk a5" lines.count { it == "unlk a5" } shouldBe 2 } test("conditional branch to an external non-entry label tears down only the taken path") { val outsideInner = IRCodeChunk("test.outside.inner", null).also { it.instructions.add(IRInstructions.simple(Opcode.RETURN)) } val lines = generateAsmForSubs( sub("test.start", frameSize = 16, instructions = listOf( IRInstructions.branch(Opcode.BSTEQ, CodeReference.Label("test.outside.inner")), IRInstructions.simple(Opcode.RETURN) )), sub("test.outside", frameSize = 0, instructions = listOf(IRInstructions.simple(Opcode.RETURN)), extraChunks = listOf(outsideInner)) ) val jmpIdx = lines.indexOfFirst { it.startsWith("jmp") && "outside.inner" in it } (jmpIdx > 0) shouldBe true lines[jmpIdx - 1] shouldBe "unlk a5" lines.any { it.startsWith("bne") } shouldBe true } test("signed comparison branch to an absolute address tears down only the taken path") { val lines = generateAsm(16, listOf( IRInstructions.branchImmediate(Opcode.BGTS, IRDataType.WORD, 1, 1, codeAddress(0x1234u)), IRInstructions.simple(Opcode.RETURN) )) lines.any { it.startsWith("cmpi.w") } shouldBe true val branchIdx = lines.indexOfFirst { it.startsWith("ble") } val jmpIdx = lines.indexOfFirst { it.startsWith("jmp") && "$1234" in it } (branchIdx >= 0) shouldBe true (jmpIdx > branchIdx) shouldBe true lines[jmpIdx - 1] shouldBe "unlk a5" lines.count { it == "unlk a5" } shouldBe 2 } test("jump to a label in another subroutine pops the frame before jumping") { // jumping out of the subroutine is a documented way to end it, so our frame must not be // left behind for whatever runs next (and its eventual rts) to trip over val lines = generateAsmForSubs( sub("test.start", frameSize = 16, instructions = listOf( IRInstructions.jump(CodeReference.Label("test.outside")), IRInstructions.simple(Opcode.RETURN) )), sub("test.outside", frameSize = 0, instructions = listOf(IRInstructions.simple(Opcode.RETURN))) ) val braIdx = lines.indexOfFirst { it.startsWith("bra") && "test.outside" in it } (braIdx > 0) shouldBe true lines[braIdx - 1] shouldBe "unlk a5" } test("jump to a label in a chunk of the same subroutine keeps the frame") { val lines = generateAsmForSubs( sub("test.start", frameSize = 16, instructions = listOf( IRInstructions.jump(CodeReference.Label("test.start.second")), IRInstructions.simple(Opcode.RETURN) ), extraChunks = listOf( IRCodeChunk("test.start.second", null).also { it.instructions.add(IRInstructions.storeImmediate(IRDataType.BYTE, 0, IRMemory.direct("test.var"))) } )) ) lines.count { it == "unlk a5" } shouldBe 1 // only the return epilogue lines[lines.indexOf("unlk a5") + 1] shouldBe "rts" lines.any { it.startsWith("bra") && "test.start.second" in it } shouldBe true } test("jump to a label inside a loop body of the same subroutine keeps the frame") { val loopBodyChunk = IRCodeChunk("test.start.loopbody", null) loopBodyChunk.instructions.add(IRInstructions.jump(CodeReference.Label("test.start.loopbody"))) val lines = generateAsmForSubs( sub("test.start", frameSize = 16, instructions = listOf( IRInstructions.simple(Opcode.RETURN) ), extraChunks = listOf(IRLoopChunk("test.start.loop", 3, mutableListOf(loopBodyChunk)))) ) lines.count { it == "unlk a5" } shouldBe 1 // the jump to the loop body label must not tear the frame down } })