From 5e5bed39a555c4814ff649f82500ede1ef19e63d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 22:07:19 +0000 Subject: [PATCH] sprite-per-scanline: add cycle_sprites runtime flicker + debug telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W0109 (shipped last commit) catches the 8-sprites-per-scanline hardware limit at compile time for static layouts, but the dynamic case — enemy formations, projectile clusters, animated NPCs where coordinates come from variables — was still silent. This change adds two layers of defense on top of W0109: Layer 2: `cycle_sprites` runtime flicker intrinsic New keyword statement that rotates the OAM DMA start offset one slot per call. When called once per `on frame`, the PPU's sprite evaluation picks up a different subset of the 12+ overlapping sprites each frame, so the permanent-dropout failure mode becomes visible flicker — the classic NES technique used by Gradius, Battletoads, and every shmup. Implementation: - Lexer keyword `KwCycleSprites` and parser production. - AST `Statement::CycleSprites(Span)`. - `IrOp::CycleSprites` lowered by the IR pass. - Codegen emits `LDA $07EF / CLC / ADC #4 / STA $07EF` with natural u8 wrap, plus a one-shot `__sprite_cycle_used` marker label the first time it fires. - Linker detects the marker and switches `gen_nmi` to the cycling variant, which reads the rotating offset from `$07EF` into OAM_ADDR before the DMA instead of writing a literal 0. Programs that don't call `cycle_sprites` skip the marker and get byte-identical ROM output. Layer 3: debug-mode sprite overflow telemetry Mirrors the frame-overrun pair (`debug.frame_overrun_count` / `debug.frame_overran`). In debug builds the NMI handler reads `$2002` at the top of vblank, masks bit 5 (the PPU's sprite overflow flag), and if set bumps a cumulative counter at `$07FD` plus a sticky bit at `$07FC`. The sticky bit clears on every `wait_frame`. New debug builtins: - `debug.sprite_overflow_count()` → u8 peek of $07FD - `debug.sprite_overflow()` → u8 peek of $07FC (sticky bit) The hardware flag has well-known quirks but is correct for the overwhelming majority of cases and costs ~15 cycles per frame to sample. Release builds emit no overflow-check code at all, so the four bytes at `$07EF` / `$07FC`-`$07FD` stay free for user allocation. Related changes: - `gen_nmi` now takes an `NmiOptions` struct. Four bool parameters tripped clippy's `fn_params_excessive_bools`. - CLI `build` now renders analyzer warnings on a successful build. Previously warnings were silently dropped unless the user also ran `nescript check`, which made W0109 effectively invisible to CI and local dev alike. Existing pre-existing W0103 / W0106 warnings on `coin_cavern`, `mmc3_per_state_split`, `sprites_and_palettes` surface too — not regressions, just now visible. New example: `examples/sprite_flicker_demo.ne` Draws 12 sprites into a 4-pixel band, W0109 fires at compile time with nine labels pointing at the offenders, and a `cycle_sprites` call at the end of `on frame` turns the hardware dropout into flicker. The committed emulator golden captures one frame of the cycling pattern (deterministic). Tests: - `runtime::tests::nmi_debug_mode_samples_sprite_overflow` - `runtime::tests::nmi_sprite_cycle_variant_reads_rotating_offset` - `ir_codegen::*::debug_sprite_overflow_count_loads_07fd` - `ir_codegen::*::debug_sprite_overflow_flag_loads_07fc` - `ir_codegen::*::wait_frame_clears_sprite_overflow_sticky_in_debug_mode` - `ir_codegen::*::wait_frame_release_does_not_touch_sprite_overflow_sticky` - `ir_codegen::*::cycle_sprites_emits_marker_and_add4` - `ir_codegen::*::cycle_sprites_marker_dedup_across_multiple_calls` - `ir_codegen::*::program_without_cycle_sprites_emits_no_marker` - `analyzer::*::accepts_debug_sprite_overflow_builtins` - `analyzer::*::rejects_unknown_debug_method_lists_all_four_known_names` - `analyzer::*::accepts_cycle_sprites_statement` Docs: `examples/war/COMPILER_BUGS.md` §4 now describes all three layers (W0109, `cycle_sprites`, debug telemetry) with reasoning for when each applies. `README.md` and `examples/README.md` add the new example to their tables. All 32 emulator goldens still match — the cycling is opt-in and programs that don't call `cycle_sprites` or enable debug mode are byte-identical to the pre-change output. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z --- README.md | 1 + examples/README.md | 1 + examples/sprite_flicker_demo.ne | 67 +++++ examples/sprite_flicker_demo.nes | Bin 0 -> 24592 bytes examples/war/COMPILER_BUGS.md | 93 +++++++ src/analyzer/mod.rs | 11 +- src/analyzer/tests.rs | 64 +++++ src/codegen/ir_codegen.rs | 229 +++++++++++++++++- src/ir/lowering.rs | 8 +- src/ir/mod.rs | 6 + src/lexer/mod.rs | 1 + src/lexer/token.rs | 2 + src/linker/mod.rs | 15 +- src/main.rs | 13 + src/optimizer/mod.rs | 2 + src/parser/ast.rs | 10 + src/parser/mod.rs | 5 + src/runtime/mod.rs | 146 ++++++++++- src/runtime/tests.rs | 88 ++++++- .../goldens/sprite_flicker_demo.audio.hash | 1 + .../emulator/goldens/sprite_flicker_demo.png | Bin 0 -> 1422 bytes 21 files changed, 739 insertions(+), 24 deletions(-) create mode 100644 examples/sprite_flicker_demo.ne create mode 100644 examples/sprite_flicker_demo.nes create mode 100644 tests/emulator/goldens/sprite_flicker_demo.audio.hash create mode 100644 tests/emulator/goldens/sprite_flicker_demo.png diff --git a/README.md b/README.md index fb0830d..5cb1978 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ start Main | [`noise_triangle_sfx.ne`](examples/noise_triangle_sfx.ne) | Noise and triangle channel sfx via `channel: noise` / `channel: triangle` on `sfx` blocks | | [`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 | | [`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 five compiler bugs / limitations, all catalogued in [`examples/war/COMPILER_BUGS.md`](examples/war/COMPILER_BUGS.md) — two fixed in the same PR. | diff --git a/examples/README.md b/examples/README.md index 4ab3c1d..2808b2d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -36,6 +36,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX] | `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`. | +| `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 and fixed two compiler bugs (E0506 too-many-params, and the IR-lowering `wide_hi` leak across functions). The remaining limitations and workarounds are catalogued in [`war/COMPILER_BUGS.md`](war/COMPILER_BUGS.md). | ## Emulator Controls diff --git a/examples/sprite_flicker_demo.ne b/examples/sprite_flicker_demo.ne new file mode 100644 index 0000000..34ed589 --- /dev/null +++ b/examples/sprite_flicker_demo.ne @@ -0,0 +1,67 @@ +// Sprite-flicker demo — showcases `cycle_sprites`, NEScript's +// opt-in mitigation for the NES's 8-sprites-per-scanline +// hardware limit. +// +// The PPU evaluates OAM each scanline and picks the first 8 +// sprites that cover it; any 9th+ sprite on the same scanline +// is silently dropped. Without sprite cycling, the SAME sprite +// gets dropped every frame because the draw order is stable +// frame-to-frame — you get a permanent dropout that looks like +// a game bug. +// +// `cycle_sprites` rotates where the OAM DMA lands each frame, +// so the PPU's "first 8" sweep picks up a different subset +// each time. Sprites at the end of the OAM buffer still drop +// sometimes, but they drop *different* sprites on adjacent +// frames. The human eye reconstructs the missing pixels from +// frame persistence, so the failure mode looks like gentle +// flicker instead of missing objects. This is the classic NES +// technique used by Gradius, Battletoads, and every shmup +// that ever existed. +// +// This demo draws 12 sprites packed onto the same y row (row +// 100), two wider than the 8-per-scanline budget. Without the +// `cycle_sprites` call you would see sprites 9 through 12 +// completely invisible forever. With it they flicker, and the +// scene is readable even though the hardware can only show 8 +// of them on any single scanline. +// +// The W0109 analyzer warning fires at compile time for this +// layout because every coordinate is a literal — the three +// layers of defense (compile-time W0109, runtime flicker via +// `cycle_sprites`, debug-mode `debug.sprite_overflow*` telemetry) +// all apply here. +// +// Build: cargo run -- build examples/sprite_flicker_demo.ne + +game "Sprite Flicker Demo" { + mapper: NROM +} + +on frame { + // Twelve sprites on the same 8-pixel band: nine at y=100 + // plus three at y=104 (all overlap scanlines 104..107). + // The PPU can only render 8 of them per scanline, so + // without cycling four would be dropped every frame. + draw Star at: (16, 100) + draw Star at: (32, 100) + draw Star at: (48, 100) + draw Star at: (64, 100) + draw Star at: (80, 100) + draw Star at: (96, 100) + draw Star at: (112, 100) + draw Star at: (128, 100) + draw Star at: (144, 100) + draw Star at: (160, 104) + draw Star at: (176, 104) + draw Star at: (192, 104) + + // Rotate the OAM DMA offset by one slot. Over 12 frames + // every sprite gets dropped approximately once, producing + // visible flicker rather than permanent dropout. + cycle_sprites + + wait_frame +} + +start Main diff --git a/examples/sprite_flicker_demo.nes b/examples/sprite_flicker_demo.nes new file mode 100644 index 0000000000000000000000000000000000000000..b128bd79f475838a8feccc4266a045ed2fda20de GIT binary patch literal 24592 zcmeI)zl+mg9LMn|&y!2q$ttyX-V{X&{umB%5GnX@pac;S{0BNH#Ldy^IEO64BOQ$p zkYK<^DEKFwI(bt^R|k*7!K;&l;`uyxTqr2n9E5k;e){G4gs0?{&bfX4?rIQ-=6Lk6 z(odIlwg21-BN7uN=A^dD$C?q^gxYpYpv{uD;-XA?Kdh)kDcn1-ze*vkn^Y;nQY@7s zDn-0I`*e8TrrT~R6MM}>iQC)7F%$dAMbAg#<6tfF)4<2DS?l`gl#g@GTHvSSK2A4l zf7`F`+p|6{H|MST>Aa7t&Dse+o$+zWN6%fo6}hU#{HOaCwfZ3D>Q?`4B@V82*B!I( zuIbu)FWsl`mtO?3kka-FC3m`quRq(QwOwm%b!&U4wH>#%4_n*K)^@VkW=Kx7OW%eq2 zooT(#fdB#sAb8 dynamically-positioned sprites (enemies, projectiles, +animated NPCs) is invisible to it. The hardware will still +drop the 9th+ sprite on every frame, and because draw order +is stable frame-to-frame the *same* sprite goes missing every +frame, which reads to the developer as a game bug rather +than a hardware limit. + +The classic NES mitigation is sprite cycling: rotate the OAM +DMA start offset each frame so different sprites land in the +PPU's "first 8" on each successive frame. Over N frames (where +N is the number of overlapping sprites) each sprite gets +dropped exactly once, and the eye reconstructs the missing +pixels from frame persistence. Permanent dropout becomes +visible flicker — the failure mode every NES player +recognises, and vastly better UX than "my bullet disappeared." + +NEScript ships this as the opt-in `cycle_sprites` statement: + +```nescript +on frame { + draw Enemy0 at: (e0x, e0y) + draw Enemy1 at: (e1x, e1y) + // ...lots of enemies... + cycle_sprites // bump the rotating offset one slot + wait_frame +} +``` + +Each call adds 4 to a one-byte runtime counter at `$07EF` +(natural u8 wrap at 256 → 0) and emits a `__sprite_cycle_used` +marker label. The linker reads the marker and swaps the NMI +handler over to a variant that writes the counter to `$2003` +before triggering the OAM DMA, so each frame's DMA lands in +a different slot of the PPU's OAM buffer. Over 64 frames the +rotation completes a full cycle. + +Programs that don't call `cycle_sprites` emit no marker and +get the original fixed-offset NMI path, so every existing +golden ROM stays byte-identical. Opt-in by design — the +tradeoff is "cosmetic HUD elements you pinned to slot 0 lose +their pin" — so programs that manage OAM priority manually +can keep doing so. + +The [`examples/sprite_flicker_demo.ne`](../sprite_flicker_demo.ne) +example drives 12 sprites into a 4-pixel band to exercise +both W0109 at compile time and `cycle_sprites` at runtime; +the committed emulator golden captures a specific frame of +the cycling pattern. + +### Layer-3: debug-mode runtime overflow telemetry + +`debug.sprite_overflow_count()` and `debug.sprite_overflow()` +mirror the existing `debug.frame_overrun_count()` / +`debug.frame_overran()` pair. In debug builds the NMI handler +samples the PPU's sprite-overflow flag (`$2002` bit 5) at the +top of vblank — it reflects whether any scanline of the +just-finished frame had more than 8 sprites and fired the +hardware "give up" pathway. If the bit is set the handler +bumps a cumulative counter at `$07FD` and sets a per-frame +sticky bit at `$07FC`, which the next `wait_frame` clears. + +User code reads those bytes via the new builtins: + +```nescript +debug.assert(not debug.sprite_overflow()) +``` + +…or, in an overlay: + +```nescript +var ovf: u8 = 0 +ovf = debug.sprite_overflow_count() +draw Digit at: (8, 8) frame: ovf +``` + +The PPU hardware flag has well-known quirks (it occasionally +misses the 9th sprite or sets the flag when none actually +overflowed), but it's correct for the overwhelming majority +of cases and is essentially free to sample — one `LDA $2002; +AND #$20` at NMI top, ~15 cycles per frame. Release builds +never emit the check block, so the four bytes at `$07EF` / +`$07FC`-`$07FD` remain free for the analyzer to allocate. + +Combined, the three layers catch the sprite-per-scanline +limit at three different lifecycle stages: W0109 at compile +time for statically-knowable layouts, `debug.sprite_overflow*` +at playtest time for the dynamic cases W0109 can't see, and +`cycle_sprites` at runtime as a graceful fallback for the +cases the user knows are unavoidable. + --- ## 5. The `inline` keyword is a hint and is silently ignored for short functions *(FIXED)* diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index e8da539..0205c63 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -943,7 +943,10 @@ impl Analyzer { // for completeness even though no current method // accepts any. match method.as_str() { - "frame_overrun_count" | "frame_overran" => { + "frame_overrun_count" + | "frame_overran" + | "sprite_overflow_count" + | "sprite_overflow" => { if !args.is_empty() { self.diagnostics.push(Diagnostic::error( ErrorCode::E0203, @@ -956,8 +959,8 @@ impl Analyzer { self.diagnostics.push(Diagnostic::error( ErrorCode::E0201, format!( - "unknown debug method '{method}' \ - (expected 'frame_overrun_count' or 'frame_overran')" + "unknown debug method '{method}' (expected 'frame_overrun_count', \ + 'frame_overran', 'sprite_overflow_count', or 'sprite_overflow')" ), *span, )); @@ -1964,6 +1967,7 @@ impl Analyzer { } } Statement::WaitFrame(_) => {} + Statement::CycleSprites(_) => {} Statement::SetPalette(name, span) => { if !self.palette_names.contains(name) { self.diagnostics.push(Diagnostic::error( @@ -2364,6 +2368,7 @@ fn collect_calls_stmt(stmt: &Statement, calls: &mut Vec) { Statement::Return(None, _) | Statement::Transition(_, _) | Statement::WaitFrame(_) + | Statement::CycleSprites(_) | Statement::Break(_) | Statement::Continue(_) | Statement::InlineAsm(_, _) diff --git a/src/analyzer/tests.rs b/src/analyzer/tests.rs index 230541c..bea2d76 100644 --- a/src/analyzer/tests.rs +++ b/src/analyzer/tests.rs @@ -2413,3 +2413,67 @@ fn analyze_sprite_scanline_budget_recurses_into_if() { result.diagnostics ); } + +#[test] +fn analyze_accepts_debug_sprite_overflow_builtins() { + // Both new debug methods should analyze without errors when + // called with zero arguments, exactly like + // frame_overrun_count / frame_overran. + let result = analyze_ok( + r#" + game "T" { mapper: NROM } + var a: u8 = 0 + var b: u8 = 0 + on frame { + a = debug.sprite_overflow_count() + b = debug.sprite_overflow() + wait_frame + } + start Main + "#, + ); + assert!(!result + .diagnostics + .iter() + .any(|d| d.code == ErrorCode::E0201)); +} + +#[test] +fn analyze_rejects_unknown_debug_method_lists_all_four_known_names() { + // When the user calls `debug.nope()`, the E0201 message + // should list every supported method name so typo fixes are + // obvious. + let errors = analyze_errors( + r#" + game "T" { mapper: NROM } + var a: u8 = 0 + on frame { + a = debug.nope() + wait_frame + } + start Main + "#, + ); + assert!( + errors.contains(&ErrorCode::E0201), + "expected E0201 for unknown debug method, got: {errors:?}" + ); +} + +#[test] +fn analyze_accepts_cycle_sprites_statement() { + // `cycle_sprites` is a no-arg keyword statement. It should + // analyze cleanly in a frame handler without triggering any + // errors or warnings. + analyze_ok( + r#" + game "T" { mapper: NROM } + on frame { + draw Blip at: (10, 20) + cycle_sprites + wait_frame + } + start Main + "#, + ); +} diff --git a/src/codegen/ir_codegen.rs b/src/codegen/ir_codegen.rs index 2aee94b..1238545 100644 --- a/src/codegen/ir_codegen.rs +++ b/src/codegen/ir_codegen.rs @@ -149,6 +149,13 @@ pub struct IrCodeGen<'a> { /// `false` and emit byte-identical ROM bytes for the audio /// subsystem. sfx_pitch_used: bool, + /// Set to true the first time the codegen lowers an + /// `IrOp::CycleSprites`. Drives the `__sprite_cycle_used` + /// marker label, which the linker reads to switch the NMI + /// handler over to the rotating-offset OAM DMA variant. The + /// flag also guards emitting the marker label *once* even + /// when the program calls `cycle_sprites` from many sites. + sprite_cycle_used: bool, /// Set to true the first time we emit any PPU update op /// (`set_palette` / `load_background`). The linker uses the /// resulting `__ppu_update_used` marker label to decide whether @@ -291,6 +298,7 @@ impl<'a> IrCodeGen<'a> { sprite_tiles: HashMap::new(), sfx_info: HashMap::new(), sfx_pitch_used: false, + sprite_cycle_used: false, music_info: HashMap::new(), state_indices: HashMap::new(), function_names, @@ -711,14 +719,16 @@ impl<'a> IrCodeGen<'a> { self.emit(LDA, AM::Immediate(0)); self.emit(STA, AM::ZeroPage(ZP_FRAME_FLAG)); // In debug mode, also clear the per-frame "did this frame - // overrun" sticky bit at $07FE so user code sees a fresh - // value next NMI even when the program has no explicit - // `wait_frame` inside its handler. The IR-level WaitFrame - // op clears it too, so explicit-wait programs already get - // this for free; mirroring it here makes the implicit - // main-loop path consistent. + // overrun" sticky bit at $07FE and the "did sprite overflow + // fire" sticky bit at $07FC so user code sees fresh values + // next NMI even when the program has no explicit `wait_frame` + // inside its handler. The IR-level WaitFrame op clears + // them too, so explicit-wait programs already get this for + // free; mirroring it here makes the implicit main-loop + // path consistent. if self.debug_mode { self.emit(STA, AM::Absolute(0x07FE)); + self.emit(STA, AM::Absolute(0x07FC)); } // Dispatch on current_state using CMP + BNE + JMP trampoline @@ -1247,9 +1257,10 @@ impl<'a> IrCodeGen<'a> { IrOp::WaitFrame => { // Poll frame flag at $00 until nonzero, then clear it. // In debug mode, also clear the per-frame "did the - // previous frame overrun" sticky bit so user code - // sees a fresh value next NMI. The cumulative - // counter at $07FF is intentionally left alone. + // previous frame overrun" and "did the previous frame + // overflow sprites" sticky bits so user code sees a + // fresh value next NMI. The cumulative counters at + // $07FF and $07FD are intentionally left alone. let wait_label = format!("__ir_wait_{}", self.local_label_suffix()); self.emit_label(&wait_label); self.emit(LDA, AM::ZeroPage(ZP_FRAME_FLAG)); @@ -1258,8 +1269,28 @@ impl<'a> IrCodeGen<'a> { self.emit(STA, AM::ZeroPage(ZP_FRAME_FLAG)); if self.debug_mode { self.emit(STA, AM::Absolute(0x07FE)); + self.emit(STA, AM::Absolute(0x07FC)); } } + IrOp::CycleSprites => { + // Emit the `__sprite_cycle_used` marker label exactly + // once per program so the linker switches to the + // cycling variant of the NMI handler. The label is + // zero-length; it only matters as a lookup key in + // the assembled label table. + if !self.sprite_cycle_used { + self.emit_label("__sprite_cycle_used"); + self.sprite_cycle_used = true; + } + // Add 4 to the rotating offset byte at $07EF. Four + // `INC $07EF`s would also work but cost one extra + // byte; `LDA / CLC / ADC #4 / STA` is 10 bytes and + // lets us rely on the natural u8 wrap at 256 → 0. + self.emit(LDA, AM::Absolute(0x07EF)); + self.emit(CLC, AM::Implied); + self.emit(ADC, AM::Immediate(0x04)); + self.emit(STA, AM::Absolute(0x07EF)); + } IrOp::Transition(name) => { // Write the target state's index to current_state, then // call the target state's on_enter handler if it exists, @@ -2274,6 +2305,7 @@ fn op_source_temps(op: &IrOp) -> Vec { } => vec![*a_lo, *a_hi, *b_lo, *b_hi], IrOp::ReadInput(_, _) | IrOp::WaitFrame + | IrOp::CycleSprites | IrOp::Transition(_) | IrOp::InlineAsm(_) | IrOp::Peek(_, _) @@ -2927,6 +2959,185 @@ mod more_tests { ); } + #[test] + fn ir_codegen_debug_sprite_overflow_count_loads_07fd() { + let insts = lower_and_gen_debug( + r#" + game "T" { mapper: NROM } + var n: u8 = 0 + on frame { + n = debug.sprite_overflow_count() + wait_frame + } + start Main + "#, + ); + let reads_counter = insts + .iter() + .any(|i| i.opcode == LDA && i.mode == AM::Absolute(0x07FD)); + assert!( + reads_counter, + "debug.sprite_overflow_count() should LDA $07FD" + ); + } + + #[test] + fn ir_codegen_debug_sprite_overflow_flag_loads_07fc() { + let insts = lower_and_gen_debug( + r#" + game "T" { mapper: NROM } + var n: u8 = 0 + on frame { + n = debug.sprite_overflow() + wait_frame + } + start Main + "#, + ); + let reads_flag = insts + .iter() + .any(|i| i.opcode == LDA && i.mode == AM::Absolute(0x07FC)); + assert!(reads_flag, "debug.sprite_overflow() should LDA $07FC"); + } + + #[test] + fn ir_codegen_wait_frame_clears_sprite_overflow_sticky_in_debug_mode() { + // The per-frame sticky bit at $07FC must be cleared by the + // wait_frame op in debug builds so user code reads a fresh + // value every frame — same pattern as the frame-overrun + // sticky at $07FE. + let insts = lower_and_gen_debug( + r#" + game "T" { mapper: NROM } + on frame { wait_frame } + start Main + "#, + ); + let clears = insts + .iter() + .any(|i| i.opcode == STA && i.mode == AM::Absolute(0x07FC)); + assert!( + clears, + "debug-mode wait_frame should clear the $07FC sticky bit" + ); + } + + #[test] + fn ir_codegen_wait_frame_release_does_not_touch_sprite_overflow_sticky() { + // Release builds must not emit a store to $07FC so the + // top-of-RAM debug slot stays available for user allocation. + let insts = lower_and_gen( + r#" + game "T" { mapper: NROM } + on frame { wait_frame } + start Main + "#, + ); + let touches = insts + .iter() + .any(|i| (i.opcode == STA || i.opcode == LDA) && i.mode == AM::Absolute(0x07FC)); + assert!(!touches, "release-mode wait_frame must not touch $07FC"); + } + + #[test] + fn ir_codegen_cycle_sprites_emits_marker_and_add4() { + // `cycle_sprites` must emit exactly one `__sprite_cycle_used` + // label (the linker looks for its presence to switch NMI + // variants), a read-modify-write of $07EF that adds 4 to the + // rotating offset byte, and nothing else. + let insts = lower_and_gen( + r#" + game "T" { mapper: NROM } + on frame { + cycle_sprites + wait_frame + } + start Main + "#, + ); + let marker_count = insts + .iter() + .filter(|i| matches!(&i.mode, AM::Label(l) if l == "__sprite_cycle_used")) + .count(); + assert_eq!( + marker_count, 1, + "cycle_sprites should emit exactly one __sprite_cycle_used marker label" + ); + + let has_lda = insts + .iter() + .any(|i| i.opcode == LDA && i.mode == AM::Absolute(0x07EF)); + let has_adc = insts + .iter() + .any(|i| i.opcode == ADC && i.mode == AM::Immediate(0x04)); + let has_sta = insts + .iter() + .any(|i| i.opcode == STA && i.mode == AM::Absolute(0x07EF)); + assert!( + has_lda && has_adc && has_sta, + "cycle_sprites should compile to LDA $07EF / ADC #4 / STA $07EF" + ); + } + + #[test] + fn ir_codegen_cycle_sprites_marker_dedup_across_multiple_calls() { + // A program with more than one `cycle_sprites` call still + // emits the marker exactly once — the flag on the codegen + // guards against duplicate labels that would break the + // assembler. + let insts = lower_and_gen( + r#" + game "T" { mapper: NROM } + on frame { + cycle_sprites + cycle_sprites + cycle_sprites + wait_frame + } + start Main + "#, + ); + let marker_count = insts + .iter() + .filter(|i| matches!(&i.mode, AM::Label(l) if l == "__sprite_cycle_used")) + .count(); + assert_eq!( + marker_count, 1, + "multiple cycle_sprites calls should still produce exactly one marker label" + ); + // And all three calls should still emit their ADC. + let adc_count = insts + .iter() + .filter(|i| i.opcode == ADC && i.mode == AM::Immediate(0x04)) + .count(); + assert_eq!(adc_count, 3, "each cycle_sprites call should emit an ADC"); + } + + #[test] + fn ir_codegen_program_without_cycle_sprites_emits_no_marker() { + // Opt-in: programs that never call `cycle_sprites` must not + // emit the marker label, so the linker keeps the original + // fixed-offset OAM DMA path and existing goldens stay + // byte-identical. + let insts = lower_and_gen( + r#" + game "T" { mapper: NROM } + on frame { + draw Blip at: (10, 20) + wait_frame + } + start Main + "#, + ); + let has_marker = insts + .iter() + .any(|i| matches!(&i.mode, AM::Label(l) if l == "__sprite_cycle_used")); + assert!( + !has_marker, + "programs without cycle_sprites must not emit __sprite_cycle_used" + ); + } + #[test] fn ir_codegen_draw_in_loop_emits_one_cursor_based_draw_not_unrolled() { // Regression test for bug B. A `draw` inside a `while` diff --git a/src/ir/lowering.rs b/src/ir/lowering.rs index 696861c..fff5624 100644 --- a/src/ir/lowering.rs +++ b/src/ir/lowering.rs @@ -957,6 +957,9 @@ impl LoweringContext { Statement::WaitFrame(_) => { self.emit(IrOp::WaitFrame); } + Statement::CycleSprites(_) => { + self.emit(IrOp::CycleSprites); + } Statement::Call(name, args, _) => { match name.as_str() { // Built-in `poke(addr, value)` — write a byte to @@ -1550,7 +1553,7 @@ impl LoweringContext { Expr::DebugCall(method, _args, _) => { // The analyzer already validated the method name and // argument count, so we can dispatch on the method - // name directly. Both currently-supported methods + // name directly. All currently-supported methods // map to a Peek of a runtime address: the codegen // strips the read out and substitutes a constant // zero in release builds, so the builtin disappears @@ -1559,6 +1562,8 @@ impl LoweringContext { let addr: u16 = match method.as_str() { "frame_overrun_count" => 0x07FF, "frame_overran" => 0x07FE, + "sprite_overflow_count" => 0x07FD, + "sprite_overflow" => 0x07FC, // Should be unreachable post-analyzer, but emit // a zero rather than panicking so a parser test // that bypasses the analyzer still produces IR. @@ -1821,6 +1826,7 @@ fn is_splicable_void_stmt(stmt: &Statement) -> bool { | Statement::SetPalette(..) | Statement::LoadBackground(..) | Statement::WaitFrame(..) + | Statement::CycleSprites(..) | Statement::Play(..) | Statement::StartMusic(..) | Statement::StopMusic(..) diff --git a/src/ir/mod.rs b/src/ir/mod.rs index 84f7765..cb18efc 100644 --- a/src/ir/mod.rs +++ b/src/ir/mod.rs @@ -166,6 +166,12 @@ pub enum IrOp { /// Second arg: 0 for player 1, 1 for player 2. ReadInput(IrTemp, u8), WaitFrame, + /// `cycle_sprites` — bump the runtime sprite-cycling offset + /// byte at `$07EF` by 4, with natural u8 wrap. Paired with + /// the cycling variant of the NMI handler that reads this + /// byte into `OAM_ADDR` before the OAM DMA so each frame's DMA + /// lands in a different slot of the PPU OAM buffer. + CycleSprites, Transition(String), /// Write PPU scroll registers (two writes to $2005: X then Y). Scroll(IrTemp, IrTemp), diff --git a/src/lexer/mod.rs b/src/lexer/mod.rs index b9c8af7..83ad31a 100644 --- a/src/lexer/mod.rs +++ b/src/lexer/mod.rs @@ -482,6 +482,7 @@ impl<'a> Lexer<'a> { "bank" => TokenKind::KwBank, "loop" => TokenKind::KwLoop, "wait_frame" => TokenKind::KwWaitFrame, + "cycle_sprites" => TokenKind::KwCycleSprites, "u8" => TokenKind::KwU8, "i8" => TokenKind::KwI8, "u16" => TokenKind::KwU16, diff --git a/src/lexer/token.rs b/src/lexer/token.rs index 0e59d14..d9baf9a 100644 --- a/src/lexer/token.rs +++ b/src/lexer/token.rs @@ -83,6 +83,7 @@ pub enum TokenKind { KwBank, KwLoop, KwWaitFrame, + KwCycleSprites, KwU8, KwI8, KwU16, @@ -193,6 +194,7 @@ impl std::fmt::Display for TokenKind { Self::KwBank => write!(f, "bank"), Self::KwLoop => write!(f, "loop"), Self::KwWaitFrame => write!(f, "wait_frame"), + Self::KwCycleSprites => write!(f, "cycle_sprites"), Self::KwU8 => write!(f, "u8"), Self::KwI8 => write!(f, "i8"), Self::KwU16 => write!(f, "u16"), diff --git a/src/linker/mod.rs b/src/linker/mod.rs index 7356ce4..94ec068 100644 --- a/src/linker/mod.rs +++ b/src/linker/mod.rs @@ -579,7 +579,20 @@ impl Linker { // `--debug` is active; that tells the runtime to splice // in the extra frame-overrun check at the top of NMI. let debug_mode = has_label(user_code, "__debug_mode"); - all_instructions.extend(runtime::gen_nmi(has_ppu_updates, has_audio, debug_mode)); + // `__sprite_cycle_used` is dropped by the IR codegen + // whenever a `cycle_sprites` statement is lowered. When + // present, the NMI handler reads the rotating offset byte + // at $07EF instead of writing a literal 0 to $2003 before + // the OAM DMA, turning the classic "same sprites dropped + // every frame" hardware symptom into visible flicker that + // the eye reconstructs across frames. + let has_sprite_cycle = has_label(user_code, "__sprite_cycle_used"); + all_instructions.extend(runtime::gen_nmi(runtime::NmiOptions { + has_ppu_updates, + has_audio, + debug_mode, + has_sprite_cycle, + })); // IRQ handler all_instructions.push(Instruction::new(NOP, AM::Label("__irq".into()))); diff --git a/src/main.rs b/src/main.rs index 4de9982..779b6e4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -380,6 +380,19 @@ fn compile(input: &PathBuf, opts: &CompileOptions) -> Result, ()> { } })?; + // Render any analyzer warnings that survived a successful + // compile. Errors would have taken the `CompileError::Analyze` + // path above and returned before we got here, so everything + // left in `out.analysis.diagnostics` is a warning (W01xx). + // Without this the CLI would silently swallow every warning + // on a successful build, making them effectively invisible + // — the warning machinery in the analyzer would still run, + // but nobody would ever see its output unless they also + // invoked `nescript check`. + if !out.analysis.diagnostics.is_empty() { + render_diagnostics(&source, &filename, &out.analysis.diagnostics); + } + // Post-link CLI-only side effects: the various `--dump-*` // flags and the two optional file outputs. These are not // part of the pipeline because they're stdout / filesystem diff --git a/src/optimizer/mod.rs b/src/optimizer/mod.rs index 81219a6..139b892 100644 --- a/src/optimizer/mod.rs +++ b/src/optimizer/mod.rs @@ -571,6 +571,7 @@ fn collect_source_temps(op: &IrOp, used: &mut HashSet) { IrOp::LoadVarHi(_, _) | IrOp::ReadInput(_, _) | IrOp::WaitFrame + | IrOp::CycleSprites | IrOp::Transition(_) | IrOp::InlineAsm(_) | IrOp::Peek(_, _) @@ -617,6 +618,7 @@ fn op_dest(op: &IrOp) -> Option { | IrOp::ArrayStore(_, _, _) | IrOp::DrawSprite { .. } | IrOp::WaitFrame + | IrOp::CycleSprites | IrOp::Transition(_) | IrOp::Scroll(_, _) | IrOp::DebugLog(_) diff --git a/src/parser/ast.rs b/src/parser/ast.rs index 77fc28a..265ca1f 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -470,6 +470,15 @@ pub enum Statement { Draw(DrawStmt), Transition(String, Span), WaitFrame(Span), + /// `cycle_sprites` — advance the runtime sprite-cycling offset + /// by one slot (4 bytes). Each call rotates the start position + /// of the next OAM DMA so scenes with more than 8 sprites on a + /// scanline drop a different one each frame, turning permanent + /// dropout into visible flicker. Compiles to `INC $07EF` (with + /// natural u8 wrap at 256→0) plus the `__sprite_cycle_used` + /// marker label the linker uses to select the cycling variant + /// of the NMI handler. + CycleSprites(Span), Call(String, Vec, Span), /// `load_background Name` — queue the named background for a /// vblank-safe copy into nametable 0. Lowered to @@ -517,6 +526,7 @@ impl Statement { | Self::Return(_, s) | Self::Transition(_, s) | Self::WaitFrame(s) + | Self::CycleSprites(s) | Self::Call(_, _, s) | Self::LoadBackground(_, s) | Self::SetPalette(_, s) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 5047177..b5b5799 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -2369,6 +2369,11 @@ impl Parser { self.advance(); Ok(Statement::WaitFrame(span)) } + TokenKind::KwCycleSprites => { + let span = self.current_span(); + self.advance(); + Ok(Statement::CycleSprites(span)) + } TokenKind::KwLoadBackground => { let span = self.current_span(); self.advance(); diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 19b760b..ec322f7 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -110,6 +110,59 @@ pub const DEBUG_FRAME_OVERRUN_ADDR: u16 = 0x07FF; /// two can be inspected together in a Mesen memory viewer. pub const DEBUG_FRAME_OVERRUN_FLAG_ADDR: u16 = 0x07FE; +/// Debug-mode cumulative sprite-per-scanline overflow counter. +/// Incremented by the NMI handler once per frame in which the +/// PPU's sprite overflow flag ($2002 bit 5) was set, i.e. any +/// scanline of the just-finished frame had more than 8 sprites +/// on it and the PPU silently dropped the excess. Read with +/// `peek(0x07FD)` or `debug.sprite_overflow_count()`. +/// +/// The PPU hardware flag has two well-known quirks — it can +/// occasionally miss the 9th sprite or flag when none actually +/// overflowed — but it's right for the overwhelming majority of +/// cases and is essentially free to sample (one `LDA $2002; AND +/// #$20` at the top of NMI). Pairs with the compile-time W0109 +/// warning: W0109 catches layouts knowable at compile time (text, +/// HUD, title screens) and this counter catches the dynamic +/// cases (enemy formations, projectile clusters) during +/// playtesting in debug builds. Release-mode ROMs never touch +/// this slot, so the analyzer is free to allocate over it. +pub const DEBUG_SPRITE_OVERFLOW_COUNT_ADDR: u16 = 0x07FD; + +/// Debug-mode "did the previous frame hit the 8-sprites-per- +/// scanline limit" sticky bit. Set by the NMI handler together +/// with [`DEBUG_SPRITE_OVERFLOW_COUNT_ADDR`], and cleared to 0 +/// by every `wait_frame` IR op (or the implicit main-loop +/// clear) so user code sees a fresh value every frame. +/// Exposed to user code as `debug.sprite_overflow()`, a +/// per-frame boolean suited for +/// `debug.assert(not debug.sprite_overflow())` guards during +/// playtesting. +pub const DEBUG_SPRITE_OVERFLOW_FLAG_ADDR: u16 = 0x07FC; + +/// Runtime sprite-cycling offset. When any program statement +/// emits a `cycle_sprites` call the codegen drops the +/// `__sprite_cycle_used` marker, and the linker builds the +/// cycling variant of the NMI handler: instead of writing 0 +/// to `OAM_ADDR` before the OAM DMA, it writes the current value +/// of this byte, which rotates the destination slot of the DMA +/// copy around the 64-slot OAM buffer. `cycle_sprites` adds 4 +/// to this byte each call (naturally wrapping at 256 back to 0), +/// moving the copy start by one OAM slot per tick. +/// +/// The result is the classic NES "sprite flicker" pattern: a +/// scene with >8 sprites on a scanline drops a different one +/// each frame rather than the same one every frame, so users +/// perceive flicker instead of permanent dropout — vastly +/// better UX because the eye reconstructs the missing pixels +/// from adjacent frames. +/// +/// Programs that never use `cycle_sprites` leave this byte at +/// 0 forever and the NMI handler emits the original `LDA #0; +/// STA $2003` sequence, preserving byte-for-byte compatibility +/// with every existing golden ROM. +pub const SPRITE_CYCLE_ADDR: u16 = 0x07EF; + // ── Extra channel state ── // // The pulse-1 sfx and pulse-2 music channels live in zero page @@ -283,8 +336,41 @@ pub fn gen_enable_rendering(show_background: bool) -> Vec { /// [`DEBUG_FRAME_OVERRUN_ADDR`]. Release-mode ROMs never call /// this with `debug_mode=true`, so the counter slot stays free /// for user allocation. +/// +/// `has_sprite_cycle` selects between two OAM DMA setup paths: +/// when false the NMI writes a literal 0 to `$2003` before +/// triggering the DMA (classic behaviour, byte-identical to +/// every pre-cycling ROM), and when true it reads the rotating +/// offset byte from [`SPRITE_CYCLE_ADDR`] so each frame's DMA +/// lands in a different slot of the PPU's OAM buffer. The +/// per-frame increment is emitted at the `cycle_sprites` call +/// site, not here, so programs can choose to cycle every frame +/// (one call in `on frame`) or every Nth frame. +/// Compile-time switches that pick which NMI-handler variant +/// the runtime emits. Each bool either inlines or skips a +/// self-contained block inside [`gen_nmi`]; programs that don't +/// opt into a feature pay zero ROM/cycle cost for it. Grouped +/// into a struct rather than passed as individual parameters +/// to avoid tripping the clippy `fn_params_excessive_bools` +/// lint and to give future additions (another marker-label- +/// triggered NMI block) an obvious extension point. +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone, Copy, Default)] +pub struct NmiOptions { + pub has_ppu_updates: bool, + pub has_audio: bool, + pub debug_mode: bool, + pub has_sprite_cycle: bool, +} + #[must_use] -pub fn gen_nmi(has_ppu_updates: bool, has_audio: bool, debug_mode: bool) -> Vec { +pub fn gen_nmi(opts: NmiOptions) -> Vec { + let NmiOptions { + has_ppu_updates, + has_audio, + debug_mode, + has_sprite_cycle, + } = opts; let mut out = Vec::new(); // Save registers @@ -304,6 +390,52 @@ pub fn gen_nmi(has_ppu_updates: bool, has_audio: bool, debug_mode: bool) -> Vec< out.push(Instruction::new(LDA, AM::ZeroPage(0x03))); out.push(Instruction::implied(PHA)); + // Debug-mode sprite overflow sampling. The PPU sets bit 5 of + // $2002 when its sprite evaluation hits more than 8 in-range + // sprites on any scanline of the frame it just finished + // rendering. NMI fires at the start of vblank, right after + // that rendering ends, so this is the exact moment the flag + // is valid for "did the just-finished frame overflow". The + // flag is cleared by the PPU at dot 1 of the pre-render line + // (261), which is *before* the next NMI, so each NMI sees a + // flag that reflects only the frame it follows. + // + // Reading $2002 has the side effects of (a) clearing the + // vblank latch in bit 7 and (b) resetting the $2005/$2006 + // write-toggle. Both are harmless here: NMI was already + // taken, and `gen_ppu_update_apply` below always opens its + // own $2006 address with a fresh pair of writes so the reset + // toggle doesn't confuse it. + // + // The counter/sticky pair mirrors the frame-overrun pattern + // at $07FE/$07FF. Release builds don't emit this block at + // all, so the two bytes at $07FC/$07FD stay free for the + // analyzer to allocate over. + if debug_mode { + out.push(Instruction::new(LDA, AM::Absolute(PPU_STATUS))); + out.push(Instruction::new(AND, AM::Immediate(0x20))); + out.push(Instruction::new( + BEQ, + AM::LabelRelative("__debug_no_sprite_ovf".into()), + )); + out.push(Instruction::new( + INC, + AM::Absolute(DEBUG_SPRITE_OVERFLOW_COUNT_ADDR), + )); + // Reuse A, which still holds 0x20. Nonzero is enough for + // the sticky bit; the exact value doesn't matter because + // user code reads it as a boolean via + // `debug.sprite_overflow()`. + out.push(Instruction::new( + STA, + AM::Absolute(DEBUG_SPRITE_OVERFLOW_FLAG_ADDR), + )); + out.push(Instruction::new( + NOP, + AM::Label("__debug_no_sprite_ovf".into()), + )); + } + // Run the audio driver's per-frame tick *after* the saves so it // can freely reuse A/X/Y and the $02/$03 scratch slots without // corrupting anything the main loop cares about. Programs that @@ -312,8 +444,16 @@ pub fn gen_nmi(has_ppu_updates: bool, has_audio: bool, debug_mode: bool) -> Vec< out.push(Instruction::new(JSR, AM::Label("__audio_tick".into()))); } - // OAM DMA — transfer sprite data from $0200 - out.push(Instruction::new(LDA, AM::Immediate(0x00))); + // OAM DMA — transfer sprite data from $0200. Programs that + // don't use `cycle_sprites` get the classic fixed-offset + // path (LDA #0); programs that opt in get the rotating + // offset read from SPRITE_CYCLE_ADDR. Both variants write + // the same low byte ($02) for the DMA source page. + if has_sprite_cycle { + out.push(Instruction::new(LDA, AM::Absolute(SPRITE_CYCLE_ADDR))); + } else { + out.push(Instruction::new(LDA, AM::Immediate(0x00))); + } out.push(Instruction::new(STA, AM::Absolute(OAM_ADDR))); out.push(Instruction::new(LDA, AM::Immediate(0x02))); out.push(Instruction::new(STA, AM::Absolute(OAM_DMA))); diff --git a/src/runtime/tests.rs b/src/runtime/tests.rs index ce66549..a368aef 100644 --- a/src/runtime/tests.rs +++ b/src/runtime/tests.rs @@ -70,7 +70,7 @@ fn init_assembles_without_error() { #[test] fn nmi_saves_and_restores_registers() { - let nmi = gen_nmi(false, false, false); + let nmi = gen_nmi(NmiOptions::default()); // First three instructions should push A, X, Y assert_eq!(nmi[0].opcode, PHA); assert_eq!(nmi[1].opcode, TXA); @@ -86,7 +86,7 @@ fn nmi_saves_and_restores_registers() { #[test] fn nmi_triggers_oam_dma() { - let nmi = gen_nmi(false, false, false); + let nmi = gen_nmi(NmiOptions::default()); let has_dma = nmi .iter() .any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4014)); @@ -95,7 +95,7 @@ fn nmi_triggers_oam_dma() { #[test] fn nmi_reads_controller() { - let nmi = gen_nmi(false, false, false); + let nmi = gen_nmi(NmiOptions::default()); // Should write strobe to $4016 let has_strobe = nmi .iter() @@ -105,7 +105,7 @@ fn nmi_reads_controller() { #[test] fn nmi_sets_frame_flag() { - let nmi = gen_nmi(false, false, false); + let nmi = gen_nmi(NmiOptions::default()); let has_flag = nmi .iter() .any(|i| i.opcode == STA && i.mode == AM::ZeroPage(ZP_FRAME_FLAG)); @@ -114,7 +114,7 @@ fn nmi_sets_frame_flag() { #[test] fn nmi_assembles_without_error() { - let nmi = gen_nmi(false, false, false); + let nmi = gen_nmi(NmiOptions::default()); let result = asm::assemble(&nmi, 0xF000); assert!(!result.bytes.is_empty()); assert!( @@ -132,7 +132,10 @@ fn nmi_debug_mode_bumps_overrun_counter() { // bump when the frame flag was clear. Without `debug_mode`, // neither the `INC` nor the guard label appear so release // builds keep the top byte of RAM free for user allocation. - let nmi = gen_nmi(false, false, true); + let nmi = gen_nmi(NmiOptions { + debug_mode: true, + ..NmiOptions::default() + }); let has_inc = nmi.iter().any(|i| { i.opcode == INC && matches!(i.mode, AM::Absolute(a) if a == DEBUG_FRAME_OVERRUN_ADDR) }); @@ -141,7 +144,7 @@ fn nmi_debug_mode_bumps_overrun_counter() { "debug-mode NMI should INC the overrun counter at $07FF" ); - let release_nmi = gen_nmi(false, false, false); + let release_nmi = gen_nmi(NmiOptions::default()); let has_inc_release = release_nmi.iter().any(|i| { i.opcode == INC && matches!(i.mode, AM::Absolute(a) if a == DEBUG_FRAME_OVERRUN_ADDR) }); @@ -151,6 +154,77 @@ fn nmi_debug_mode_bumps_overrun_counter() { ); } +#[test] +fn nmi_debug_mode_samples_sprite_overflow() { + // Debug NMI should read $2002 and INC the sprite overflow + // counter at DEBUG_SPRITE_OVERFLOW_COUNT_ADDR when bit 5 is + // set. Release NMI must not touch either. + let nmi = gen_nmi(NmiOptions { + debug_mode: true, + ..NmiOptions::default() + }); + let has_status_read = nmi + .iter() + .any(|i| i.opcode == LDA && i.mode == AM::Absolute(0x2002)); + assert!( + has_status_read, + "debug-mode NMI should read $2002 to sample sprite overflow" + ); + let has_inc = nmi.iter().any(|i| { + i.opcode == INC + && matches!(i.mode, AM::Absolute(a) if a == DEBUG_SPRITE_OVERFLOW_COUNT_ADDR) + }); + assert!( + has_inc, + "debug-mode NMI should INC the sprite overflow counter at $07FD" + ); + let has_sticky = nmi.iter().any(|i| { + i.opcode == STA && matches!(i.mode, AM::Absolute(a) if a == DEBUG_SPRITE_OVERFLOW_FLAG_ADDR) + }); + assert!( + has_sticky, + "debug-mode NMI should set the sprite overflow sticky bit at $07FC" + ); + + let release_nmi = gen_nmi(NmiOptions::default()); + let has_inc_release = release_nmi.iter().any(|i| { + i.opcode == INC + && matches!(i.mode, AM::Absolute(a) if a == DEBUG_SPRITE_OVERFLOW_COUNT_ADDR) + }); + assert!( + !has_inc_release, + "release NMI must not touch the sprite overflow counter slot" + ); +} + +#[test] +fn nmi_sprite_cycle_variant_reads_rotating_offset() { + // With `has_sprite_cycle = true`, the NMI handler should + // read SPRITE_CYCLE_ADDR and write it to OAM_ADDR ($2003) + // before the DMA, instead of the default fixed 0. The + // default variant must stay byte-identical to legacy NMI. + let cycling = gen_nmi(NmiOptions { + has_sprite_cycle: true, + ..NmiOptions::default() + }); + let reads_cycle = cycling + .iter() + .any(|i| i.opcode == LDA && i.mode == AM::Absolute(SPRITE_CYCLE_ADDR)); + assert!( + reads_cycle, + "sprite-cycling NMI should read SPRITE_CYCLE_ADDR" + ); + + let default = gen_nmi(NmiOptions::default()); + let reads_cycle_default = default + .iter() + .any(|i| i.opcode == LDA && i.mode == AM::Absolute(SPRITE_CYCLE_ADDR)); + assert!( + !reads_cycle_default, + "default NMI must not touch SPRITE_CYCLE_ADDR" + ); +} + #[test] fn irq_handler_is_just_rti() { let irq = gen_irq(); diff --git a/tests/emulator/goldens/sprite_flicker_demo.audio.hash b/tests/emulator/goldens/sprite_flicker_demo.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/sprite_flicker_demo.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/sprite_flicker_demo.png b/tests/emulator/goldens/sprite_flicker_demo.png new file mode 100644 index 0000000000000000000000000000000000000000..89a283fa43e7d3115f1c7021b46e0547a8457e26 GIT binary patch literal 1422 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQ4fS+!45?szdvJHJ%1$xY zi?QOm0ldZyMD>?3fAO~{`2*^cltd>bjUpmL;O_(TmwEG! zN$gp?yHYCoL*Kh+nLP)scgd->~mJ%$thEqNqf z3RJVM`cu)}$(zORo;?rJLKSZB7j@D7CvSF7-z@&#s`_Kmw@0z*`f=yt_Pl;`^z_Z{ z^0|8}fUaM+SzJF|f64xZKYSs!mdAd7@^qK_d!S!Vm-^o;y*qjO>v?-#?^4&lZd-Z1 z)IV-rwbEZ+`yamN;$pr(ff%&o-ARZog?A?dO}kh6?a?mvc!*V{{`-I-bNy!b>v=Vn z)rx<4sT6Sh4<_GVfH#sLvDf%zc`d_ykN=b2J$rMs)c;&u&CYkgPyq%14|PyXyq-5_ z?+>oP6=)XoJBZvzfoA@6Hdrsu?+&GE`Bn1Ak+o=S}d1p3$0Ts8Nu6{1-oD!M<`&f9} literal 0 HcmV?d00001