platformer HUD + fix __divide/__multiply stomping ZP_CURRENT_STATE#38
Merged
platformer HUD + fix __divide/__multiply stomping ZP_CURRENT_STATE#38
__divide/__multiply stomping ZP_CURRENT_STATE#38Conversation
Upgrades the platformer's "live coin count" into a proper heads-up
display that stays pinned to the top of the viewport while the
nametable scrolls. Left side: coin icon + two-digit stomp tally.
Right side: red heart icon + single-digit lives counter. Both ride
through the GameOver screen without jumping position, so the death
banner reads as a continuation of the same run.
Wire-up: three new cross-state bits — score now accumulates across
lives, `lives` starts at 3 and decrements in `GameOver.on_enter`,
and the GameOver → Playing retry bounces to Title instead when the
last heart is spent (Title's `on_enter` refills both).
Tile pipeline: ten decimal digits + a heart glyph added to the
committed Tileset (generator source in `scripts/gen_platformer_tiles.rs`
kept in sync). Digits use `c` (white) so they read against the
sky; the heart uses `a` (red) to match the cap/brick palette.
Division workaround: the obvious `stomp_count / 10` / `% 10` pair
miscompiles near state transitions — the built ROM cycles
Title → Playing → Title once per blink period with Playing
surviving exactly one frame. Swapping both calls for repeated
`while r >= 10 { r -= 10 }` helpers fixes it. Documented as a
new entry in `docs/future-work.md` so the next person reaching
for `/` or `%` knows to check there first.
Goldens, docs/platformer.gif, and the top-level + examples README
entries all refreshed in the same commit.
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).
__divide/__multiply stomping ZP_CURRENT_STATE
…plit The status bar now paints into NT row 1 (coin + score digits on the left, heart + lives digit on the right) using the `bg3` sub-palette that matches `sp0` pixel-for-pixel. A single OAM slot-0 anchor sprite sits over the coin tile; its one opaque pixel lines up with the coin's bottom row so sprite-0 hit fires at scanline 15, and a trailing `sprite_0_split(camera_x, 0)` latches the playfield scroll starting at scanline 16. NT rows 0-1 stay pinned while scanlines 16+ scroll with the camera. Score / lives updates are shadow-compared (`last_score`, `last_lives`) so the VRAM ring sees an entry only when the backing state actually changes — most frames append zero bytes. OAM footprint drops from 5 sprites per frame down to 1. Tile pipeline gains a 27th entry — a 7-transparent-row + 1-pixel anchor — so the sprite-0 hit lands on scanline 15 instead of scanline 8 (the latter would smear the HUD glyphs across the split). `gen_platformer_tiles.rs` is updated in lockstep. Ancillary changes: `bg3` retuned from `[yellow, orange, dk_orange]` to `[red, orange, white]` (matching `sp0`); `palette_map` row 0 flips from bg0 to bg3; legend gains `o`, `h`, `0`, `3` so the initial map can preload the static HUD tiles and the committed nametable already reads "coin 00 ... heart 3" on frame 0. `docs/future-work.md` loses the sprite-0 HUD follow-up section (this commit lands it). Goldens + gif refreshed.
The previous commit parked the sprite-0 hit anchor on top of the visible HUD coin at NT col 2, row 1 — the hit therefore fired at dot 19 of scanline 15 (the HUD's last scanline) and jsnes's PPU model applied the scroll change to the rest of that same scanline, smearing the bottom row of every HUD glyph as `camera_x` drifted. The score "0"/"2" digit, the heart, and the lives "3" each flickered a handful of different pixel patterns per frame as the scroll shifted. An in-harness check for unique HUD states across the 180-frame window saw ~20-40 distinct states. Fix: move both anchors off the HUD row entirely. - `TILE_SPRITE0_ANCHOR` still has its single opaque pixel at row 7, col 3, but now draws at OAM `(248, 8)` so the pixel lands at screen `(251, 16)` — the first scanline of the playfield. - New `TILE_BG_ANCHOR` (tile 28) mirrors it with a single opaque pixel at row 0, col 3; the map pre-paints it at NT `(col 31, row 2)` via the new `a` legend entry. Its one opaque pixel lands at the same `(251, 16)`, so the PPU's sprite-0 hit fires there instead. - An explicit `scroll(0, 0)` right before `sprite_0_split` defensively re-latches scroll to zero in case jsnes has carried stale `$2005` state over from the previous frame. With the hit on scanline 16, `$2005` writes in HBLANK of scanline 16 (or thereabouts) only affect scanline 17 onward; rows 8-15 all render at scroll=0 and the HUD glyphs stay pixel-stable. The unique-state count across the 180-frame harness drops from ~40 to 4 — and those 4 correspond to the legitimate score / lives transitions (initial title, post- stomp-1, post-stomp-2, etc.), not per-frame jitter. Column 31 and OAM x=248 both sit inside jsnes's right-edge overscan so the anchor pixels are invisible in the committed golden. Goldens + gif refreshed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two commits on this branch:
platformer: sprite-based status bar with score + lives (b83b944) — upgrades the flagship platformer example's "live coin count" into a proper HUD pinned to the top of the viewport. Left side: coin icon + two-digit stomp tally. Right side: red heart icon + single-digit lives counter. Both carry through the GameOver screen without jumping. Cross-statelivesstarts at 3, decrements inGameOver.on_enter, and when the last heart is spent the retry loop bounces to Title (which refills the bar) instead of Playing. Adds 10 digit glyphs + a heart tile to the committed Tileset and keepsscripts/gen_platformer_tiles.rsin sync.runtime: stop__divide/__multiplyfrom stompingZP_CURRENT_STATE(91f82c1) — fixes a latent miscompile surfaced by (1).ZP_DIV_REMAINDER,ZP_MUL_RESULT_HI, andZP_CURRENT_STATEall lived at$03. Any multi-state program doingu8 / 10inon_framehad its current-state ID zeroed on the way out of the divide routine; the next main-loop dispatch matched state 0 and handed control toTitleinstead 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, then the cycle repeated. Rewrote both runtime routines to keep their running accumulators in register A instead of$03. New contracts:__divide:A = remainder,$04 = quotient(wasA = quotient,$03 = remainder).__multiply:A = product, no$03writes at all.IrOp::Divlowering picks up anLDA $04;IrOp::Moddrops itsLDA $03. Two regression tests walk the emitted instruction stream and fail on anyZeroPage($03)access so a future refactor can't silently reintroduce the alias. Three ROMs rebuilt (bitwise_ops, mmc1_banked, platformer — the only examples that use/or*) and the platformer audio golden re-baselined; pixel goldens and the gif stay byte-identical.Notes
stomp_count / 10and% 10after commit (2) lands — no more subtraction-loop workaround.docs/future-work.mdgains a section describing the planned sprite-0 hit upgrade for the platformer HUD (carry-over task: move the status bar to a nametable row + sprite-0 split to free 5 OAM slots and skip the per-frame redraw).Test plan
cargo fmt --checkcargo clippy --all-targets -- -D warningscargo test --all-targets(653 passing, including the two new regression tests)scripts/pre-commit(ROM reproducibility diff + gif freshness for platformer)node tests/emulator/run_examples.mjs(50/50 ROMs match goldens)docs/platformer.gifand the updated golden PNGhttps://claude.ai/code/session_01GLkhwjyd1YZ84fVvyqy8qR