fix Status flag extraction clobbers a register return value

This commit is contained in:
Irmen de Jong committed 2026-09-24 01:00:49 +02:00
1 parent 976b64185d
commit 4cbf93da85
8 files changed
+232 -293

No files matched your search

@@ -68,10 +68,15 @@ internal class AssignmentAsmGen(
// Check if we need to save A register before extracting status flags
// (flag extraction functions like assignCarryFlagResult overwrite A)
val hasByteInA = registersResults.any { (ret, _) ->
// A holds the low byte of a byte/bool return in @A, and of a word return in @AX/@AY
val byteInA = registersResults.any { (ret, _) ->
ret.type.isByteOrBool && ret.register.registerOrPair == RegisterOrPair.A
}
if(hasByteInA && statusFlagResults.isNotEmpty()) {
val wordLowByteInA = registersResults.any { (ret, _) ->
ret.type.isWord && ret.register.registerOrPair in setOf(RegisterOrPair.AX, RegisterOrPair.AY)
}
val hasAInResult = byteInA || wordLowByteInA
if(hasAInResult && statusFlagResults.isNotEmpty()) {
asmgen.out(" pha") // Save A (contains return value) before flag extraction
}
@@ -101,7 +106,7 @@ internal class AssignmentAsmGen(
}
// Restore A if we saved it for flag extraction
if(hasByteInA && statusFlagResults.isNotEmpty()) {
if(hasAInResult && statusFlagResults.isNotEmpty()) {
asmgen.out(" pla")
}
@@ -33,9 +33,10 @@ internal class AssignmentGen(private val codeGen: IRCodeGen, private val exprGen
if (extsub.returns.size == assignmentTargets.size) {
// Targets and values match. Assign all the things. Skip 'void' targets.
// We need to handle both FP registers (FAC1/FAC2) and regular CPU registers
// Status flag returns MUST be processed first, because their branch-based
// code needs to read CPU flags immediately after the call, before any
// LOADHR/STOREM instructions clobber them.
// Status flag returns must be read before anything that clobbers the CPU status
// bits. When the call also has non-flag register returns, those are processed
// first (they may clobber the scratch register used by the flag extraction),
// protected by an outer PUSHST/POPST so the flags stay intact for the extraction.
val fpRegs = funcCall.multipleResultFpRegs.toMutableList()
val cpuRegs = funcCall.multipleResultRegs.toMutableList()
@@ -67,16 +68,32 @@ internal class AssignmentGen(private val codeGen: IRCodeGen, private val exprGen
// flag still needs to read. Wrap every flag pair's handling in
// PUSHST/POPST so each one sees the original status right after the call.
val wrapStatusFlags = flagPairs.count { !isVoidTarget(it) } > 1
flagPairs.forEach { pair ->
if (wrapStatusFlags && !isVoidTarget(pair)) {
result += IRCodeChunk(null, null).also { it += IRInstructions.simple(Opcode.PUSHST) }
processPair(pair)
result += IRCodeChunk(null, null).also { it += IRInstructions.simple(Opcode.POPST) }
} else {
processPair(pair)
fun processFlagPairs() {
flagPairs.forEach { pair ->
if (wrapStatusFlags && !isVoidTarget(pair)) {
result += IRCodeChunk(null, null).also { it += IRInstructions.simple(Opcode.PUSHST) }
processPair(pair)
result += IRCodeChunk(null, null).also { it += IRInstructions.simple(Opcode.POPST) }
} else {
processPair(pair)
}
}
}
otherPairs.forEach(::processPair)
val hasStatusFlags = flagPairs.any { !isVoidTarget(it) }
val hasOtherResults = otherPairs.any { !isVoidTarget(it) }
if (hasStatusFlags && hasOtherResults) {
// The non-flag register returns are read before the flag extraction,
// so the extraction's scratch register cannot clobber them. The flag
// bits are preserved across the register loads/stores with PUSHST/POPST.
result += IRCodeChunk(null, null).also { it += IRInstructions.simple(Opcode.PUSHST) }
otherPairs.forEach(::processPair)
result += IRCodeChunk(null, null).also { it += IRInstructions.simple(Opcode.POPST) }
processFlagPairs()
} else {
processFlagPairs()
otherPairs.forEach(::processPair)
}
} else {
throw AssemblyError("number of values and targets don't match")
}
@@ -180,21 +180,18 @@ internal fun AsmGen.translateControl(insn: IRInstruction, forwardedImmediateCall
// === Status flag stack ops via Scc ===
Opcode.PUSHST -> {
// Move CCR to D0 (low byte), then push as byte.
// Save CCR to the stack. The 68010+ memory form keeps the CCR save
// register-neutral (no data register is clobbered) and word-aligned.
if(program.options.compTarget.cpu == CpuType.M68000)
error("the 68000 cpu cannot save/restore the status bits using a nonprivileged instruction. This is required to implement the 'PUSHST/POPST' IR opcodes. Compile for 68010 or higher cpu or change the code such that PUSHST/POPST are no longer used (sometimes complicated rol/ror operations need it)")
invalidateD0Cache()
emitLine("move ccr, d0")
emitLine("move.b d0, -(sp)")
emitLine("move ccr, -(sp)")
}
Opcode.POPST -> {
// Pop byte into D0, then restore CCR
// Restore CCR from the stack (register-neutral on 68010+).
if(program.options.compTarget.cpu == CpuType.M68000)
error("the 68000 cpu cannot save/restore the status bits using a nonprivileged instruction. This is required to implement the 'PUSHST/POPST' IR opcodes. Compile for 68010 or higher cpu or change the code such that PUSHST/POPST are no longer used (sometimes complicated rol/ror operations need it)")
invalidateD0Cache()
emitLine("move.b (sp)+, d0")
emitLine("move d0, ccr")
emitLine("move (sp)+, ccr")
}
// === Flag manipulation ===
+46
View File
@@ -450,6 +450,52 @@ main {
}
}
test("status flag return is saved and register return read first on m68k") {
// Regression test: a multi-assign returning both a status flag and a register value
// used to let the flag extraction (which uses D0 as scratch for ROXL) clobber the
// register return. The register return is now read first, protected by a
// register-neutral PUSHST/POPST (the 68010+ 'move ccr,-(sp)' form).
val src = """
main {
inline asmsub f(ubyte arg @D0) -> bool @Pc, ubyte @D0 {
%asm {{
moveq #42, d0
cmp.b #42, d0
}}
}
sub start() {
bool c
ubyte v
c, v = f(42)
}
}
"""
val result = compileText(Qemu68kTarget(), optimize = false, src, outputDir, writeAssembly = true, assemble = false)
result shouldNotBe null
val asm = result!!.compilationOptions.outputDir.resolve("${result.compilerAst.name}.asm").toFile().readText()
// scope to the start subroutine region
val start = asm.indexOf("; ---- Subroutine: p8b_main.p8s_start")
val end = asm.indexOf("; ---- Subroutine:", start + 1).let { if (it < 0) asm.length else it }
val lines = asm.substring(start, end).lines().map { it.trim() }
// register-neutral PUSHST/POPST form, no d0 round-trip
lines.count { it == "move ccr,-(sp)" } shouldBe 1
lines.count { it == "move (sp)+,ccr" } shouldBe 1
lines.count { it == "move ccr, d0" } shouldBe 0
lines.count { it == "move.b d0, -(sp)" } shouldBe 0
// the @D0 return is read (loadhr) before the flag extraction's scratch use (roxl)
val loadhrIdx = lines.indexOfFirst { it.startsWith("; loadhr.b") }
val roxlIdx = lines.indexOfFirst { it.contains("roxl") }
loadhrIdx shouldBeGreaterThan -1
roxlIdx shouldBeGreaterThan -1
(loadhrIdx < roxlIdx) shouldBe true
// the loadhr sits inside the PUSHST/POPST sandwich
val pushIdx = lines.indexOfFirst { it == "move ccr,-(sp)" }
val popIdx = lines.indexOfFirst { it == "move (sp)+,ccr" }
(pushIdx < loadhrIdx) shouldBe true
(loadhrIdx < popIdx) shouldBe true
}
test("struct type symbols are emitted in the header before program start") {
val src = """
main {
@@ -1,6 +1,7 @@
package prog8tests.codegeneration
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.ints.shouldBeGreaterThan
import io.kotest.matchers.shouldBe
import prog8.code.target.C64Target
import prog8tests.helpers.compileText
@@ -187,4 +188,107 @@ class TestNewCodegenBugs : FunSpec({
legacyMachine.assertMemory(0x343, expected[3])
}
}
test("status flag return does not clobber an A register return value") {
// Regression test: in a multi-assign returning both a status flag and a byte in A,
// the flag extraction (which uses A as scratch) used to destroy the byte return.
// The register return is now read first, protected by a PUSHST/POPST (php/plp on 6502).
val src = $$"""
%option no_sysinit
%launcher none
%address $1000
main {
inline asmsub f(ubyte arg @A) -> bool @Pc, ubyte @A {
%asm {{
lda #42
cmp #42
}}
}
sub start() {
bool c
ubyte v
c, v = f(42)
main.v = v
main.c = c as ubyte
}
ubyte @shared v
ubyte @shared c
}
""".trimIndent()
// asm-text only, so this test does not require an external assembler
val compileResult = compileText(C64Target(), false, src, outputDir, newCodegen = true, writeAssembly = true, assemble = false)!!
val asm = compileResult.compilationOptions.outputDir.resolve(compileResult.compilerAst.name + ".asm").readText()
// scope to the start subroutine region
val start = asm.indexOf("; ---- Subroutine: p8b_main.p8s_start")
val end = asm.indexOf("; ---- Subroutine:", start + 1).let { if (it < 0) asm.length else it }
val lines = asm.substring(start, end).lines().map { it.trim() }
// the register read (loadhr) must happen before the flag extraction's lda #0
val loadhrIdx = lines.indexOfFirst { it.startsWith("; loadhr.b") }
val flagLdaIdx = lines.indexOfFirst { it == "lda #0" }
loadhrIdx shouldBeGreaterThan -1
flagLdaIdx shouldBeGreaterThan -1
(loadhrIdx < flagLdaIdx) shouldBe true
// protected by a php/plp pair around the register read
lines.count { it == "php" } shouldBe 1
lines.count { it == "plp" } shouldBe 1
}
test("legacy 6502 word @AX/@AY return saves A across status flag extraction") {
val src = $$"""
%option no_sysinit
main {
asmsub fAX(ubyte a @A) -> bool @Pc, uword @AX {
%asm {{
lda #42
ldx #1
cmp #42
rts
}}
}
asmsub fAY(ubyte a @A) -> bool @Pc, uword @AY {
%asm {{
lda #42
ldy #1
cmp #42
rts
}}
}
sub start() {
bool c
uword w
c, w = fAX(1)
main.w = w
c, w = fAY(1)
main.w = w
}
uword @shared w
}
""".trimIndent()
// asm-text only, so this test does not require an external assembler
val compileResult = compileText(C64Target(), false, src, outputDir, newCodegen = false, writeAssembly = true, assemble = false)!!
val asm = compileResult.compilationOptions.outputDir.resolve(compileResult.compilerAst.name + ".asm").readText()
// scope to the start subroutine region (legacy asm delimits top-level subroutines with a "<name>\t.proc" label at column 0)
val rawLines = asm.lines()
val from = rawLines.indexOfFirst { it.startsWith("p8s_start\t.proc") }
val to = rawLines.drop(from + 1).indexOfFirst { it.endsWith(".proc") && !it.startsWith(" ") && !it.startsWith("\t") }
.let { if (it < 0) rawLines.size else from + 1 + it }
val lines = rawLines.subList(from, to).map { it.trim() }
// the flag extraction clobbers A, which holds the low byte of the word result,
// so A must be saved with pha and restored with pla before the pair is stored
lines.count { it == "pha" } shouldBe 2
lines.count { it == "pla" } shouldBe 2
val phaIdx = lines.indexOfFirst { it == "pha" }
val flagIdx = lines.indexOfFirst { it == "lda #0" }
val plaIdx = lines.indexOfFirst { it == "pla" }
// the first store of the word variable's low byte
val staIdx = lines.indexOfFirst { it.startsWith("sta ") && it.endsWith("p8v_w") }
phaIdx shouldBeGreaterThan -1
flagIdx shouldBeGreaterThan -1
plaIdx shouldBeGreaterThan -1
staIdx shouldBeGreaterThan -1
(phaIdx < flagIdx) shouldBe true
(flagIdx < plaIdx) shouldBe true
(plaIdx < staIdx) shouldBe true
}
})
+41 -3
View File
@@ -1753,6 +1753,43 @@ main {
irContent shouldContain "bstvc"
}
test("asmsub multi-assign with a status flag and a register return orders the register read first") {
// The register return must be read before the flag extraction (which uses a
// scratch hardware register), protected by an outer PUSHST/POPST.
val src = """
%option no_sysinit
main {
sub start() {
ubyte v
bool c
v, c = test()
main.r1 = v
main.b1 = c
}
asmsub test() -> ubyte @A, bool @Pc {
%asm {{
lda #42
cmp #42
rts
}}
}
ubyte @shared r1
bool @shared b1
}"""
val result = compileText(VMTarget(), optimize=false, src, outputDir, writeAssembly = true)!!
val virtfile = result.compilationOptions.outputDir.resolve(result.compilerAst.name + ".p8ir")
val lines = virtfile.readText().lines().map { it.trim() }
// one outer PUSHST/POPST pair (only one flag, so no per-flag wrapping)
lines.count { it == "pushst" } shouldBe 1
lines.count { it == "popst" } shouldBe 1
// the register read (loadhr) must come before the flag extraction (roxl)
val loadhrIdx = lines.indexOfFirst { it.startsWith("loadhr") }
val roxlIdx = lines.indexOfFirst { it.startsWith("roxl") }
loadhrIdx shouldBeGreaterThan -1
roxlIdx shouldBeGreaterThan -1
(loadhrIdx < roxlIdx) shouldBe true
}
test("asmsub multi-assign with multiple status flags in one call saves the status around each flag") {
val src = """
%option no_sysinit
@@ -1783,10 +1820,11 @@ main {
val result = compileText(VMTarget(), optimize=false, src, outputDir, writeAssembly = true)!!
val virtfile = result.compilationOptions.outputDir.resolve(result.compilerAst.name + ".p8ir")
val irContent = virtfile.readText()
// each of the three status flags needs its own PUSHST/POPST pair, because the
// 1 outer PUSHST/POPST pair protects the register return read plus the flags across it,
// and each of the three status flags needs its own PUSHST/POPST pair, because the
// branch-based extraction of one flag clobbers the status for the following flags
irContent.lines().count { it.trim() == "pushst" } shouldBe 3
irContent.lines().count { it.trim() == "popst" } shouldBe 3
irContent.lines().count { it.trim() == "pushst" } shouldBe 4
irContent.lines().count { it.trim() == "popst" } shouldBe 4
}
test("union zero-init via memory, write byte and read overlapping word") {
-1
View File
@@ -14,7 +14,6 @@ Future Things and Ideas
- support typed pointer arrays as struct fields, curretly requires untyped pointers arrays.
- allow aliasing a whole enum (``alias myEnum = MyEnum``) so that ``myEnum::Member`` works just like ``MyEnum::Member``; currently only aliasing individual enum members is supported. See ideas/alias-enums.md for the plan.
- evaluate the feature suggestions in ideas/c16-banked-data.md (cx16 banked data library).
- Status flag extraction clobbers a register return value: in a multi-assign where one call returns both a status flag and a regular register value (e.g. ``-> bool @Pc, ubyte @A`` on new6502, or ``-> bool @Pc, ubyte @D0`` on m68k), the status flag extraction destroys the register return before it is read. The extraction uses a scratch hardware register (``A`` on 6502, ``D0`` on m68k) that can be the same register holding the return value. The old 6502 codegen avoids this with ``pha``/``pla``. See ideas/status-flag-register-clobber.md for the design (reorder the register returns before the flag extraction, protected by register-neutral ``PUSHST``/``POPST``).
Won't do's or deferred
^^^^^^^^^^^^^^^^^^^^^^
-267
View File
@@ -1,267 +0,0 @@
# Status flag extraction clobbers a register return value
**Status: deferred / design only, not implemented.** Listed in
`docs/source/todo.rst`.
Note: the *multiple* status flag returns case is already fixed (each flag
extraction is wrapped in `PUSHST`/`POPST` in `AssignmentGen`). This document
covers the separate case where a status flag return is combined with a regular
register return that lives in the extraction's scratch register.
## 1. Problem
In a multi-assignment where one call returns both a CPU status flag and a
regular register value, the flag extraction can destroy the register return
before it is read. Example:
```prog8
main {
inline asmsub f(ubyte arg @A) -> bool @Pc, ubyte @A {
%asm {{
lda #42
cmp #42
}}
}
sub start() {
bool c
ubyte v
c, v = f(42) ; v is NOT 42, it is the clobbered scratch value
}
}
```
This is reachable in practice, e.g. the f256 syslib `GETIN() -> bool @Pc, ubyte @A`
(`examples/customtarget/libraries/f256/syslib.p8:39`).
The bug is *separate* from the "multiple status flag returns clobber each other"
issue (which was fixed by wrapping each flag extraction in `PUSHST`/`POPST` in
`AssignmentGen`). That fix protects the status bits; it does not protect a
hardware register that holds a non-flag return value.
### When it triggers
The bug needs a **multi-assign** from an `asmsub`/`extsub` (only those can
declare status flag returns) whose returned **status flag target is not `void`**
and whose **register return target is not `void`**, where the register value
lives in the extraction's scratch register:
- **new6502 / 6502**: scratch is `A`.
- `@A` (byte/bool) return: broken with *any* flag (`@Pc`/`@Pz`/`@Pn`/`@Pv`),
because every flag extraction loads a `0`/`1` immediate through `A`.
- `@AX`/`@AY` (word) return: broken too, since `A` is its low byte.
- `@X`/`@Y`-only returns: safe.
- **m68k**: scratch is `D0`, but only the `@Pc` extraction uses it (the `ROXL`
round-trip). `@Pz`/`@Pn`/`@Pv` lower to a branch plus a direct memory store.
- `@D0` + `@Pc`: broken.
- `@D0` + `@Pz`/`@Pn`/`@Pv`: safe.
- `@D1` and higher: safe.
- **virtual (VM)**: never (extraction only uses IR virtual registers).
The return order does not matter (flags are always extracted before the register
returns), and it is irrelevant where the value is stored (variable, register, or
memory).
## 2. Root cause
The IR generator (`codeGenIntermediate/src/prog8/codegen/intermediate/AssignmentGen.kt`)
processes a multi-assign in this order:
1. **status flag returns first** (they must read the live CPU status right after
the call), then
2. **non-flag register returns** via `LOADHR` (hardware register -> IR virtual
register).
The status flag extraction sequence uses a **scratch hardware register**:
| target | extraction scratch | where |
| --- | --- | --- |
| new6502 | `A` | `lda #0` before `ROXL`, and the `lda`-based stores |
| m68k | `D0` | `ROXL` is lowered as `move.b regfile,d0; roxl.b #1,d0; move.b d0,regfile` (`codeGenM68k/.../InstrBitwise.kt:357`) |
| virtual (VM) | none (operates on IR virtual registers only) | unaffected |
If a non-flag return lives in that same scratch register, the extraction
destroys it before `LOADHR` reads it.
### Evidence (new6502, `-target c64 -newcodegen`)
```
; call p8b_main.p8s_f():r1.b@Pc,r2.b@s0
; inlined: p8b_main.p8s_f
lda #42
cmp #42
; end inlined
; load.b r1.b,#0.b
lda #0 <-- clobbers A
sta p8_regfile+0
; roxl.b r1.b
rol p8_regfile+0
; storem.b r1.b,[c]
lda p8_regfile+0
sta c
; loadhr.b r2.b,s0.b <-- reads the clobbered A, not 42
sta p8_regfile+1
```
### Evidence (m68k, `-target qemu68k`)
```
bsr p8b_main.p8s_f
; load.b r1.b,#0.b
clr.b p8_regfile+0
; roxl.b r1.b
move.b p8_regfile+0,d0 <-- clobbers d0 (which held the @D0 return)
roxl.b #1,d0
; storem.b r1.b,[c]
move.b d0,p8v_c
; loadhr.b r2.b,s10.b
; storem.b r2.b,[v]
move.b d0,p8v_v <-- stores the clobbered d0
```
### Legacy 6502 reference
The old 6502 codegen handles the byte/bool-in-`A` case with `pha`/`pla`:
- `codeGenCpu6502/.../assignment/AssignmentAsmGen.kt:69-76` - if a byte/bool
return is in `A` and there are status flags, emit `pha` before the flag
extraction.
- `:103-106` - `pla` before storing the register results.
Note that `hasByteInA` (`:69`) only checks `ret.type.isByteOrBool &&
registerOrPair == A`, so legacy does **not** cover a word return in `@AX`/`@AY`
and shares that variant of the bug (the low byte in `A` is clobbered). The
proposed design below covers all of these cases.
## 3. Design
### 3.1 Key enabler: register-neutral PUSHST/POPST on m68k
The recommended fix relies on `PUSHST`/`POPST` not clobbering any data register.
On new6502 they are already neutral (`php`/`plp`). On m68k the current
implementation goes through `d0`:
```
move ccr,d0
move.b d0,-(sp) ; PUSHST
move.b (sp)+,d0
move d0,ccr ; POPST
```
The 68010 `MOVE CCR` instruction also supports a **memory** operand, so the
round-trip is unnecessary:
```
move ccr,-(sp) ; PUSHST (encoding 42E7)
move (sp)+,ccr ; POPST (encoding 44DF)
```
This saves/restores the CCR without touching any data register, and keeps the
stack word-aligned (the current byte push does not, which the m68k coding
conventions warn against).
Verified by assembling with `vasmm68k_mot -m68020` and by executing a CCR
round-trip under `qemu-system-m68k -M virt -cpu m68020` (a program that sets
C=1, pushes the CCR, clears the flags, pops the CCR back, then takes `if_cs`
prints the expected result).
### 3.2 IR generator change (`AssignmentGen.translate`)
Only when a call has **at least one status-flag return AND at least one
non-flag result** (register or float), reorder so the register/float returns are
materialized first, protected by a single `PUSHST`/`POPST`:
```
CALL
PUSHST ; save post-call status S0
[otherPairs: LOADHR + store] ; read return registers before extraction;
; may clobber flags and scratch registers
POPST ; restore S0
[flagPairs: per-flag PUSHST/extract+store/POPST when >1 flag]
```
- Register/float values are safely in IR virtual registers before the flag
extraction clobbers the scratch register.
- The float case (`LOADHFACZERO`/`LOADHFACONE`, which lowers to a `jsr` on 6502)
is covered by the same outer save/restore.
- The existing per-flag `PUSHST`/`POPST` sandwich (for >1 status flag) is kept
unchanged; this change is orthogonal.
- Flags-only and registers-only multi-assigns keep the current codegen (no
extra instructions).
Pseudo-code in the extsub branch:
```kotlin
val hasStatusFlags = flagPairs.any { !isVoidTarget(it) }
val hasOtherResults = otherPairs.any { !isVoidTarget(it) }
if (hasStatusFlags && hasOtherResults) {
result += IRCodeChunk(null, null).also { it += IRInstructions.simple(Opcode.PUSHST) }
otherPairs.forEach(::processPair)
result += IRCodeChunk(null, null).also { it += IRInstructions.simple(Opcode.POPST) }
flagPairs.forEach { pair -> wrap-if-multi(pair) }
} else {
flagPairs.forEach { pair -> wrap-if-multi(pair) }
otherPairs.forEach(::processPair)
}
```
The "status flags MUST be processed first" comment in `AssignmentGen.kt:36` is
then no longer accurate; it becomes "status flags must be read before anything
that clobbers them, which is guaranteed by the surrounding PUSHST/POPST".
### 3.3 m68k backend change (`codeGenM68k/.../InstrControl.kt:180-200`)
Change `PUSHST`/`POPST` to the memory form and drop the `d0` round-trip (and the
now-unnecessary `invalidateD0Cache()` calls). The 68000 guard stays: the 68000
cannot save the CCR with a nonprivileged instruction.
No changes are needed in new6502, the VM, or the IR file format.
## 4. Alternatives considered
- **New IR opcodes `PUSHHR`/`POPHR`** (save/restore a hardware register slot)
around the flag extraction. Works, and keeps `PUSHST`/`POPST` semantics
unchanged, but requires IR enum changes, `IRTextCodec`/reader support,
peephole side-effect sets, VM support, and implementations in both backends.
More plumbing for the same result.
- **d0-preserving `PUSHST`** (push `d0` plus the CCR, restore both). Legal on
all m68k CPUs, but leaves `d0` clobbered *between* `PUSHST` and `POPST`, so a
`LOADHR d0` inside the wrap still reads garbage. Rejected.
- **6502-only reorder without save/restore.** On 6502 `LOADHR` (`sta`/`stx`/
`sty`) is flag-neutral, so the reorder alone would work there, but the stores
and the m68k `move`-based `LOADHR` are not, so it does not generalize.
## 5. Edge cases
- `void` targets: excluded from both the wrap condition and the per-pair
handling, as today.
- Mixed status flag + `@A`/`@D0` + float returns: covered by the same outer
save/restore.
- `amiga500` (68000): remains a hard codegen error, now triggered only for the
flags-plus-register combination.
- Portability note: `move ccr,-(sp)` / `move (sp)+,ccr` are 68010+ instructions.
The only non-68000 m68k target is `qemu68k` (68020), so this is safe today. If
a 68010 target is ever added it still works; a 68000 target cannot use it at
all (already an error).
## 6. Test plan
- **ksim65 regression** (`compiler/test/codegeneration/TestNewCodegenBugs.kt`):
`-> bool @Pc, ubyte @A` returning 42, assert the byte survives; run with
`newCodegen=true` and with the legacy codegen as the reference, for
`optimize` false and true.
- **m68k**: a `TestM68k`-style test asserting the `move ccr,-(sp)` sandwich and
that no `d0` round-trip remains around the extraction; if feasible, a
`qemu68k` execution check.
- **VM**: an IR-text test asserting the reorder/save appears (asmsub bodies
cannot be executed by the VM).
## 7. Affected files
- `codeGenIntermediate/src/prog8/codegen/intermediate/AssignmentGen.kt` - reorder
+ outer `PUSHST`/`POPST`.
- `codeGenM68k/src/prog8/codegen/m68k/InstrControl.kt` - register-neutral
`PUSHST`/`POPST`.
- `compiler/test/codegeneration/TestNewCodegenBugs.kt`,
`compiler/test/codegeneration/TestM68k.kt`,
`compiler/test/vm/TestCompilerVirtual.kt` - tests.