1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 00:45:38 +00:00

sprite-per-scanline: add cycle_sprites runtime flicker + debug telemetry

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
This commit is contained in:
Claude 2026-04-15 22:07:19 +00:00
parent d6cb84a5bd
commit 5e5bed39a5
No known key found for this signature in database
21 changed files with 739 additions and 24 deletions

View file

@ -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. |

View file

@ -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

View file

@ -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

Binary file not shown.

View file

@ -471,6 +471,99 @@ Five tests in `src/analyzer/tests.rs`:
draws inside an `if` block still trip W0109 (conservative
over-count).
### Layer-2: runtime sprite cycling (`cycle_sprites`)
W0109 only catches the literal-coordinate case — a game with
>8 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)*

View file

@ -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<String>) {
Statement::Return(None, _)
| Statement::Transition(_, _)
| Statement::WaitFrame(_)
| Statement::CycleSprites(_)
| Statement::Break(_)
| Statement::Continue(_)
| Statement::InlineAsm(_, _)

View file

@ -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
"#,
);
}

View file

@ -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<IrTemp> {
} => 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`

View file

@ -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(..)

View file

@ -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),

View file

@ -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,

View file

@ -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"),

View file

@ -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())));

View file

@ -380,6 +380,19 @@ fn compile(input: &PathBuf, opts: &CompileOptions) -> Result<Vec<u8>, ()> {
}
})?;
// 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

View file

@ -571,6 +571,7 @@ fn collect_source_temps(op: &IrOp, used: &mut HashSet<IrTemp>) {
IrOp::LoadVarHi(_, _)
| IrOp::ReadInput(_, _)
| IrOp::WaitFrame
| IrOp::CycleSprites
| IrOp::Transition(_)
| IrOp::InlineAsm(_)
| IrOp::Peek(_, _)
@ -617,6 +618,7 @@ fn op_dest(op: &IrOp) -> Option<IrTemp> {
| IrOp::ArrayStore(_, _, _)
| IrOp::DrawSprite { .. }
| IrOp::WaitFrame
| IrOp::CycleSprites
| IrOp::Transition(_)
| IrOp::Scroll(_, _)
| IrOp::DebugLog(_)

View file

@ -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<Expr>, 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)

View file

@ -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();

View file

@ -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<Instruction> {
/// [`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<Instruction> {
pub fn gen_nmi(opts: NmiOptions) -> Vec<Instruction> {
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)));

View file

@ -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();

View file

@ -0,0 +1 @@
a82b6ff5 132084

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB