1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 08:55:38 +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:
Claude 2026-04-20 15:15:19 +00:00
parent b83b944570
commit 91f82c169a
No known key found for this signature in database
9 changed files with 160 additions and 108 deletions

Binary file not shown.

Binary file not shown.

View file

@ -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.