diff --git a/src/linker/mod.rs b/src/linker/mod.rs index ad5ea76..5c9bbe2 100644 --- a/src/linker/mod.rs +++ b/src/linker/mod.rs @@ -396,13 +396,19 @@ impl Linker { let mut all_instructions = Vec::new(); - // `__oam_used` marker from IR codegen — set whenever user - // code contains at least one `draw` statement. Gates the - // `$FE` OAM shadow fill inside `gen_init`, the OAM DMA - // inside `gen_nmi`, and (cascaded below) the default palette - // / smiley / rendering-enable machinery. Programs that don't + // Does this program need the OAM DMA plumbing? True when + // either of two markers the IR codegen drops is present: + // - `__oam_used`: user code contains at least one `draw`. + // - `__sprite_cycle_used`: user code calls `cycle_sprites` + // (which rotates the DMA start offset each frame; it + // presupposes the DMA is running). + // Gates the `$FE` OAM shadow fill inside `gen_init`, the + // OAM DMA inside `gen_nmi`, and — cascaded via the + // `has_visual_output` check below — the default palette / + // smiley / rendering-enable machinery. Programs that don't // draw save ~520 cycles per NMI plus a handful of bytes. - let has_oam = has_label(user_code, "__oam_used"); + let has_oam = + has_label(user_code, "__oam_used") || has_label(user_code, "__sprite_cycle_used"); // RESET entry point all_instructions.push(Instruction::new(NOP, AM::Label("__reset".into()))); diff --git a/src/linker/tests.rs b/src/linker/tests.rs index 22614b6..66ce23d 100644 --- a/src/linker/tests.rs +++ b/src/linker/tests.rs @@ -97,6 +97,28 @@ fn link_rom_size_correct() { assert_eq!(rom_data.len(), 16 + 16384 + 8192); } +#[test] +fn sprite_cycle_marker_implies_oam_dma() { + // `cycle_sprites` rotates the OAM DMA start offset each frame; + // without the DMA itself the cycling is a no-op. The linker's + // `has_oam` gate therefore ORs `__sprite_cycle_used` with + // `__oam_used` so a program that only cycles still gets the + // DMA plumbing. The NMI of such a program writes the DMA + // trigger at `$4014`. + let linker = Linker::new(Mirroring::Horizontal); + let user_code = vec![ + Instruction::new(NOP, AM::Label("__sprite_cycle_used".into())), + Instruction::implied(NOP), + ]; + let rom = linker.link(&user_code); + // The OAM DMA write is `STA $4014` — `8D 14 40` in bytes. + let prg = &rom[16..16 + 16_384]; + assert!( + prg.windows(3).any(|w| w == [0x8D, 0x14, 0x40]), + "cycle_sprites should force the OAM DMA even without a draw" + ); +} + #[test] fn link_with_sprites_places_chr_data() { let linker = Linker::new(Mirroring::Horizontal); diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 552ea0e..ebe2d57 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -388,29 +388,19 @@ pub struct NmiOptions { pub has_oam: bool, /// When false, drop the three instructions that shift `$4017` /// (JOY2) into `ZP_INPUT_P2` from the NMI's 8-iteration input - /// loop. Single-player programs save ~6 bytes of code and ~30 - /// cycles per frame (a `LDA abs`, an `LSR A`, and a `ROL zp` - /// running 8 times). + /// loop. Single-player programs save ~6 bytes of code and ~88 + /// cycles per frame (LDA abs + LSR A + ROL zp = 11 cycles × 8 + /// iterations). pub has_p2_input: bool, /// When false, drop the three instructions that shift `$4016` /// (JOY1) into `ZP_INPUT_P1`. If both `has_p1_input` and /// `has_p2_input` are false the whole strobe-and-loop block /// disappears — programs that never touch `button.*` pay - /// zero cycles for input sampling. + /// zero cycles for input sampling (~100 cycles: the 88-cycle + /// body plus the ~12-cycle strobe and loop scaffold). pub has_p1_input: bool, } -impl NmiOptions { - /// Whether the program reads any controller input — the - /// necessary condition for emitting the strobe write to - /// `$4016` and the 8-iteration shift loop. Skipped entirely - /// when both ports are unused. - #[must_use] - pub fn any_input(&self) -> bool { - self.has_p1_input || self.has_p2_input - } -} - #[must_use] pub fn gen_nmi(opts: NmiOptions) -> Vec { let NmiOptions {