diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11a14be..9c0ab23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,6 +146,21 @@ jobs: - name: Diff each ROM's framebuffer against its golden working-directory: tests/emulator run: node run_examples.mjs + - name: Verify docs/platformer.gif is up to date + # The README embeds docs/platformer.gif as the project demo. + # gifenc + jsnes are deterministic, so rebuilding the gif from + # the current compiler should byte-match the committed copy. + # If it doesn't, the contributor changed the compiler / source + # / harness in a way that affects the observable gameplay of + # platformer.ne but forgot to regenerate the gif — the fix is + # the exact command printed in the error. + working-directory: tests/emulator + run: | + node record_gif.mjs platformer 360 2 /tmp/platformer.gif + if ! cmp -s ../../docs/platformer.gif /tmp/platformer.gif; then + echo "::error file=docs/platformer.gif::committed docs/platformer.gif is stale; rerun \`node tests/emulator/record_gif.mjs platformer 360 2 docs/platformer.gif\` and commit the new gif" + exit 1 + fi - name: Upload actual + diff PNGs on failure if: failure() uses: actions/upload-artifact@v4 diff --git a/CLAUDE.md b/CLAUDE.md index 4df3038..c36c8d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,22 @@ re-derive the project conventions from scratch. tmp path and fails if the committed version differs, pointing at the exact `cargo run -- build examples/.ne` to run. The pre-commit hook under `scripts/pre-commit` catches this locally. +- **`docs/platformer.gif` is committed** and embedded in the + top-level README as the project demo. `gifenc` + `jsnes` are + deterministic, so the gif's bytes are a function of the compiler, + the runtime, the harness, and `examples/platformer.ne`. Any change + to those that affects the first ~6 seconds of observable platformer + gameplay must be followed by regenerating the gif: + + ```bash + node tests/emulator/record_gif.mjs platformer 360 2 docs/platformer.gif + ``` + + and committing it in the same change. CI's `emulator` job renders + a fresh gif and fails if the committed one doesn't byte-match. The + pre-commit hook rebuilds the gif when `platformer.ne`, `platformer.nes`, + `record_gif.mjs`, or `harness.html` is staged (and `tests/emulator/node_modules` + is installed). - `docs/future-work.md` lists the remaining gaps. If you implement something from that file, update the doc in the same PR. diff --git a/README.md b/README.md index 1efb5f3..2abee78 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ start Main | [`structs_enums_for.ne`](examples/structs_enums_for.ne) | Structs, enums, `for` loops, struct literals | | [`inline_asm_demo.ne`](examples/inline_asm_demo.ne) | Inline asm with `{var}` substitution, `poke`/`peek` | | [`audio_demo.ne`](examples/audio_demo.ne) | Audio subsystem: user `sfx`/`music` blocks, builtin effects, `play`/`start_music`/`stop_music` | -| [`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, enemies, coins, user-declared SFX + music, and a Title → Playing state machine with auto-play for the headless harness | +| [`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 | ## Compiler Commands diff --git a/docs/platformer.gif b/docs/platformer.gif index fb2bff5..2ed2b86 100644 Binary files a/docs/platformer.gif and b/docs/platformer.gif differ diff --git a/examples/README.md b/examples/README.md index 27adc2a..6317498 100644 --- a/examples/README.md +++ b/examples/README.md @@ -28,7 +28,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX] | `mmc1_banked.ne` | MMC1, banks, multiply | Banked mapper with software multiply | | `palette_and_background.ne` | palette, background, set_palette, load_background | Reset-time initial load plus vblank-safe runtime swaps | | `friendly_assets.ne` | named colours, grouped palette, pixel art, tilemap+legend, palette_map, scalar sfx pitch, note-name music | Exercises every "friendlier" asset syntax at once — the `palette` uses `bg0..sp3` + a shared `universal:`, the sprite is authored as ASCII pixel art, the background uses a `legend { ... } + map:` tilemap with a `palette_map:` for attributes, the sfx uses a scalar `pitch:` + `envelope:` alias, and the music uses note names (`C4, E4 40, rest 10`) with a `tempo:` default. | -| `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, enemies, coin pickups, user-declared SFX + music, and a Title → Playing state machine with an autopilot so the headless harness still hits interesting gameplay at frame 180. 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 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`. | ## Emulator Controls diff --git a/examples/bitwise_ops.nes b/examples/bitwise_ops.nes index 94e82bb..61032e6 100644 Binary files a/examples/bitwise_ops.nes and b/examples/bitwise_ops.nes differ diff --git a/examples/logic_ops.nes b/examples/logic_ops.nes index 511a9ef..1f2ebe9 100644 Binary files a/examples/logic_ops.nes and b/examples/logic_ops.nes differ diff --git a/examples/match_demo.nes b/examples/match_demo.nes index 772ecae..65f247f 100644 Binary files a/examples/match_demo.nes and b/examples/match_demo.nes differ diff --git a/examples/mmc3_per_state_split.nes b/examples/mmc3_per_state_split.nes index f80e039..857af30 100644 Binary files a/examples/mmc3_per_state_split.nes and b/examples/mmc3_per_state_split.nes differ diff --git a/examples/platformer.ne b/examples/platformer.ne index 1277c83..0eaf76c 100644 --- a/examples/platformer.ne +++ b/examples/platformer.ne @@ -3,12 +3,13 @@ // one program: custom CHR tiles, a full 32×30 background nametable // with per-region attribute palettes, a multi-tile metasprite // player with gravity/jump physics, wrap-around horizontal -// scrolling, moving enemies, collectible coins, a user-declared -// SFX envelope, 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 an -// interesting frame-180 composition. +// 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. // // Controls (when played by a human): // D-pad left/right — walk @@ -358,6 +359,12 @@ const ENEMY2_WORLD_X: u8 = 200 const COIN1_WORLD_X: u8 = 50 const COIN2_WORLD_X: u8 = 150 +// How many auto-jumps the autopilot will pre-queue per life. Two +// is exactly enough to stomp enemy 1 and enemy 2 once each; after +// that the autopilot stops assisting so the headless harness gets +// to see the player walk into the next enemy, die, and retry. +const AUTOPILOT_JUMPS: u8 = 2 + // ── Game state ────────────────────────────────────────────── // Player physics @@ -369,9 +376,13 @@ var fall_vy: u8 = 0 // gravity accumulator // World/camera var camera_x: u8 = 0 var frame_tick: u8 = 0 // free-running frame counter -var jump_timer: u8 = 0 // autopilot: jump every N frames var anim_tick: u8 = 0 // visual animation phase +// Gameplay +var alive: u8 = 1 // 0 = dying/dead, 1 = playable +var stomp_count: u8 = 0 // successful enemy stomps this life +var auto_jumps: u8 = 0 // proximity pre-jumps used this life + // ── Helper functions ──────────────────────────────────────── // Try to start a jump. Only valid when the player is on the @@ -423,6 +434,54 @@ fun draw_player() { draw Tileset at: (PLAYER_SCREEN_X + 8, player_y + 8) frame: TILE_PLAYER_BR } +// Resolve contact with an enemy whose screen X is `e_sx`. Mutates +// the player's physics / alive flag directly and returns 1 on a +// successful stomp, 0 on no contact or a fatal hit. The caller +// checks `alive` after invocation to decide whether to keep +// running the frame. +// +// The player's 16×16 hitbox is at ([80, 96), [player_y, player_y+16)). +// Each enemy's 8×8 hitbox is at ([e_sx, e_sx+8), [168, 176)). Those +// overlap horizontally when e_sx ∈ (72, 96) and overlap vertically +// when player_y ∈ (152, 176). +// +// Outcomes: +// - No overlap → no-op +// - Overlap while rising (rise_count > 0) → graceful +// pass-through. rise_count > 0 only happens right after a +// successful stomp bounce (try_jump() clears rise_count by +// the end of the same frame) or on the stomp's own upward +// leg, which we don't want to retrigger. +// - Overlap while falling, feet near enemy head → STOMP: bounce +// up, increment `stomp_count`, play Boing +// - Any other overlap (walking, on ground) → DEATH: set +// `alive = 0` and play the builtin `hit` sfx +fun resolve_enemy_hit(e_sx: u8) -> u8 { + if e_sx > 72 { + if e_sx < 96 { + if player_y > 152 { + if player_y < 176 { + // Real contact. Stomp if falling; otherwise, if + // we're not in the post-bounce grace window, + // die. + if fall_vy > 0 { + rise_count = 6 + fall_vy = 0 + stomp_count += 1 + play Boing + return 1 + } + if rise_count == 0 { + alive = 0 + play hit + } + } + } + } + } + return 0 +} + // ── States ────────────────────────────────────────────────── state Title { @@ -468,8 +527,11 @@ state Playing { fall_vy = 0 camera_x = 0 frame_tick = 0 - jump_timer = 0 anim_tick = 0 + alive = 1 + stomp_count = 0 + auto_jumps = 0 + start_music Theme } on frame { @@ -490,31 +552,40 @@ state Playing { camera_x -= 1 } - // ── Autopilot ──────────────────────────────────────── - // The headless golden harness never presses buttons, so - // we advance the camera and periodically hop all by - // ourselves. A human holding right still accelerates - // forward — the two effects compose. + // Always advance camera one px per frame (baseline scroll). camera_x += 1 - jump_timer += 1 - if jump_timer >= 40 { - jump_timer = 0 - try_jump() - } - // ── Physics ────────────────────────────────────────── - step_physics() - - // ── Collisions ─────────────────────────────────────── - // Coin pickup when the player's screen X is within ±8 of - // a coin's screen position and the coin's Y range overlaps - // the player. Uses u8 subtraction so "screen X" of a - // world entity is just (world_x - camera_x). + // ── Compute screen positions of world entities ────── var e1_sx: u8 = ENEMY1_WORLD_X - camera_x var e2_sx: u8 = ENEMY2_WORLD_X - camera_x var c1_sx: u8 = COIN1_WORLD_X - camera_x var c2_sx: u8 = COIN2_WORLD_X - camera_x + // ── Proximity autopilot ───────────────────────────── + // Pre-jump when an enemy is 19 px ahead. The fall phase of + // a JUMP_RISE=12, GRAVITY_CAP=4 jump lands the player's + // feet at enemy head height ~20 frames later, by which + // point the autopilot camera has scrolled the enemy under + // the player. Limited to AUTOPILOT_JUMPS hops per life so + // the third enemy encounter becomes a scripted death, + // giving the golden harness a visible game-over sequence. + if auto_jumps < AUTOPILOT_JUMPS { + if on_ground == 1 { + if e1_sx == 99 { + try_jump() + auto_jumps += 1 + } + if e2_sx == 99 { + try_jump() + auto_jumps += 1 + } + } + } + + // ── Physics ────────────────────────────────────────── + step_physics() + + // ── Coin pickups ───────────────────────────────────── // Coin collect: player occupies [80..96). A coin is "hit" // when its screen X is in [72..96) (8-px tolerance on each // side of the player's left edge). @@ -529,24 +600,17 @@ state Playing { } } - // Enemy stomp: if we're above an enemy horizontally *and* - // we're mid-fall (fall_vy > 0), count the stomp and - // bounce. Otherwise just ignore — the enemy slides past. - if e1_sx >= 72 and e1_sx < 96 { - if fall_vy > 0 { - if player_y + 16 >= ENEMY_Y { - play hit - rise_count = 6 - fall_vy = 0 - } - } - } + // ── Enemy collisions ──────────────────────────────── + // Both enemies get the full stomp-or-die check; the helper + // mutates player state directly. If `alive` flips to 0 + // we fall through the rest of the frame (drawing is + // guarded below) and transition to GameOver at the end. + resolve_enemy_hit(e1_sx) + resolve_enemy_hit(e2_sx) // ── Drawing ────────────────────────────────────────── - draw_player() - - // Enemies — walking on the grass line. anim_tick drives - // a small vertical bob so they visibly move on the golden. + // Enemies and coins are always drawn so the scene stays + // coherent even on the fatal-contact frame. var ey: u8 = ENEMY_Y if (anim_tick & 16) != 0 { ey = ENEMY_Y - 2 @@ -554,9 +618,6 @@ state Playing { draw Tileset at: (e1_sx, ey) frame: TILE_ENEMY draw Tileset at: (e2_sx, ENEMY_Y) frame: TILE_ENEMY - // Coins — alternate two vertical positions to fake a - // spin via position change (OAM attr flip isn't wired up - // in the current runtime). var cy: u8 = COIN_Y if (anim_tick & 8) != 0 { cy = COIN_Y - 1 @@ -564,11 +625,85 @@ 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 + } + + // Player is only drawn while alive — on the dying frame + // we show the scene without the hero on top of the enemy + // that killed them, which reads as "player got got". + if alive == 1 { + draw_player() + } + // ── PPU scroll latch ───────────────────────────────── // Write the scroll at the very end of the frame so it's // the last $2005 pair before the implicit wait_frame / // NMI handshake, minimizing mid-frame artifacts. scroll(camera_x, 0) + + // Fatal contact this frame → kick the state machine over + // to GameOver on the next frame. + if alive == 0 { + transition GameOver + } + } +} + +state GameOver { + var linger: u8 = 0 + + on enter { + linger = 0 + stop_music + } + + on frame { + linger += 1 + + // "GAME OVER" marker: a row of enemy tiles across the + // middle of the screen, plus a live readout of how many + // stomps the player racked up before dying (drawn as + // coins right underneath). The death-screen backdrop is + // whatever Playing last scrolled to — we don't clear it. + draw Tileset at: (96, 112) frame: TILE_ENEMY + draw Tileset at: (112, 112) frame: TILE_ENEMY + 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 + } + + // Auto-retry after ~1 s so the headless harness sees the + // full loop. A human can hit Start to retry immediately. + if linger >= 60 { + transition Playing + } + if button.start { + transition Playing + } } } diff --git a/examples/platformer.nes b/examples/platformer.nes index 0ed5185..ce8b799 100644 Binary files a/examples/platformer.nes and b/examples/platformer.nes differ diff --git a/examples/two_player.nes b/examples/two_player.nes index 2611272..4e5c36e 100644 Binary files a/examples/two_player.nes and b/examples/two_player.nes differ diff --git a/scripts/pre-commit b/scripts/pre-commit index e9539d1..8bfb841 100755 --- a/scripts/pre-commit +++ b/scripts/pre-commit @@ -39,4 +39,26 @@ if [ $stale -ne 0 ]; then exit 1 fi +# Only rebuild docs/platformer.gif when platformer.nes or the gif +# recorder itself changed — the gif regeneration takes ~20s due to +# puppeteer's cold start, so we don't want to pay it on every commit. +# Skipped entirely if node_modules isn't installed in the emulator +# harness; the CI `emulator` job is the authoritative check. +changed_files=$(git diff --cached --name-only) +if echo "$changed_files" | grep -qE '^(examples/platformer\.(ne|nes)|tests/emulator/record_gif\.mjs|tests/emulator/harness\.html)$'; then + if [ -d tests/emulator/node_modules ]; then + echo " Rebuilding docs/platformer.gif (platformer.nes or recorder changed)..." + (cd tests/emulator && node record_gif.mjs platformer 360 2 /tmp/platformer-precheck.gif >/dev/null) + if ! cmp -s docs/platformer.gif /tmp/platformer-precheck.gif; then + echo " STALE: docs/platformer.gif" + echo " rerun: node tests/emulator/record_gif.mjs platformer 360 2 docs/platformer.gif" + rm -f /tmp/platformer-precheck.gif + exit 1 + fi + rm -f /tmp/platformer-precheck.gif + else + echo " (skipping gif freshness check — tests/emulator/node_modules not installed)" + fi +fi + echo "All pre-commit checks passed." diff --git a/src/optimizer/mod.rs b/src/optimizer/mod.rs index 9a1ad18..81219a6 100644 --- a/src/optimizer/mod.rs +++ b/src/optimizer/mod.rs @@ -218,13 +218,22 @@ fn strength_reduce_block(block: &mut IrBasicBlock) { /// destination temps are no longer referenced anywhere in the block. fn const_fold(program: &mut IrProgram) { for func in &mut program.functions { + // Pre-compute function-wide source-operand usage: a LoadImm's + // destination may be read by an op in a sibling block or + // consumed by a branch/jump terminator in another block, so + // the per-block DCE below can't decide liveness by looking + // at its own block alone. Cf. the `and` / `or` short-circuit + // lowering: the false path writes `LoadImm(result, 0)` but + // `result` is read by the merge block's branch, not in the + // false block itself. + let func_used = collect_used_temps(func); for block in &mut func.blocks { - const_fold_block(block); + const_fold_block(block, &func_used); } } } -fn const_fold_block(block: &mut IrBasicBlock) { +fn const_fold_block(block: &mut IrBasicBlock, func_used: &HashSet) { let mut constants: HashMap = HashMap::new(); // First pass: fold arithmetic / comparison ops into LoadImm where possible. @@ -349,12 +358,21 @@ fn const_fold_block(block: &mut IrBasicBlock) { } } - // Second pass: remove LoadImm ops whose dest temps are no longer referenced - // as source operands by anything else in the block (ops + terminator). - let used = collect_used_temps_in_block(block); + // Second pass: remove LoadImm ops whose dest temps are no longer + // referenced locally AND aren't referenced function-wide. The + // function-wide check is what makes this pass correct in the + // presence of control-flow merges — a LoadImm written in one + // block and consumed in another (for example the `and`/`or` + // short-circuit false path, whose `LoadImm(result, 0)` is only + // read by the downstream merge block's branch terminator) must + // not be dropped. The previous implementation only consulted + // block-local usage and silently dropped these cross-block + // LoadImms, leaving the zero-page result slot to carry whatever + // value the *previous* AND/OR had written into it. + let used_local = collect_used_temps_in_block(block); block.ops.retain(|op| { if let IrOp::LoadImm(t, _) = op { - used.contains(t) + used_local.contains(t) || func_used.contains(t) } else { true } @@ -397,7 +415,10 @@ fn collect_used_temps(func: &IrFunction) -> HashSet { used } -/// Collect temps used as source operands within a single block (ops + terminator). +/// Collect temps used as source operands within a single block +/// (ops + terminator). Used by the per-block `LoadImm` DCE so we +/// can cheaply find local uses before falling back on the +/// function-wide liveness set. fn collect_used_temps_in_block(block: &IrBasicBlock) -> HashSet { let mut used = HashSet::new(); collect_used_from_block(block, &mut used); diff --git a/src/optimizer/tests.rs b/src/optimizer/tests.rs index f868466..4492a68 100644 --- a/src/optimizer/tests.rs +++ b/src/optimizer/tests.rs @@ -436,3 +436,85 @@ fn inline_removes_trivial() { .any(|op| matches!(op, IrOp::Call(..))); assert!(!has_call, "call to trivial function should be removed"); } + +// --------------------------------------------------------------------------- +// Cross-block LoadImm liveness (regression for stale-and-result bug) +// --------------------------------------------------------------------------- + +/// A `LoadImm(result, 0)` in a predecessor block whose only consumer +/// is a branch terminator in a *successor* block must not be dropped +/// by the const-folding pass. This is the shape the logical-`and` +/// short-circuit false path lowers to, and dropping the `LoadImm` +/// leaves the zero-page slot holding whichever value the previous +/// `and` evaluation wrote — which was observable in the platformer +/// example as spurious enemy-stomp bounces firing whenever coin 2 +/// happened to be in its pickup window. +#[test] +fn const_fold_preserves_loadimm_used_by_sibling_branch() { + let cond = IrTemp(0); + let result = IrTemp(1); + let zero = IrTemp(2); + + // Shape mirrors `lower_logical_and`: an entry block that branches + // on `cond`, a right-side block that writes `result` via an + // `Or(result, , zero)`, a false-side block that only + // writes `LoadImm(result, 0)`, and a merge block whose terminator + // reads `result`. + let entry = IrBasicBlock { + label: "entry".to_string(), + ops: vec![IrOp::LoadImm(cond, 1)], + terminator: IrTerminator::Branch(cond, "right".to_string(), "false_path".to_string()), + }; + let right = IrBasicBlock { + label: "right".to_string(), + ops: vec![IrOp::LoadImm(zero, 0), IrOp::Or(result, cond, zero)], + terminator: IrTerminator::Jump("end".to_string()), + }; + // This is the block that previously tripped the bug: its + // `LoadImm(result, 0)` is the only op, and `result` is not used + // as a source operand anywhere *within* this block — but it is + // read by the `end` block's branch terminator. + let false_path = IrBasicBlock { + label: "false_path".to_string(), + ops: vec![IrOp::LoadImm(result, 0)], + terminator: IrTerminator::Jump("end".to_string()), + }; + let end = IrBasicBlock { + label: "end".to_string(), + ops: vec![], + terminator: IrTerminator::Return(Some(result)), + }; + + let mut prog = IrProgram { + functions: vec![IrFunction { + name: "and_like".to_string(), + blocks: vec![entry, right, false_path, end], + locals: vec![], + param_count: 0, + has_return: true, + source_span: Span::new(0, 0, 0), + }], + globals: vec![], + rom_data: vec![], + states: vec![], + start_state: String::new(), + }; + + optimize(&mut prog); + + // Find the false_path block post-optimization and verify that + // its LoadImm(result, 0) is still there. + let func = &prog.functions[0]; + let fp = func + .blocks + .iter() + .find(|b| b.label == "false_path") + .expect("false_path block should survive as reachable"); + assert!( + fp.ops + .iter() + .any(|op| matches!(op, IrOp::LoadImm(t, 0) if *t == result)), + "false-path LoadImm(result, 0) must survive optimization; block ops were {:?}", + fp.ops + ); +} diff --git a/tests/emulator/goldens/platformer.audio.hash b/tests/emulator/goldens/platformer.audio.hash index 03072e1..f4619b8 100644 --- a/tests/emulator/goldens/platformer.audio.hash +++ b/tests/emulator/goldens/platformer.audio.hash @@ -1 +1 @@ -7c8e60e4 132084 +e8b3ea0b 132084 diff --git a/tests/emulator/goldens/platformer.png b/tests/emulator/goldens/platformer.png index 1b975c9..3db239f 100644 Binary files a/tests/emulator/goldens/platformer.png and b/tests/emulator/goldens/platformer.png differ diff --git a/tests/emulator/record_gif.mjs b/tests/emulator/record_gif.mjs index 59a3161..dea9ca9 100644 --- a/tests/emulator/record_gif.mjs +++ b/tests/emulator/record_gif.mjs @@ -12,6 +12,18 @@ // At stride=2 we end up with a 30 fps GIF that maps 1:1 to every // other NES frame (NES runs at ~60 fps), which is the right // tradeoff between smoothness and file size for a README demo. +// +// IMPORTANT: `docs/platformer.gif` is committed and embedded in the +// README. Any change to the compiler, the runtime, the harness, or +// `examples/platformer.ne` that alters the gameplay you see in the +// first ~6 seconds of the demo must be followed by +// +// node tests/emulator/record_gif.mjs platformer 360 2 docs/platformer.gif +// +// committed alongside the source change. The CI `emulator` job +// regenerates the gif and fails if the committed copy is stale — +// gifenc + jsnes are deterministic, so the freshly-rendered bytes +// byte-match a valid commit. See `.github/workflows/ci.yml`. import { promises as fs } from "node:fs"; import path from "node:path";