Skip to content

platformer HUD + fix __divide/__multiply stomping ZP_CURRENT_STATE#38

Merged
imjasonh merged 4 commits intomainfrom
claude/add-platformer-hud-V5Xi8
Apr 20, 2026
Merged

platformer HUD + fix __divide/__multiply stomping ZP_CURRENT_STATE#38
imjasonh merged 4 commits intomainfrom
claude/add-platformer-hud-V5Xi8

Conversation

@imjasonh
Copy link
Copy Markdown
Owner

@imjasonh imjasonh commented Apr 20, 2026

Summary

Two commits on this branch:

  1. 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-state lives starts at 3, decrements in GameOver.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 keeps scripts/gen_platformer_tiles.rs in sync.

  2. runtime: stop __divide / __multiply from stomping ZP_CURRENT_STATE (91f82c1) — fixes a latent miscompile surfaced by (1). ZP_DIV_REMAINDER, ZP_MUL_RESULT_HI, and ZP_CURRENT_STATE all lived at $03. Any multi-state program doing u8 / 10 in on_frame had its current-state ID zeroed on the way out of the divide routine; the next main-loop dispatch 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, 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 (was A = quotient, $03 = remainder).
    • __multiply: A = product, no $03 writes at all.

    IrOp::Div lowering picks up an LDA $04; IrOp::Mod drops its LDA $03. Two regression tests walk the emitted instruction stream and fail on any ZeroPage($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

  • The platformer HUD uses native stomp_count / 10 and % 10 after commit (2) lands — no more subtraction-loop workaround.
  • docs/future-work.md gains 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).
  • CI churn from earlier in the branch was a GitHub Actions toolchain-download outage unrelated to these changes.

Test plan

  • cargo fmt --check
  • cargo clippy --all-targets -- -D warnings
  • cargo 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)
  • Visual spot-check of docs/platformer.gif and the updated golden PNG

https://claude.ai/code/session_01GLkhwjyd1YZ84fVvyqy8qR

claude added 2 commits April 20, 2026 13:59
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).
@imjasonh imjasonh changed the title Add sprite-based HUD with lives tracking and two-digit score platformer HUD + fix __divide/__multiply stomping ZP_CURRENT_STATE Apr 20, 2026
claude added 2 commits April 20, 2026 17:21
…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.
@imjasonh imjasonh merged commit 1f0feb9 into main Apr 20, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants