mirror of
https://github.com/imjasonh/nescript
synced 2026-07-21 14:28:18 +00:00
runtime: stop __divide / __multiply from stomping ZP_CURRENT_STATE
Root cause: `ZP_DIV_REMAINDER`, `ZP_MUL_RESULT_HI`, and `ZP_CURRENT_STATE` all live at `$03`. The divide routine was zeroing the byte on entry (`LDA #0; STA $03`) and writing the running remainder there on every one of its 8 iterations; the multiply routine accumulated its running product there. Any multi-state program doing `u8 / 10` in `on_frame` had its state ID clobbered on the way out of the routine — the next main-loop dispatch read `$03 == 0` (or whatever the remainder happened to be), matched state 0, and handed control to `Title` instead of the current state. The platformer's HUD hit this once per blink period: Playing survived exactly one frame, then Title took over for 20 frames, then the cycle repeated. Fix: rewrite both runtime routines to keep their running accumulators in register A instead of `$03`. The new contracts: - `__divide`: input `A = dividend`, `$02 = divisor`. Output `A = remainder`, `$04 = quotient`. The algorithm shifts the dividend-turning-into-quotient through `$04` (same as before) and rotates the extracted bits into `A`, comparing and subtracting directly without ever touching `$03`. - `__multiply`: input `A = multiplicand`, `$02 = multiplier`. Output `A = product` (low 8 bits — high byte discarded for `u8 * u8 → u8` as before, but not via a `$03` write). The multiplicand gets shifted left each iteration via `$04` and the running sum stays in `A`. `IrOp::Div` lowering gains one extra `LDA $04` after the JSR to pick up the quotient; `IrOp::Mod` loses the old `LDA $03` since the remainder is already in A. Net callsite cost is one instruction either way. Added two regression tests — `divide_routine_does_not_touch_zp_03` and `multiply_routine_does_not_touch_zp_03` — that walk the emitted instruction stream and fail loudly on any ZeroPage($03) access, so a future refactor can't silently reintroduce the alias. Rebuilt the three ROMs that use `/` or `*` (bitwise_ops, mmc1_banked, platformer) and re-baselined the platformer audio golden — the new instruction count shifts vblank-relative audio timing by a few cycles, as the CLAUDE.md audio-churn note warns. Pixel goldens and docs/platformer.gif stay byte-identical. The platformer HUD is back on native `stomp_count / 10` + `% 10`; the subtraction-loop workaround is gone. docs/future-work.md gains a new section describing the planned sprite-0 hit upgrade for the platformer HUD (carry-over task from the branch).
This commit is contained in:
parent
b83b944570
commit
91f82c169a
9 changed files with 160 additions and 108 deletions
|
|
@ -260,41 +260,46 @@ missing:
|
|||
call — would shave a few bytes more on programs with many deep
|
||||
handlers.
|
||||
|
||||
### `u8 / 10` and `u8 % 10` miscompile near state transitions
|
||||
### `examples/platformer.ne` HUD — move from sprites to sprite-0 hit
|
||||
|
||||
The `examples/platformer.ne` HUD originally rendered its two-digit
|
||||
stomp tally with `TILE_DIGIT_0 + (stomp_count / 10)` and
|
||||
`TILE_DIGIT_0 + (stomp_count % 10)`. Both expressions passed the
|
||||
parser, survived analysis, and emitted a W0101 "expensive
|
||||
multiply/divide" warning as expected — but the built ROM cycled
|
||||
Title → Playing → Title once per blink period, with the Playing
|
||||
state surviving for exactly one frame before something stomped the
|
||||
state-machine bookkeeping at `$1B` (Title's `blink` / Playing's
|
||||
`player_y` overlay slot) and kicked control back to Title. The
|
||||
divide has to be the culprit: swapping both calls for a manual
|
||||
`while r >= 10 { r -= 10; … }` in two helper functions makes the
|
||||
golden hold across every frame.
|
||||
The platformer currently renders its status bar as five OAM
|
||||
sprites re-emitted every frame inside `draw_hud()`. That works but
|
||||
burns 5/64 OAM slots on static UI and forces the redraw path to
|
||||
walk the HUD every tick even when `score` / `lives` haven't
|
||||
changed. The clean upgrade is to paint the HUD into the top row of
|
||||
the nametable and use a sprite-0 hit split (same pattern as
|
||||
`examples/sprite_0_split_demo.ne`) to reset the X-scroll at the
|
||||
HUD/playfield boundary so the rest of the screen can keep
|
||||
scrolling freely. Target shape:
|
||||
|
||||
The HUD's `draw_hud` workaround sits in `examples/platformer.ne`
|
||||
with a comment pointing here. Two guesses at the root cause:
|
||||
1. Pre-paint row 0 of the nametable with `Bar` + glyph tiles via
|
||||
an `on enter` one-shot that drops `nt_set` / `nt_fill_h`
|
||||
writes into the VRAM ring — same shape as
|
||||
`examples/hud_demo.ne`.
|
||||
2. Place sprite 0 as a 1-pixel-wide invisible dot on the last
|
||||
scanline of the HUD row; the PPU sets the sprite-0 hit flag
|
||||
when rendering crosses it.
|
||||
3. Poll the flag in `on frame` and latch `scroll(camera_x, 0)`
|
||||
only after the hit, leaving the HUD painted with scroll=0 for
|
||||
the top rows.
|
||||
4. Replace the four in-frame `draw Tileset at: (…HUD…)` calls
|
||||
with a shadow-compare `if score != last_score { nt_set(...) }`
|
||||
pair (score digits) + `if lives != last_lives { nt_set(...) }`
|
||||
(heart row). Most frames write nothing.
|
||||
5. The palette row at metatile y=0 needs a dedicated sub-palette
|
||||
so the HUD doesn't inherit the sky/brick/ground attribute
|
||||
zones — add it to `palette_map` in row 0.
|
||||
6. Shadow variables `last_score` / `last_lives` initialize to
|
||||
`255` so the first frame paints unconditionally.
|
||||
|
||||
1. **Scratch overlap.** The divide routine's scratch may land
|
||||
beyond the `$00-$0F` runtime reservation and stomp on the
|
||||
state-local overlay base at `$1B`. A printout of the runtime's
|
||||
actual zero-page footprint alongside the analyzer's allocator
|
||||
snapshot would catch this.
|
||||
2. **Expression lowering.** The `BinaryOp::Div` / `Rem` path may
|
||||
leave a dangling temp in a slot that a surrounding `draw`
|
||||
statement later clobbers. The existing
|
||||
`uninitialized_struct_field_store_emits_sta_to_allocated_address`
|
||||
regression test exercises loads + stores but not divides; a
|
||||
round-trip test along the lines of "compile `x / 10` into a
|
||||
`var tens: u8`, wait a frame, assert `tens` still reads as the
|
||||
pre-wait value" would pin this down.
|
||||
Wins: 5 OAM slots back for enemies/particles, no per-frame HUD
|
||||
draw cost, and the flagship demo actually exercises sprite-0 hit
|
||||
in a real game (the standalone `sprite_0_split_demo.ne` stays as
|
||||
the minimal pedagogical example). Still NROM — no mapper change.
|
||||
|
||||
Either way the negative test is the priority — without one, the
|
||||
next person to reach for `/` or `%` in a state with overlaid
|
||||
locals hits the same PR #31-shaped silent drop.
|
||||
Blocker to track: the HUD reads `stomp_count` as a u8 decimal;
|
||||
when this lands, plumb it through whatever digit-extraction path
|
||||
replaces the current `tens_digit` / `ones_digit` helpers.
|
||||
|
||||
### Cross-block temp live-range analysis
|
||||
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -496,36 +496,11 @@ var lives: u8 = 3 // lives remaining; HUD heart readout
|
|||
// so the HUD stays pinned while the background scrolls under it.
|
||||
// y = 16 is above the brick row, so the white digits read cleanly
|
||||
// against the universal-sky backdrop.
|
||||
// Split a 0..99 count into its tens and ones decimal digits via
|
||||
// repeated subtraction — the runtime's software divide has a known
|
||||
// issue that scribbles over state-local memory (tracked in
|
||||
// docs/future-work.md §G), and the HUD is hot enough that the
|
||||
// miscompile sends the state machine spinning. Manual repeated
|
||||
// subtraction is two dozen cycles for the worst case of a two-digit
|
||||
// score, which is cheap for a once-per-frame call.
|
||||
fun tens_digit(n: u8) -> u8 {
|
||||
var t: u8 = 0
|
||||
var r: u8 = n
|
||||
while r >= 10 {
|
||||
r -= 10
|
||||
t += 1
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
fun ones_digit(n: u8) -> u8 {
|
||||
var r: u8 = n
|
||||
while r >= 10 {
|
||||
r -= 10
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
fun draw_hud() {
|
||||
// Score side (left): coin + tens + ones.
|
||||
draw Tileset at: (16, 16) frame: TILE_COIN
|
||||
draw Tileset at: (28, 16) frame: TILE_DIGIT_0 + tens_digit(stomp_count)
|
||||
draw Tileset at: (36, 16) frame: TILE_DIGIT_0 + ones_digit(stomp_count)
|
||||
draw Tileset at: (28, 16) frame: TILE_DIGIT_0 + (stomp_count / 10)
|
||||
draw Tileset at: (36, 16) frame: TILE_DIGIT_0 + (stomp_count % 10)
|
||||
|
||||
// Lives side (right): heart + digit.
|
||||
draw Tileset at: (208, 16) frame: TILE_HEART
|
||||
|
|
@ -843,8 +818,8 @@ state GameOver {
|
|||
// final score and the remaining heart total are visible at
|
||||
// the exact same position as in Playing — no awkward jump.
|
||||
draw Tileset at: (16, 16) frame: TILE_COIN
|
||||
draw Tileset at: (28, 16) frame: TILE_DIGIT_0 + tens_digit(stomp_count)
|
||||
draw Tileset at: (36, 16) frame: TILE_DIGIT_0 + ones_digit(stomp_count)
|
||||
draw Tileset at: (28, 16) frame: TILE_DIGIT_0 + (stomp_count / 10)
|
||||
draw Tileset at: (36, 16) frame: TILE_DIGIT_0 + (stomp_count % 10)
|
||||
draw Tileset at: (208, 16) frame: TILE_HEART
|
||||
draw Tileset at: (224, 16) frame: TILE_DIGIT_0 + lives
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1514,11 +1514,18 @@ impl<'a> IrCodeGen<'a> {
|
|||
IrOp::ShiftRightVar(d, a, amt) => self.gen_shift_var(*d, *a, *amt, LSR),
|
||||
IrOp::Div(d, a, b) => {
|
||||
// Software divide: dividend in A, divisor in $02.
|
||||
// `__divide` returns quotient in A and leaves
|
||||
// remainder in ZP $03. Flag the `__div_used` marker
|
||||
// so the linker links the `__divide` subroutine in;
|
||||
// `__divide` returns the quotient in ZP $04 and the
|
||||
// remainder in A. Flag the `__div_used` marker so
|
||||
// the linker links the `__divide` subroutine in;
|
||||
// programs that don't divide skip ~70 bytes. The
|
||||
// label is emitted at the end of generate().
|
||||
// label itself is emitted at the end of generate().
|
||||
//
|
||||
// We deliberately read the quotient from $04 (not
|
||||
// A) so the routine can keep the running remainder
|
||||
// in the accumulator and leave `$03` alone — `$03`
|
||||
// is `ZP_CURRENT_STATE`, and touching it would
|
||||
// stomp the state-machine dispatch. See
|
||||
// `runtime::gen_divide` for the contract.
|
||||
self.div_used = true;
|
||||
self.load_temp(*a);
|
||||
self.emit(PHA, AM::Implied);
|
||||
|
|
@ -1527,13 +1534,14 @@ impl<'a> IrCodeGen<'a> {
|
|||
self.emit(STA, AM::ZeroPage(0x02));
|
||||
self.emit(PLA, AM::Implied);
|
||||
self.emit(JSR, AM::Label("__divide".into()));
|
||||
self.emit(LDA, AM::ZeroPage(0x04));
|
||||
self.store_temp(*d);
|
||||
}
|
||||
IrOp::Mod(d, a, b) => {
|
||||
// Modulo reuses __divide and reads the remainder out
|
||||
// of ZP $03 afterwards. Same `__div_used` marker as
|
||||
// `IrOp::Div` — modulo doesn't have a separate
|
||||
// runtime routine.
|
||||
// Modulo reuses __divide and picks up the remainder
|
||||
// from the accumulator afterwards. Same `__div_used`
|
||||
// marker as `IrOp::Div` — modulo doesn't have a
|
||||
// separate runtime routine.
|
||||
self.div_used = true;
|
||||
self.load_temp(*a);
|
||||
self.emit(PHA, AM::Implied);
|
||||
|
|
@ -1542,7 +1550,6 @@ impl<'a> IrCodeGen<'a> {
|
|||
self.emit(STA, AM::ZeroPage(0x02));
|
||||
self.emit(PLA, AM::Implied);
|
||||
self.emit(JSR, AM::Label("__divide".into()));
|
||||
self.emit(LDA, AM::ZeroPage(0x03));
|
||||
self.store_temp(*d);
|
||||
}
|
||||
IrOp::Negate(d, src) => {
|
||||
|
|
|
|||
|
|
@ -979,31 +979,44 @@ pub fn gen_initial_background_load(tiles_label: &str, attrs_label: &str) -> Vec<
|
|||
out
|
||||
}
|
||||
|
||||
/// Zero-page locations used by multiply/divide routines.
|
||||
/// Zero-page locations used by the multiply / divide routines.
|
||||
/// `$02` is the "second operand" input slot — the multiplier for
|
||||
/// `__multiply` and the divisor for `__divide`. `$04` is the
|
||||
/// routines' private scratch: it holds the working multiplicand
|
||||
/// (shifted left each iteration) for multiply and the running
|
||||
/// dividend-turning-into-quotient for divide. Both routines keep
|
||||
/// their running output in the accumulator, so `$03`
|
||||
/// (`ZP_CURRENT_STATE` in the codegen) stays untouched — aliasing
|
||||
/// `$03` across the divide routine used to zero it and hand
|
||||
/// control to state 0 on the next frame.
|
||||
const ZP_MUL_OPERAND: u8 = 0x02;
|
||||
const ZP_MUL_RESULT_HI: u8 = 0x03;
|
||||
const ZP_DIV_DIVISOR: u8 = 0x02;
|
||||
const ZP_DIV_REMAINDER: u8 = 0x03;
|
||||
|
||||
/// Generate 8x8 -> 16 software multiply routine.
|
||||
/// Generate 8x8 -> 8 software multiply routine.
|
||||
///
|
||||
/// Input: A = multiplicand, zero-page $02 = multiplier
|
||||
/// Output: A = result low byte, $03 = result high byte
|
||||
/// Output: A = product (low byte; high byte is discarded for
|
||||
/// unsigned u8 * u8 → u8 semantics)
|
||||
///
|
||||
/// Algorithm: shift-and-add. For each bit of the multiplier, if set,
|
||||
/// add the (shifted) multiplicand to the result.
|
||||
/// add the (shifted) multiplicand to the running product. The
|
||||
/// product is kept in the accumulator — not in ZP `$03` — so the
|
||||
/// routine doesn't alias with `ZP_CURRENT_STATE`. `$02` is read-
|
||||
/// only at the callsite (the divisor/multiplier input slot) and is
|
||||
/// shifted in place here, which is fine because the caller re-
|
||||
/// populates it on every `__multiply` call.
|
||||
pub fn gen_multiply() -> Vec<Instruction> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
// Label for the subroutine entry
|
||||
out.push(Instruction::new(NOP, AM::Label("__multiply".into())));
|
||||
|
||||
// Store multiplicand in $04 (working copy)
|
||||
// Store multiplicand in $04 (working copy; we shift it left
|
||||
// each iteration to line up with the next multiplier bit).
|
||||
out.push(Instruction::new(STA, AM::ZeroPage(0x04)));
|
||||
|
||||
// Clear result: A (low) and $03 (high)
|
||||
// Running product starts at 0 in A.
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
|
||||
out.push(Instruction::new(STA, AM::ZeroPage(ZP_MUL_RESULT_HI)));
|
||||
|
||||
// Loop counter: 8 bits
|
||||
out.push(Instruction::new(LDX, AM::Immediate(0x08)));
|
||||
|
|
@ -1011,24 +1024,24 @@ pub fn gen_multiply() -> Vec<Instruction> {
|
|||
// __mul_loop:
|
||||
out.push(Instruction::new(NOP, AM::Label("__mul_loop".into())));
|
||||
|
||||
// Shift multiplier right, check carry (current bit)
|
||||
// Shift multiplier right, check carry (current bit).
|
||||
out.push(Instruction::new(LSR, AM::ZeroPage(ZP_MUL_OPERAND)));
|
||||
out.push(Instruction::new(
|
||||
BCC,
|
||||
AM::LabelRelative("__mul_no_add".into()),
|
||||
));
|
||||
|
||||
// Carry set: add multiplicand to result
|
||||
// Add low byte
|
||||
// Carry set: add the current shifted multiplicand to the
|
||||
// running product in A. CLC first because LSR sets carry to the
|
||||
// shifted-out bit.
|
||||
out.push(Instruction::implied(CLC));
|
||||
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_MUL_RESULT_HI)));
|
||||
out.push(Instruction::new(ADC, AM::ZeroPage(0x04)));
|
||||
out.push(Instruction::new(STA, AM::ZeroPage(ZP_MUL_RESULT_HI)));
|
||||
|
||||
// __mul_no_add:
|
||||
out.push(Instruction::new(NOP, AM::Label("__mul_no_add".into())));
|
||||
|
||||
// Shift multiplicand left (double it) for next bit position
|
||||
// Shift multiplicand left (double it) for the next bit
|
||||
// position.
|
||||
out.push(Instruction::new(ASL, AM::ZeroPage(0x04)));
|
||||
|
||||
// Decrement counter
|
||||
|
|
@ -1038,11 +1051,7 @@ pub fn gen_multiply() -> Vec<Instruction> {
|
|||
AM::LabelRelative("__mul_loop".into()),
|
||||
));
|
||||
|
||||
// Load low byte of result into A
|
||||
// For 8-bit result, just use the high byte accumulation
|
||||
// (since we shifted the multiplicand left, result is in $03)
|
||||
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_MUL_RESULT_HI)));
|
||||
|
||||
// A already holds the product.
|
||||
out.push(Instruction::implied(RTS));
|
||||
|
||||
out
|
||||
|
|
@ -1610,19 +1619,33 @@ pub fn gen_data_block(label: &str, bytes: Vec<u8>) -> Vec<Instruction> {
|
|||
/// Generate 8 / 8 -> 8 software divide routine (restoring division).
|
||||
///
|
||||
/// Input: A = dividend, zero-page $02 = divisor
|
||||
/// Output: A = quotient, $03 = remainder
|
||||
/// Output: A = remainder, zero-page $04 = quotient
|
||||
///
|
||||
/// Keeps the running remainder in the accumulator (instead of ZP
|
||||
/// $03) so the routine doesn't alias with `ZP_CURRENT_STATE` at
|
||||
/// $03. Any program with a state machine reads $03 to dispatch
|
||||
/// `on_frame`; the old shape zeroed it on every divide call and
|
||||
/// kicked control back to state 0 (usually `Title`) on the very
|
||||
/// next frame. The only caller-visible change is that `IrOp::Div`
|
||||
/// now picks up the quotient from `$04` rather than `A` — see
|
||||
/// `ir_codegen.rs` for the Div/Mod callsite shape. `$04` stays
|
||||
/// inside the `$00-$0F` runtime reservation so it never collides
|
||||
/// with user variables (analyzer starts user ZP at `$10` minimum).
|
||||
pub fn gen_divide() -> Vec<Instruction> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
// Label for the subroutine entry
|
||||
out.push(Instruction::new(NOP, AM::Label("__divide".into())));
|
||||
|
||||
// Store dividend in $04
|
||||
// Stash dividend in $04. Over the course of the loop we shift
|
||||
// it left 8 times; the bits that fall out become the running
|
||||
// remainder (in A), and the bits shifted in (via `INC $04`
|
||||
// below) become the quotient, so $04 ends up holding exactly
|
||||
// the quotient we want to return.
|
||||
out.push(Instruction::new(STA, AM::ZeroPage(0x04)));
|
||||
|
||||
// Clear remainder
|
||||
// Running remainder starts at 0 in A.
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
|
||||
out.push(Instruction::new(STA, AM::ZeroPage(ZP_DIV_REMAINDER)));
|
||||
|
||||
// Loop counter: 8 bits
|
||||
out.push(Instruction::new(LDX, AM::Immediate(0x08)));
|
||||
|
|
@ -1630,25 +1653,27 @@ pub fn gen_divide() -> Vec<Instruction> {
|
|||
// __div_loop:
|
||||
out.push(Instruction::new(NOP, AM::Label("__div_loop".into())));
|
||||
|
||||
// Shift dividend left into remainder
|
||||
// Shift the next bit of the dividend out of $04 into the carry,
|
||||
// then rotate it into the remainder accumulator.
|
||||
out.push(Instruction::new(ASL, AM::ZeroPage(0x04)));
|
||||
out.push(Instruction::new(ROL, AM::ZeroPage(ZP_DIV_REMAINDER)));
|
||||
out.push(Instruction::new(ROL, AM::Accumulator));
|
||||
|
||||
// Try to subtract divisor from remainder
|
||||
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_DIV_REMAINDER)));
|
||||
out.push(Instruction::implied(SEC));
|
||||
out.push(Instruction::new(SBC, AM::ZeroPage(ZP_DIV_DIVISOR)));
|
||||
|
||||
// If remainder >= divisor (no borrow), keep subtraction
|
||||
// Remainder >= divisor? CMP sets carry when A >= memory, clear
|
||||
// when A < memory. On `>=` we do the subtraction for real with
|
||||
// SBC (which still sees the same carry-set condition) and set
|
||||
// the quotient bit for this iteration. On `<` we leave A alone.
|
||||
out.push(Instruction::new(CMP, AM::ZeroPage(ZP_DIV_DIVISOR)));
|
||||
out.push(Instruction::new(
|
||||
BCC,
|
||||
AM::LabelRelative("__div_no_sub".into()),
|
||||
));
|
||||
|
||||
// Store updated remainder
|
||||
out.push(Instruction::new(STA, AM::ZeroPage(ZP_DIV_REMAINDER)));
|
||||
// Accept the subtraction into the remainder. SBC with carry set
|
||||
// performs a true subtract (no extra borrow).
|
||||
out.push(Instruction::new(SBC, AM::ZeroPage(ZP_DIV_DIVISOR)));
|
||||
|
||||
// Set bit 0 of quotient (in $04, which we shifted left)
|
||||
// Set bit 0 of the quotient (stored in $04, whose low bit is
|
||||
// free because we just shifted left).
|
||||
out.push(Instruction::new(INC, AM::ZeroPage(0x04)));
|
||||
|
||||
// __div_no_sub:
|
||||
|
|
@ -1661,9 +1686,7 @@ pub fn gen_divide() -> Vec<Instruction> {
|
|||
AM::LabelRelative("__div_loop".into()),
|
||||
));
|
||||
|
||||
// Load quotient into A
|
||||
out.push(Instruction::new(LDA, AM::ZeroPage(0x04)));
|
||||
|
||||
// A already holds the final remainder. $04 holds the quotient.
|
||||
out.push(Instruction::implied(RTS));
|
||||
|
||||
out
|
||||
|
|
|
|||
|
|
@ -663,6 +663,48 @@ fn divide_routine_assembles() {
|
|||
);
|
||||
}
|
||||
|
||||
/// `$03` is `ZP_CURRENT_STATE` in the codegen — the byte the main
|
||||
/// dispatch loop reads every frame to pick the right `on_frame`
|
||||
/// handler. If the divide routine writes to `$03` at any point it
|
||||
/// zeroes the current-state value and kicks the state machine back
|
||||
/// to state 0 on the very next frame (see the PR that introduced
|
||||
/// the platformer HUD for the full repro). This test pins the
|
||||
/// routine to a `$03`-free implementation so any future refactor
|
||||
/// that reintroduces a ZP-3 write fails loudly instead of shipping
|
||||
/// a silent miscompile.
|
||||
#[test]
|
||||
fn divide_routine_does_not_touch_zp_03() {
|
||||
for inst in gen_divide() {
|
||||
let touches_03 = matches!(&inst.mode, AM::ZeroPage(0x03));
|
||||
assert!(
|
||||
!touches_03,
|
||||
"gen_divide emitted an instruction touching $03 \
|
||||
(collides with ZP_CURRENT_STATE): {:?} {:?}",
|
||||
inst.opcode, inst.mode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Same argument as `divide_routine_does_not_touch_zp_03`, applied
|
||||
/// to the multiplicand. The old `gen_multiply` accumulated the
|
||||
/// result in ZP `$03` even for `u8 * u8 → u8` (the high byte was
|
||||
/// simply discarded at the callsite), which silently corrupted
|
||||
/// `ZP_CURRENT_STATE` across every `*` operator in a multi-state
|
||||
/// program. Holding the running product in the accumulator instead
|
||||
/// keeps `$03` pristine.
|
||||
#[test]
|
||||
fn multiply_routine_does_not_touch_zp_03() {
|
||||
for inst in gen_multiply() {
|
||||
let touches_03 = matches!(&inst.mode, AM::ZeroPage(0x03));
|
||||
assert!(
|
||||
!touches_03,
|
||||
"gen_multiply emitted an instruction touching $03 \
|
||||
(collides with ZP_CURRENT_STATE): {:?} {:?}",
|
||||
inst.opcode, inst.mode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bank switching ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
c5e41b59 132084
|
||||
30938b26 132084
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue