diff --git a/README.md b/README.md index 925accd..463a7ee 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ start Main | [`sfx_pitch_envelope.ne`](examples/sfx_pitch_envelope.ne) | Per-frame pulse `pitch:` arrays — the audio tick walks the pitch envelope in lockstep with the volume envelope and writes `$4002` on every NMI for a frequency-sweeping siren tone | | [`metasprite_demo.ne`](examples/metasprite_demo.ne) | `metasprite Hero { sprite: ..., dx: [...], dy: [...], frame: [...] }` declarative multi-tile groups — `draw Hero at: (x, y)` expands to one OAM slot per tile so 16×16 sprites stop needing four hand-written `draw` statements | | [`sprite_flicker_demo.ne`](examples/sprite_flicker_demo.ne) | `cycle_sprites` — rotates the OAM DMA start offset one slot per frame so scenes with more than 8 sprites on a scanline drop a *different* one each frame. Turns the NES's permanent sprite-dropout hardware symptom into visible flicker, which the eye reconstructs from adjacent frames. Pairs with the compile-time `W0109` warning and the debug-mode `debug.sprite_overflow()` / `debug.sprite_overflow_count()` telemetry for a three-layer defense against the 8-sprites-per-scanline limit. | -| [`platformer.ne`](examples/platformer.ne) | **End-to-end side-scroller** — custom CHR tileset, full background nametable, metasprite player with gravity/jump physics, wrap-around scrolling, stomp-or-die enemy collisions, live stomp-count HUD, pickup coins, user-declared SFX + music, and a Title → Playing → GameOver state machine with a proximity-based autopilot so the headless harness demonstrates the full gameplay loop (stomp, stomp, die, retry) inside six seconds | +| [`platformer.ne`](examples/platformer.ne) | **End-to-end side-scroller** — custom CHR tileset, full background nametable, metasprite player with gravity/jump physics, wrap-around scrolling, stomp-or-die enemy collisions, a sprite-based status bar pinned above the scrolling playfield (coin icon + 2-digit score on the left, heart icon + lives counter on the right) that carries through into the death screen, cross-state life tracking that cycles GameOver → Playing while hearts last and back to Title when the last heart is spent, pickup coins, user-declared SFX + music, and a Title → Playing → GameOver state machine with a proximity-based autopilot so the headless harness demonstrates the full gameplay loop (stomp, stomp, die, retry) inside six seconds | | [`war.ne`](examples/war.ne) | **Production-quality card game** — a complete port of War split across `examples/war/*.ne`: title screen with a 0/1/2-player menu, animated deal, sliding face-up cards, deck-count HUD, "WAR!" tie-break with buried cards, victory screen with a fanfare, and a brisk 4/4 march on pulse 2. Pulls in nearly every NEScript subsystem (custom 88-tile sheet, felt nametable, 8-bit LFSR PRNG, queue-based decks, phase machine inside `Playing`, multiple sfx + music tracks). Building it surfaced seven compiler bugs, all fixed on the same branch — see `git log` for the details. | | [`feature_canary.ne`](examples/feature_canary.ne) | **Regression canary** — a minimal program that paints a green universal backdrop at frame 180 when every memory-affecting construct round-trips correctly, and flips to red if any check fails. The committed golden is green; any silent-drop regression (state-locals, uninit struct field writes, u16 high byte, array elements, `slow` placement, function return values) turns it red. Built after PR #31 to close the "goldens capture whatever happens, not what should happen" failure mode that let the state-local bug survive for a year. | | [`sha256.ne`](examples/sha256.ne) | **Interactive SHA-256 hasher** — an on-screen keyboard lets the player type up to 16 ASCII characters, and pressing ↵ runs a full FIPS 180-4 SHA-256 compression on the NES (64 rounds + 48-entry message-schedule expansion, all written in NEScript with inline-asm 32-bit primitives). The 64-character hex digest renders as sprites across eight 8-character rows at the bottom of the screen. Splits across `examples/sha256/*.ne` with a phased driver that runs four iterations per frame so the full hash finishes in well under a second; the jsnes golden captures `SHA-256("NES")` = `AE9145DB5CABC41FE34B54E34AF8881F462362EA20FD8F861B26532FFBB84E0D`. | diff --git a/docs/future-work.md b/docs/future-work.md index d48b25a..f2473e1 100644 --- a/docs/future-work.md +++ b/docs/future-work.md @@ -260,6 +260,42 @@ missing: call — would shave a few bytes more on programs with many deep handlers. +### `u8 / 10` and `u8 % 10` miscompile near state transitions + +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 HUD's `draw_hud` workaround sits in `examples/platformer.ne` +with a comment pointing here. Two guesses at the root cause: + +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. + +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. + ### Cross-block temp live-range analysis The slot recycler is function-local per-block. Temps that flow across block diff --git a/docs/platformer.gif b/docs/platformer.gif index 2ed2b86..dc1b9bd 100644 Binary files a/docs/platformer.gif and b/docs/platformer.gif differ diff --git a/examples/README.md b/examples/README.md index 0fd74ad..f9eca2e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -35,7 +35,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX] | `sfx_pitch_envelope.ne` | varying-pitch pulse SFX | A 16-frame frequency sweep written as a per-frame `pitch:` array on a Pulse-1 sfx. The compiler emits a separate `__sfx_pitch_` blob and gates the audio tick's pitch update path on the `__sfx_pitch_used` marker, so programs that stick to the scalar `pitch:` form still get byte-identical ROM output. | | `metasprite_demo.ne` | declarative multi-tile sprites | A 16×16 hero sprite split into a `metasprite Hero { sprite: Hero16, dx: [...], dy: [...], frame: [...] }` declaration. `draw Hero at: (px, py)` then expands to one `DrawSprite` op per tile in the IR lowering, each with its dx/dy added to the user's anchor point and the frame offset by the underlying sprite's base tile. The codegen needs no metasprite-specific support — it sees N regular draws and the OAM cursor allocator handles the slots. | | `nested_structs.ne` | nested struct fields, array struct fields, chained literals | Two `Hero` instances each carry a `Vec2` position and a `u8[4]` inventory. Exercises `hero.pos.x` chained access, `hero.inv[i]` array-field access, and chained struct-literal initializers (`Hero { pos: Vec2 { x: ..., y: ... }, inv: [...] }`). | -| `platformer.ne` | **every subsystem** | End-to-end side-scrolling demo: custom CHR tileset, full 32×30 nametable with per-region attribute palettes, 2×2 metasprite hero with gravity/jump physics, wrap-around horizontal scrolling, stomp-or-die enemy collisions with a live stomp-count HUD, coin pickups, user-declared SFX + music, and a Title → Playing → GameOver state machine with a proximity-based autopilot so the headless harness cycles through stomp, stomp, die, and retry inside six seconds. Regenerate the tile art with `cargo run --bin gen_platformer_tiles`. | +| `platformer.ne` | **every subsystem** | End-to-end side-scrolling demo: custom CHR tileset, full 32×30 nametable with per-region attribute palettes, 2×2 metasprite hero with gravity/jump physics, wrap-around horizontal scrolling, stomp-or-die enemy collisions, coin pickups, a sprite-based status bar pinned to the top of the viewport (coin icon + 2-digit score on the left, heart icon + lives counter on the right), cross-state life tracking that sends GameOver back to Title when the last heart is spent, user-declared SFX + music, and a Title → Playing → GameOver state machine with a proximity-based autopilot so the headless harness cycles through stomp, stomp, die, and retry inside six seconds. Regenerate the tile art with `cargo run --bin gen_platformer_tiles`. | | `sprite_flicker_demo.ne` | `cycle_sprites`, 8-per-scanline hardware limit | Twelve sprites packed onto the same 4-pixel band — two more than the NES's 8-sprites-per-scanline hardware budget. The W0109 analyzer warning fires at compile time, and a `cycle_sprites` call at the end of `on frame` rotates the OAM DMA offset one slot per frame so the PPU drops a *different* sprite each frame. The permanent-dropout failure mode becomes visible flicker, which the eye reconstructs across frames. The classic NES technique used by Gradius, Battletoads, and every shmup that ever existed. | | `war.ne` | **production-quality card game**, multi-file source layout | A complete port of the card game War, split across `examples/war/*.ne` files and pulled in via `include` directives. Title screen with a 0/1/2-player menu (cursor sprite, blinking PRESS A, brisk 4/4 march on pulse 2), a 50-frame deal animation, a deep `Playing` state with an inner phase machine (`P_WAIT_A`/`P_FLY_A`/.../`P_WAR_BANNER`/`P_WAR_BURY`/`P_CHECK`), card-conserving queue-based decks built on a 200-iteration random-swap shuffle, a "WAR!" tie-break that buries 3+1 face-down cards per player and plays a noise-channel thump per bury, and a victory screen with the builtin fanfare. The first NEScript example to use a top-level file as a thin shell that `include`s ~12 component files; building it surfaced seven compiler bugs across the analyzer, IR lowerer, and codegen that were all fixed on the same branch (see `git log` for details). | | `pong.ne` | **production-quality Pong**, powerups, multi-ball, multi-file | A complete Pong game split across `examples/pong/*.ne`. CPU VS CPU / 1 PLAYER / 2 PLAYERS title menu with brisk pulse-2 title march and autopilot, smooth ball physics with wall and paddle bouncing, CPU AI that tracks the ball with a reaction lag and dead zone, three powerup types (LONG paddle for 5 hits, FAST ball on next hit, MULTI-ball on next hit spawning 3 balls) that bounce around the field and are caught by paddle AABB overlap, multi-ball scoring (each ball scores a point, round continues until last ball exits), inner phase machine (`P_SERVE`/`P_PLAY`/`P_POINT`), and a "PLAYER N WINS" victory screen with the builtin fanfare. First-to-7 wins. | diff --git a/examples/platformer.ne b/examples/platformer.ne index 687cd23..fa7ff5d 100644 --- a/examples/platformer.ne +++ b/examples/platformer.ne @@ -4,12 +4,17 @@ // with per-region attribute palettes, a multi-tile metasprite // player with gravity/jump physics, wrap-around horizontal // scrolling, moving enemies with stomp-or-die contact logic, a -// coin HUD that tracks live score, collectible coins, user-declared -// SFX envelopes, a user-declared music track, and a -// title → playing → game-over state machine driven by both -// controller input and an autopilot so the jsnes golden harness -// (which runs headless with no simulated buttons) still captures -// the full gameplay loop inside the first ~6 seconds of demo. +// sprite-based status bar at the top of the viewport (coin icon + +// two-digit score on the left, heart icon + lives counter on the +// right) that rides along through the death screen, cross-state +// life tracking that cycles GameOver → Playing while hearts last +// and bounces back to Title when the last heart is spent, +// collectible coins, user-declared SFX envelopes, a user-declared +// music track, and a title → playing → game-over state machine +// driven by both controller input and an autopilot so the jsnes +// golden harness (which runs headless with no simulated buttons) +// still captures the full gameplay loop inside the first ~6 +// seconds of demo. // // Controls (when played by a human): // D-pad left/right — walk @@ -212,6 +217,105 @@ sprite Tileset { "........", "........", "........", + "........", + // tile 16: Digit 0 + "........", + "..cccc..", + "..c..c..", + "..c..c..", + "..c..c..", + "..c..c..", + "..cccc..", + "........", + // tile 17: Digit 1 + "........", + "...cc...", + "..ccc...", + "...cc...", + "...cc...", + "...cc...", + "..cccc..", + "........", + // tile 18: Digit 2 + "........", + "..cccc..", + ".....c..", + "....cc..", + "...cc...", + "..cc....", + "..cccc..", + "........", + // tile 19: Digit 3 + "........", + "..cccc..", + ".....c..", + "...ccc..", + ".....c..", + ".....c..", + "..cccc..", + "........", + // tile 20: Digit 4 + "........", + "..c..c..", + "..c..c..", + "..cccc..", + ".....c..", + ".....c..", + ".....c..", + "........", + // tile 21: Digit 5 + "........", + "..cccc..", + "..c.....", + "..cccc..", + ".....c..", + "..c..c..", + "..cccc..", + "........", + // tile 22: Digit 6 + "........", + "..cccc..", + "..c.....", + "..cccc..", + "..c..c..", + "..c..c..", + "..cccc..", + "........", + // tile 23: Digit 7 + "........", + "..cccc..", + ".....c..", + "....c...", + "...c....", + "..c.....", + "..c.....", + "........", + // tile 24: Digit 8 + "........", + "..cccc..", + "..c..c..", + "..cccc..", + "..c..c..", + "..c..c..", + "..cccc..", + "........", + // tile 25: Digit 9 + "........", + "..cccc..", + "..c..c..", + "..cccc..", + ".....c..", + ".....c..", + "..cccc..", + "........", + // tile 26: Heart + "........", + ".aa..aa.", + "aaaaaaaa", + "aaaaaaaa", + ".aaaaaa.", + "..aaaa..", + "...aa...", "........" ] } @@ -223,6 +327,10 @@ const TILE_PLAYER_BL: u8 = 3 const TILE_PLAYER_BR: u8 = 4 const TILE_ENEMY: u8 = 5 const TILE_COIN: u8 = 6 +// HUD glyphs (sprite-only — not referenced by the nametable). The +// digit tiles are contiguous so `TILE_DIGIT_0 + n` picks digit `n`. +const TILE_DIGIT_0: u8 = 16 +const TILE_HEART: u8 = 26 // ── Background ────────────────────────────────────────────── // The 32×30 nametable is authored as ASCII art with a `legend` @@ -378,6 +486,51 @@ const AUTOPILOT_JUMPS: u8 = 2 // Cross-state scratch var frame_tick: u8 = 0 // free-running frame counter var stomp_count: u8 = 0 // successful enemy stomps this life +var lives: u8 = 3 // lives remaining; HUD heart readout + +// ── HUD helpers ───────────────────────────────────────────── +// Paint the four-sprite status bar at the very top of the screen: +// a coin icon followed by a two-digit stomp tally on the left, and +// a heart icon followed by a single-digit lives counter on the +// right. Everything is drawn as OAM sprites (never the nametable) +// 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) + + // Lives side (right): heart + digit. + draw Tileset at: (208, 16) frame: TILE_HEART + draw Tileset at: (224, 16) frame: TILE_DIGIT_0 + lives +} // ── Helper functions ──────────────────────────────────────── @@ -486,6 +639,11 @@ state Title { on enter { blink = 0 frame_tick = 0 + // Fresh run — restore the full life bar so the HUD reads 3 + // when Playing kicks in and the GameOver loop doesn't trap + // the game in a zero-lives state forever. + lives = 3 + stomp_count = 0 start_music Theme } @@ -628,21 +786,10 @@ state Playing { draw Tileset at: (c1_sx, cy) frame: TILE_COIN draw Tileset at: (c2_sx, COIN_Y) frame: TILE_COIN - // Live HUD: draw one coin per stomp in the top-left. The - // background palette-1 region covers this strip so the - // sprite coins read correctly against the brick band. - if stomp_count >= 1 { - draw Tileset at: (16, 24) frame: TILE_COIN - } - if stomp_count >= 2 { - draw Tileset at: (28, 24) frame: TILE_COIN - } - if stomp_count >= 3 { - draw Tileset at: (40, 24) frame: TILE_COIN - } - if stomp_count >= 4 { - draw Tileset at: (52, 24) frame: TILE_COIN - } + // Status bar (score tally on the left, lives on the right). + // Drawn as sprites so it stays pinned while the nametable + // scrolls under it. + draw_hud() // Player is only drawn while alive — on the dying frame // we show the scene without the hero on top of the enemy @@ -670,6 +817,12 @@ state GameOver { on enter { linger = 0 + // Burn a life on entry so the HUD's heart counter ticks + // down visibly each death. Guard against underflow in case + // something transitions here with lives already at zero. + if lives > 0 { + lives -= 1 + } stop_music } @@ -686,26 +839,33 @@ state GameOver { draw Tileset at: (128, 112) frame: TILE_ENEMY draw Tileset at: (144, 112) frame: TILE_ENEMY - if stomp_count >= 1 { - draw Tileset at: (96, 128) frame: TILE_COIN - } - if stomp_count >= 2 { - draw Tileset at: (112, 128) frame: TILE_COIN - } - if stomp_count >= 3 { - draw Tileset at: (128, 128) frame: TILE_COIN - } - if stomp_count >= 4 { - draw Tileset at: (144, 128) frame: TILE_COIN - } + // Carry the status bar through the death screen so the + // 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: (208, 16) frame: TILE_HEART + draw Tileset at: (224, 16) frame: TILE_DIGIT_0 + lives // Auto-retry after ~1 s so the headless harness sees the - // full loop. A human can hit Start to retry immediately. + // full loop. When the last life is spent, bounce back to + // the Title screen instead (Title's `on enter` refills the + // heart bar for the next run). A human can hit Start to + // skip the wait. if linger >= 60 { - transition Playing + if lives == 0 { + transition Title + } else { + transition Playing + } } if button.start { - transition Playing + if lives == 0 { + transition Title + } else { + transition Playing + } } } } diff --git a/examples/platformer.nes b/examples/platformer.nes index ababf8a..271a179 100644 Binary files a/examples/platformer.nes and b/examples/platformer.nes differ diff --git a/scripts/gen_platformer_tiles.rs b/scripts/gen_platformer_tiles.rs index 9df6e02..6eaaa8a 100644 --- a/scripts/gen_platformer_tiles.rs +++ b/scripts/gen_platformer_tiles.rs @@ -226,6 +226,145 @@ cccccccc", ........ ........ ........ +........", + }, + // ── HUD glyphs (sprite-only; palette sp0: a=red, c=white) ── + // Each digit is a 4-wide outline centred in an 8×8 cell and + // drawn in white ('c') so it reads crisply over the sky + // backdrop at the top of the playfield. The compiler treats + // '.' as transparent for sprites, so the sky shows through. + Tile { + name: "Digit 0", + art: "\ +........ +..cccc.. +..c..c.. +..c..c.. +..c..c.. +..c..c.. +..cccc.. +........", + }, + Tile { + name: "Digit 1", + art: "\ +........ +...cc... +..ccc... +...cc... +...cc... +...cc... +..cccc.. +........", + }, + Tile { + name: "Digit 2", + art: "\ +........ +..cccc.. +.....c.. +....cc.. +...cc... +..cc.... +..cccc.. +........", + }, + Tile { + name: "Digit 3", + art: "\ +........ +..cccc.. +.....c.. +...ccc.. +.....c.. +.....c.. +..cccc.. +........", + }, + Tile { + name: "Digit 4", + art: "\ +........ +..c..c.. +..c..c.. +..cccc.. +.....c.. +.....c.. +.....c.. +........", + }, + Tile { + name: "Digit 5", + art: "\ +........ +..cccc.. +..c..... +..cccc.. +.....c.. +..c..c.. +..cccc.. +........", + }, + Tile { + name: "Digit 6", + art: "\ +........ +..cccc.. +..c..... +..cccc.. +..c..c.. +..c..c.. +..cccc.. +........", + }, + Tile { + name: "Digit 7", + art: "\ +........ +..cccc.. +.....c.. +....c... +...c.... +..c..... +..c..... +........", + }, + Tile { + name: "Digit 8", + art: "\ +........ +..cccc.. +..c..c.. +..cccc.. +..c..c.. +..c..c.. +..cccc.. +........", + }, + Tile { + name: "Digit 9", + art: "\ +........ +..cccc.. +..c..c.. +..cccc.. +.....c.. +.....c.. +..cccc.. +........", + }, + // Small red heart for the lives readout. Uses 'a' (red) so the + // shape pops against the sky and matches the cap/brick red. + Tile { + name: "Heart", + art: "\ +........ +.aa..aa. +aaaaaaaa +aaaaaaaa +.aaaaaa. +..aaaa.. +...aa... ........", }, ]; diff --git a/tests/emulator/goldens/platformer.audio.hash b/tests/emulator/goldens/platformer.audio.hash index 274dce7..480e5e7 100644 --- a/tests/emulator/goldens/platformer.audio.hash +++ b/tests/emulator/goldens/platformer.audio.hash @@ -1 +1 @@ -443d66c5 132084 +c5e41b59 132084 diff --git a/tests/emulator/goldens/platformer.png b/tests/emulator/goldens/platformer.png index 3db239f..35dd658 100644 Binary files a/tests/emulator/goldens/platformer.png and b/tests/emulator/goldens/platformer.png differ