From 7899714af13c1cd762bc082fcf03935314a80166 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 20:48:31 +0000 Subject: [PATCH] examples: 4 new programs covering MMC3 + other e2e gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four new examples bring total coverage to 18/18 ROMs through the jsnes smoke test: - mmc3_per_state_split.ne — two states, each with their own `on scanline(N)` handler at a different line (80 vs 160). Pressing START transitions between them. Verifies the per-state MMC3 IRQ dispatch: the `__ir_mmc3_reload` helper CMPs `current_state` on every NMI and writes the right latch value to `$C000`/`$C001`, and `__irq_user` runs the current state's handler when the counter fires. This is the first example that exercises the per-state reload logic at runtime, not just at compile-time. - two_player.ne — exercises `p2.button.*` reads alongside the default (P1) `button.*`. Two independently-moveable sprites sharing a single frame handler and the runtime OAM cursor. The runtime's NMI controller poll already reads both `$4016` and `$4017`, but until this example no runtime test actually looked at `$08` (the P2 input byte). - function_chain.ne — five-deep user-function call chain (`frame -> compute -> scale -> clamp -> fold -> taper`) with parameter passing through ZP `$04-$07` and return values through A. Early returns inside nested `if`s, handler-local result var, mixed shift + additive transforms. Catches any regression in: JSR stack discipline, param slot layout, RTS stack unwinding, return-value flow, or the analyzer's call-graph / max-depth computation. - comparisons.ne — one `if` per comparison operator (`==`, `!=`, `<`, `<=`, `>`, `>=`) gated on a u8 ramping through 0..255. Each `if` drives a pip sprite at a fixed column. Exercises every `CmpKind::*` case in the IR codegen's `gen_cmp`, catching regressions in branch-opcode selection (BEQ/BNE/BCC/BCS) and inverted-branch peephole folding. Smoke test deltas (all 18/18 pass, with per-example floors): comparisons 208 (floor 150) function_chain 104 (floor 100) mmc3_per_state_split 104 (floor 80) two_player 104 (floor 100) `tests/emulator/run_examples.mjs` gets new `EXAMPLE_FLOORS` entries for each, with notes describing the expected content so a regression prints a helpful reason. cargo test (313 unit + 37 integration), cargo fmt --check, cargo clippy --release -- -D warnings all clean. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V --- examples/comparisons.ne | 50 ++++++++ examples/function_chain.ne | 93 +++++++++++++++ examples/mmc3_per_state_split.ne | 112 ++++++++++++++++++ examples/two_player.ne | 74 ++++++++++++ tests/emulator/run_examples.mjs | 7 ++ tests/emulator/screenshots/comparisons.png | Bin 0 -> 930 bytes tests/emulator/screenshots/function_chain.png | Bin 0 -> 891 bytes .../screenshots/mmc3_per_state_split.png | Bin 0 -> 884 bytes tests/emulator/screenshots/two_player.png | Bin 0 -> 844 bytes 9 files changed, 336 insertions(+) create mode 100644 examples/comparisons.ne create mode 100644 examples/function_chain.ne create mode 100644 examples/mmc3_per_state_split.ne create mode 100644 examples/two_player.ne create mode 100644 tests/emulator/screenshots/comparisons.png create mode 100644 tests/emulator/screenshots/function_chain.png create mode 100644 tests/emulator/screenshots/mmc3_per_state_split.png create mode 100644 tests/emulator/screenshots/two_player.png diff --git a/examples/comparisons.ne b/examples/comparisons.ne new file mode 100644 index 0000000..81d4a47 --- /dev/null +++ b/examples/comparisons.ne @@ -0,0 +1,50 @@ +// Comparisons — uses every comparison operator (==, !=, <, <=, +// >, >=) to drive a different pip on screen. Each pip only +// lights up when its corresponding comparison is true, so at +// any given moment the set of visible pips is a direct readout +// of which comparisons are passing. +// +// What this exercises end-to-end: +// - All six comparison operators in `if` conditions +// - Their lowering through `CmpKind::{Eq,Ne,Lt,LtEq,Gt,GtEq}` +// in `src/codegen/ir_codegen.rs::gen_cmp` +// - Each one correctly mapping to `BEQ`/`BNE`/`BCC`/`BCS` and +// producing the expected truth value in a branch +// +// The `value` variable ramps from 0 to 255 so the six +// comparisons against MIDPOINT (=128) all fire across one cycle. +// +// Build: cargo run -- build examples/comparisons.ne + +game "Comparisons" { + mapper: NROM +} + +const MIDPOINT: u8 = 128 + +var value: u8 = 0 + +on frame { + // Slowly ramp through 0..255 and wrap. At the wrap the u8 + // overflow resets value to 0 — intentional, the pips will + // re-animate. + value += 1 + + // Always-visible player sprite so the harness has at least + // one solid OAM entry every frame. Its position reflects + // the current ramp value — easy to read at a glance. + draw Player at: (value, 120) + + // Each comparison drives a pip at a fixed X position along + // the top. When its condition is true, the pip draws; when + // false, the draw is skipped and the cursor's next slot + // stays hidden from the OAM clear's $FE Y byte. + if value == MIDPOINT { draw Pip at: ( 32, 16) } + if value != MIDPOINT { draw Pip at: ( 64, 16) } + if value < MIDPOINT { draw Pip at: ( 96, 16) } + if value <= MIDPOINT { draw Pip at: (128, 16) } + if value > MIDPOINT { draw Pip at: (160, 16) } + if value >= MIDPOINT { draw Pip at: (192, 16) } +} + +start Main diff --git a/examples/function_chain.ne b/examples/function_chain.ne new file mode 100644 index 0000000..bf9d3e9 --- /dev/null +++ b/examples/function_chain.ne @@ -0,0 +1,93 @@ +// Function Chain — exercises a deep call graph with parameter +// passing and return values across multiple user functions. +// +// The analyzer caps call depth at 8 (hard NES stack limit for +// a cooperative compiler). This example chains five functions: +// +// frame -> compute -> scale -> clamp -> fold -> taper +// +// Each function takes its argument through the zero-page +// parameter slots ($04-$07), computes a small transform, and +// returns a value in A. The chained result is what drives the +// player sprite's X position on screen each frame. +// +// What this exercises end-to-end: +// - Five levels of nested `JSR` without stack corruption +// - Parameter passing via `$04-$07` between callers +// - Return value propagation through A +// - `fun ... -> u8 { return ... }` — the full typed-function +// shape, including an early `return` inside an `if` +// - Interaction of function calls with handler-local vars +// (the `out` result ends up in a local that drives draw) +// +// Build: cargo run -- build examples/function_chain.ne + +game "Fn Chain" { + mapper: NROM +} + +const SCREEN_MIN: u8 = 16 +const SCREEN_MAX: u8 = 232 + +var tick: u8 = 0 + +// Level 5: final transform — fold the input by reflecting any +// overshoot back toward the middle. Pure function, returns u8. +fun taper(v: u8) -> u8 { + if v > 200 { + return 200 + } + return v +} + +// Level 4: fold — bias the input toward the screen center. +fun fold(v: u8) -> u8 { + var biased: u8 = v + if biased < SCREEN_MIN { + biased = SCREEN_MIN + } + return taper(biased) +} + +// Level 3: clamp to the visible screen band. +fun clamp(v: u8) -> u8 { + if v > SCREEN_MAX { + return fold(SCREEN_MAX) + } + return fold(v) +} + +// Level 2: scale the tick into a pixel position. Uses a shift +// instead of multiply so we don't pull in the soft multiply. +fun scale(t: u8) -> u8 { + return clamp(t << 1) +} + +// Level 1: top of the call chain. Takes the raw frame counter, +// adds a small offset, and hands it to `scale`. The returned +// value is the player's X position. +fun compute(counter: u8) -> u8 { + var shifted: u8 = counter + shifted += 16 + return scale(shifted) +} + +on frame { + tick += 1 + + // Single call site that triggers the whole chain. If any + // link in the chain corrupts the param passing or stack, + // the player sprite starts jittering or disappears. + var x: u8 = compute(tick) + + // Player Y is fixed; X comes from the chain. Visually the + // sprite sweeps across the screen as the chain holds. + draw Player at: (x, 112) + + // Also draw a static reference marker so the smoke test + // always has at least one visible sprite even if the chain + // somehow returns 0. + draw Marker at: (8, 8) +} + +start Main diff --git a/examples/mmc3_per_state_split.ne b/examples/mmc3_per_state_split.ne new file mode 100644 index 0000000..5d7c4f5 --- /dev/null +++ b/examples/mmc3_per_state_split.ne @@ -0,0 +1,112 @@ +// MMC3 Per-State Scanline Split — proves the compiler's per-state +// IRQ dispatch and reload logic. Two states each own their own +// `on scanline(N)` handler at a different scanline; pressing START +// transitions between them. Because the MMC3 IRQ latch is +// reloaded from the *current* state's scanline each frame (via +// the `__ir_mmc3_reload` helper), the visible split line moves +// when the state changes. +// +// What this exercises end-to-end: +// - MMC3 scanline IRQ firing at the latched line +// - `__ir_mmc3_reload` walking the dispatch table to pick the +// latch value for the new state after a transition +// - `__irq_user` dispatching to the right per-state handler +// - scroll writes from inside an IRQ handler landing before the +// PPU renders the next visible scanline +// +// Controls: +// START — toggle between Upper-split and Lower-split states +// +// Build: cargo run -- build examples/mmc3_per_state_split.ne + +game "MMC3 Split" { + mapper: MMC3 + mirroring: horizontal +} + +// Scroll values for the two halves of the screen. `frame_scroll` +// drifts every frame so the split is easy to see in motion — the +// top half scrolls right, the bottom scrolls left. +var top_scroll: u8 = 0 +var bottom_scroll: u8 = 0 + +// Tiny debouncer so one press of START doesn't cycle through the +// states multiple times per frame. +var debounce: u8 = 0 + +// Shared drift counter — primarily for observability (the split +// animation works from `top_scroll` / `bottom_scroll` directly). +var _frame_counter: u8 = 0 + +state Upper { + on enter { + // When we arrive, reset the scroll so the split is easy + // to see from the first frame onward. + top_scroll = 0 + bottom_scroll = 0 + } + + on frame { + _frame_counter += 1 + top_scroll += 1 + bottom_scroll -= 1 + + // Initial scroll for the TOP half. The scanline handler + // below will rewrite scroll midway through the frame. + scroll(top_scroll, 0) + + // Draw a marker at the top half and a player sprite in + // the bottom half so the split position is visually + // obvious. + draw Marker at: (40, 40) + draw Player at: (120, 140) + + if debounce > 0 { + debounce -= 1 + } + if button.start and debounce == 0 { + transition Lower + debounce = 30 + } + } + + // Split at line 80 — the top 80 rows use `top_scroll`, the + // rest use `bottom_scroll`. + on scanline(80) { + scroll(bottom_scroll, 0) + } +} + +state Lower { + on enter { + top_scroll = 0 + bottom_scroll = 0 + } + + on frame { + _frame_counter += 1 + top_scroll += 1 + bottom_scroll -= 1 + + scroll(top_scroll, 0) + + draw Marker at: (40, 40) + draw Player at: (120, 140) + + if debounce > 0 { + debounce -= 1 + } + if button.start and debounce == 0 { + transition Upper + debounce = 30 + } + } + + // Split at line 160 — lower split, so the top 160 rows use + // `top_scroll` and only the last ~80 rows use `bottom_scroll`. + on scanline(160) { + scroll(bottom_scroll, 0) + } +} + +start Upper diff --git a/examples/two_player.ne b/examples/two_player.ne new file mode 100644 index 0000000..b2637b0 --- /dev/null +++ b/examples/two_player.ne @@ -0,0 +1,74 @@ +// Two Player — proves the compiler can read both NES controllers. +// +// Player 1 input goes through `button.*` (the implicit default, +// equivalent to `p1.button.*`); player 2 input goes through +// `p2.button.*`. The language guide mentions both; until this +// example, no runtime test actually exercised player 2. +// +// What this exercises end-to-end: +// - `p2.button.up/down/left/right/a` reads from the player-2 +// ZP slot ($08) rather than player 1 ($01) +// - The runtime's NMI controller poll reads both $4016 and +// $4017 per frame and shifts the bits into $01 and $08 +// - Two independently-controlled sprites sharing a frame +// handler's OAM cursor +// +// Controls: +// D-pad, A/B — player 1 moves red sprite +// p2.D-pad, — player 2 moves blue sprite +// p2.A/B — p2 shoots "bullets" (visual marker) +// +// Build: cargo run -- build examples/two_player.ne + +game "Two Player" { + mapper: NROM +} + +var p1x: u8 = 64 +var p1y: u8 = 112 +var p2x: u8 = 192 +var p2y: u8 = 112 + +// A simple "shot" indicator for each player — when any button is +// pressed this frame, we light up a pixel near the player. +var p1_shot: u8 = 0 +var p2_shot: u8 = 0 + +on frame { + // Player 1 — uses the implicit prefix. + if button.left { p1x -= 1 } + if button.right { p1x += 1 } + if button.up { p1y -= 1 } + if button.down { p1y += 1 } + if button.a or button.b { + p1_shot = 1 + } else { + p1_shot = 0 + } + + // Player 2 — explicit `p2.` prefix. + if p2.button.left { p2x -= 1 } + if p2.button.right { p2x += 1 } + if p2.button.up { p2y -= 1 } + if p2.button.down { p2y += 1 } + if p2.button.a or p2.button.b { + p2_shot = 1 + } else { + p2_shot = 0 + } + + // Draw each player, and a shot indicator above their head + // whenever they're holding a face button. Using separate + // `draw` statements so each gets its own OAM slot via the + // runtime cursor. + draw Player1 at: (p1x, p1y) + draw Player2 at: (p2x, p2y) + if p1_shot == 1 { + draw Shot at: (p1x, p1y - 8) + } + if p2_shot == 1 { + draw Shot at: (p2x, p2y - 8) + } +} + +start Main diff --git a/tests/emulator/run_examples.mjs b/tests/emulator/run_examples.mjs index da6a556..8403d18 100644 --- a/tests/emulator/run_examples.mjs +++ b/tests/emulator/run_examples.mjs @@ -36,6 +36,13 @@ const EXAMPLE_FLOORS = { structs_enums_for: [200, "player + 4 enemies drawn by a `for` loop"], sprites_and_palettes: [60, "custom CHR tiles visible"], scanline_split: [80, "banner + player"], + mmc3_per_state_split: [80, "marker + player in the split-screen state"], + two_player: [100, "two player sprites drawn independently"], + function_chain: [100, "player swept by chained function return + a static marker"], + // `comparisons` has at least `value != MIDPOINT` true for 255 of + // 256 frames, plus either `<`/`<=` or `>`/`>=`, plus the player. + // That's 4+ sprites on most frames. + comparisons: [150, "player + pips for each true comparison against MIDPOINT"], }; async function listRoms() { diff --git a/tests/emulator/screenshots/comparisons.png b/tests/emulator/screenshots/comparisons.png new file mode 100644 index 0000000000000000000000000000000000000000..ade5cd3c7b79d4640472a92a2738d2d18d0b7d6b GIT binary patch literal 930 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5#Gr$%L=Ea~K$y!#!ObLn`LHxoPOPCP1Vi z(L`*^x`Vnq{)_Hly`JFx)5C+~+~FBB0t)`WU%lh)%%7IbpN$-EoMuU6Qt)M1!r5TL zI6;l!5{ttu1`lC|OiV#(m4?*FZ?7xosqfoeW%qsVe-6eqm;e9zV*+16KoJH{S3j3^P6o9}CLzp3xNx_$4 z31@=|;{-K^OY>L^&VSxEZ&~Jntu|ZkN>@vZO4q?|M{-n{{Qde&b;YIH{IoD z+0meG-?)u)gFI`2Sc(#;|E6#jaNTCP z(a*$}z%=Jj9TN{<3XuM$eS*c}M(&5K{c;DrIpq$v3m(w?!ShRtN#U$CG}=Z$7=ltK smAv^Mm~;OBPvVN%2xPE<@(we@&Kc3=o6H`Y0Yw-*UHx3vIVCg!0M$PgZU6uP literal 0 HcmV?d00001 diff --git a/tests/emulator/screenshots/mmc3_per_state_split.png b/tests/emulator/screenshots/mmc3_per_state_split.png new file mode 100644 index 0000000000000000000000000000000000000000..8c59f358284d54e49884fdf3f85820bcabb82e9e GIT binary patch literal 884 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5#Gr$%L=Ea~K$yH9cJ%Ln`LHxw$d-uz`R> zpqj@*Gvk!jH};NUA9(UY-5MAiKQJt5{2zDp_0Pg(e@qqLWIN1Y@DOImWK!^DSi;$0 z!Z<;V;S!6(EUF8Nv2S?)yY$|++ygP%%och7KYuKjH$9LtKY8NkZ}tEG%zIpDaqPEK z1E<)3mR)8CYDEv2oHje~avvj`&-OazIpO!&5<2@go;$7lL*tDnHyMKOnjd(; zdxIf3c7Ib&gZW3p8Bd=I)=YUIxMvQ-vQLs=`Tt*P i>DjG71{)~TFf;fHMVE(9_%i`0!rHB}$EF0GU|NFIa-_-+Cm)-kc>hmt* z|J%Qhcih$gcKdVVk^Qr3|GVyEvh`c=Iq*p8v%B@b)DJwYVdB*@fB)~b4P)O+TgG`w z_x{Eo`%|~K%3+1$}B$!`>UK$*qU)z4*}Q$iB}DANI` literal 0 HcmV?d00001