diff --git a/examples/auto_chr_background.nes b/examples/auto_chr_background.nes index 5fed29a..e80d644 100644 Binary files a/examples/auto_chr_background.nes and b/examples/auto_chr_background.nes differ diff --git a/examples/sfx_pitch_envelope.nes b/examples/sfx_pitch_envelope.nes index d75847b..652d4e4 100644 Binary files a/examples/sfx_pitch_envelope.nes and b/examples/sfx_pitch_envelope.nes differ diff --git a/src/linker/mod.rs b/src/linker/mod.rs index f5b3a97..11848ec 100644 --- a/src/linker/mod.rs +++ b/src/linker/mod.rs @@ -396,11 +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 + // draw save ~520 cycles per NMI plus a handful of bytes. + let has_oam = has_label(user_code, "__oam_used"); + // RESET entry point all_instructions.push(Instruction::new(NOP, AM::Label("__reset".into()))); // Hardware initialization - all_instructions.extend(runtime::gen_init()); + all_instructions.extend(runtime::gen_init(has_oam)); // Mapper configuration: for banked mappers, set up the PRG // layout so the fixed bank sits at $C000-$FFFF. NROM is a @@ -413,17 +421,14 @@ impl Linker { // Whether the program produces any visual output. True if // the user declared a palette / sprite / background, or if - // user code contains the `__oam_used` marker (emitted by - // `IrOp::DrawSprite`). A purely audio- or compute-only - // program is happy to leave the PPU fully silent — no - // palette load, no rendering enable, no default-sprite - // smiley in CHR — so we gate the reset-time palette - // machinery on this flag. See the sprite-chr / OAM-DMA - // gates below for the other places it cascades. - let has_visual_output = !palettes.is_empty() - || !sprites.is_empty() - || !backgrounds.is_empty() - || has_label(user_code, "__oam_used"); + // user code contains the `__oam_used` marker (i.e. draws). + // A purely audio- or compute-only program is happy to leave + // the PPU fully silent — no palette load, no rendering + // enable, no default-sprite smiley in CHR — so we gate the + // reset-time palette machinery on this flag. See the + // sprite-chr / OAM-DMA gates for the other places it cascades. + let has_visual_output = + !palettes.is_empty() || !sprites.is_empty() || !backgrounds.is_empty() || has_oam; // Load the initial palette. If the program declared any // `palette` blocks, use the first one; otherwise fall back @@ -662,6 +667,7 @@ impl Linker { has_audio, debug_mode, has_sprite_cycle, + has_oam, })); // IRQ handler diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index ec322f7..d54329b 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -200,7 +200,17 @@ pub const AUDIO_SFX_PITCH_PTR_HI: u16 = 0x07F7; /// Generate the NES hardware initialization sequence. /// This runs at RESET and sets up the hardware before user code. -pub fn gen_init() -> Vec { +/// +/// `has_oam` controls the $0200 OAM shadow fill inside the RAM +/// clear loop. When true, the shadow is filled with `$FE` so any +/// sprite slot not explicitly written by a later `draw` stays +/// off-screen (Y = $FE is out of the 240-line viewport). When +/// false — i.e. the program never draws — the shadow stays at $00 +/// alongside the rest of RAM, saving ~4 bytes of init code since +/// the inner-loop doesn't need the two extra LDA / mid-loop value +/// swaps to load and restore the `$FE` immediate. +#[must_use] +pub fn gen_init(has_oam: bool) -> Vec { let mut out = Vec::new(); // Disable IRQs and set decimal mode off @@ -252,16 +262,24 @@ pub fn gen_init() -> Vec { AM::LabelRelative("__vblankwait1".into()), )); - // Clear RAM ($0000-$07FF) + // Clear RAM ($0000-$07FF). When the program uses sprites, the + // $0200-$02FF OAM shadow gets filled with $FE so unused slots + // stay off-screen (Y=$FE puts the sprite below scanline 240); + // programs that never draw skip that swap and zero-fill the + // whole 2 KB range uniformly. out.push(Instruction::new(LDA, AM::Immediate(0x00))); out.push(Instruction::new(LDX, AM::Immediate(0x00))); out.push(Instruction::new(NOP, AM::Label("__clrmem".into()))); out.push(Instruction::new(STA, AM::AbsoluteX(0x0000))); out.push(Instruction::new(STA, AM::AbsoluteX(0x0100))); - // OAM shadow: fill with $FE (hide sprites off-screen) - out.push(Instruction::new(LDA, AM::Immediate(0xFE))); - out.push(Instruction::new(STA, AM::AbsoluteX(0x0200))); - out.push(Instruction::new(LDA, AM::Immediate(0x00))); + if has_oam { + // OAM shadow: fill with $FE (hide sprites off-screen). + out.push(Instruction::new(LDA, AM::Immediate(0xFE))); + out.push(Instruction::new(STA, AM::AbsoluteX(0x0200))); + out.push(Instruction::new(LDA, AM::Immediate(0x00))); + } else { + out.push(Instruction::new(STA, AM::AbsoluteX(0x0200))); + } out.push(Instruction::new(STA, AM::AbsoluteX(0x0300))); out.push(Instruction::new(STA, AM::AbsoluteX(0x0400))); out.push(Instruction::new(STA, AM::AbsoluteX(0x0500))); @@ -361,6 +379,13 @@ pub struct NmiOptions { pub has_audio: bool, pub debug_mode: bool, pub has_sprite_cycle: bool, + /// When false, skip the OAM DMA (`$2003` address write + `$4014` + /// DMA trigger). A program that never `draw`s sprites has + /// nothing interesting to transfer — every one of the 256 OAM + /// bytes is either the $FE hide sentinel or a zeroed row — + /// so the DMA is ~520 wasted cycles per NMI. Skipping it saves + /// those cycles plus 9 bytes of NMI code. + pub has_oam: bool, } #[must_use] @@ -370,6 +395,7 @@ pub fn gen_nmi(opts: NmiOptions) -> Vec { has_audio, debug_mode, has_sprite_cycle, + has_oam, } = opts; let mut out = Vec::new(); @@ -444,19 +470,26 @@ pub fn gen_nmi(opts: NmiOptions) -> Vec { out.push(Instruction::new(JSR, AM::Label("__audio_tick".into()))); } - // 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))); + // OAM DMA — transfer sprite data from $0200. Skipped entirely + // for programs that never `draw`: gated on `has_oam`, which the + // linker sets from the `__oam_used` marker the IR codegen drops + // at the first `IrOp::DrawSprite`. `has_sprite_cycle` is a + // strict subset of `has_oam` so a program that opts into cycling + // has definitely also drawn something. + // + // Programs that *do* draw choose between two paths: the classic + // fixed-offset (LDA #0) and the rotating offset read from + // SPRITE_CYCLE_ADDR used by `cycle_sprites`. + if has_oam { + 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))); } - 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))); // PPU updates: check the flags byte, apply any pending palette // or background write. Runs inside vblank where $2006/$2007 diff --git a/src/runtime/tests.rs b/src/runtime/tests.rs index a368aef..f34a260 100644 --- a/src/runtime/tests.rs +++ b/src/runtime/tests.rs @@ -4,13 +4,13 @@ use crate::asm::{AddressingMode as AM, Opcode::*}; #[test] fn init_disables_irq() { - let init = gen_init(); + let init = gen_init(true); assert_eq!(init[0].opcode, SEI); } #[test] fn init_sets_stack_pointer() { - let init = gen_init(); + let init = gen_init(true); // LDX #$FF, TXS let has_ldx = init .iter() @@ -22,7 +22,7 @@ fn init_sets_stack_pointer() { #[test] fn init_disables_ppu() { - let init = gen_init(); + let init = gen_init(true); // Should write 0 to $2000 and $2001 let writes_ppu_ctrl = init .iter() @@ -36,7 +36,7 @@ fn init_disables_ppu() { #[test] fn init_enables_nmi_at_end() { - let init = gen_init(); + let init = gen_init(true); // Last STA $2000 should enable NMI (bit 7 set = 0x80) let nmi_writes: Vec<_> = init .iter() @@ -56,7 +56,7 @@ fn init_enables_nmi_at_end() { #[test] fn init_assembles_without_error() { - let init = gen_init(); + let init = gen_init(true); let result = asm::assemble(&init, 0x8000); // Should produce non-empty output assert!(!result.bytes.is_empty(), "init should produce bytes"); @@ -85,12 +85,27 @@ fn nmi_saves_and_restores_registers() { } #[test] -fn nmi_triggers_oam_dma() { +fn nmi_triggers_oam_dma_when_requested() { + let nmi = gen_nmi(NmiOptions { + has_oam: true, + ..NmiOptions::default() + }); + let has_dma = nmi + .iter() + .any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4014)); + assert!(has_dma, "NMI should trigger OAM DMA when has_oam is set"); +} + +#[test] +fn nmi_skips_oam_dma_by_default() { + // With `has_oam = false` (the default) the NMI must not write + // the OAM DMA trigger at $4014, saving ~520 cycles per frame + // for programs that don't `draw`. let nmi = gen_nmi(NmiOptions::default()); let has_dma = nmi .iter() .any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4014)); - assert!(has_dma, "NMI should trigger OAM DMA"); + assert!(!has_dma, "NMI should skip OAM DMA when has_oam is unset"); } #[test] @@ -203,8 +218,11 @@ fn nmi_sprite_cycle_variant_reads_rotating_offset() { // 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. + // Sprite cycling is always paired with `has_oam = true` + // (cycling is pointless without the DMA). let cycling = gen_nmi(NmiOptions { has_sprite_cycle: true, + has_oam: true, ..NmiOptions::default() }); let reads_cycle = cycling diff --git a/tests/emulator/goldens/sfx_pitch_envelope.audio.hash b/tests/emulator/goldens/sfx_pitch_envelope.audio.hash index 8d0c60d..025c719 100644 --- a/tests/emulator/goldens/sfx_pitch_envelope.audio.hash +++ b/tests/emulator/goldens/sfx_pitch_envelope.audio.hash @@ -1 +1 @@ -9c881965 132084 +7be9372c 132084