From bd30ac301087d497b9f9574c3790b06247e5e614 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 13:38:27 +0000 Subject: [PATCH] runtime: gate OAM DMA and OAM shadow init on __oam_used Skip the OAM DMA (LDA#0/STA \$2003 + LDA#2/STA \$4014) inside the NMI handler and the `\$FE` hide-sentinel fill of the \$0200 OAM shadow inside `gen_init` for programs that never `draw`. Both are gated on the `__oam_used` marker the IR codegen now drops at the first `IrOp::DrawSprite`. Savings per NMI for a non-drawing program: - ~520 cycles (the DMA is 513 cycles plus the 4 register writes), - ~9 bytes of NMI code, - ~4 bytes of init code (the \$FE swap is replaced by a plain zero-fill of \$0200-\$02FF alongside the rest of the 2 KB RAM clear). Plumbed by: - New `NmiOptions::has_oam: bool`, threaded through `gen_nmi`. - `gen_init(has_oam: bool)` parameter controlling the inner-loop OAM fill. Existing runtime tests all migrate to `gen_init(true)` to preserve their legacy assertions. - Linker computes `has_oam = has_label(user_code, "__oam_used")` once and feeds it to both call sites, and the existing `has_visual_output` predicate reuses the same lookup rather than re-scanning user_code. sfx_pitch_envelope is the one audio-only example; its audio golden flips by the usual cycle-accurate-APU-register-write-timing drift caused by the NMI layout shifting ~14 bytes earlier. https://claude.ai/code/session_016kM6P7PukktBDqTZexrrAN --- examples/auto_chr_background.nes | Bin 24592 -> 24592 bytes examples/sfx_pitch_envelope.nes | Bin 24592 -> 24592 bytes src/linker/mod.rs | 30 +++++--- src/runtime/mod.rs | 69 +++++++++++++----- src/runtime/tests.rs | 32 ++++++-- .../goldens/sfx_pitch_envelope.audio.hash | 2 +- 6 files changed, 95 insertions(+), 38 deletions(-) diff --git a/examples/auto_chr_background.nes b/examples/auto_chr_background.nes index 5fed29a7629e43480922e5323cde769b05a40040..e80d64439acd5d0616d14beedefc1c914b75e717 100644 GIT binary patch delta 110 zcmbPmfN=s4dF#z(V4BOoJePrGE(7aa2DZ5j>@O}n)nQT)_`R~BmqB4Ilfp{-UN(i5 z3?Sn9#99SL_KEXU8IMmqqQ!Iiz*2?}e^xTIGWeX{_)CXnvjOWZtH~Ew`PkTxG8~wH Obn-)oh|L)eE93!(11d%U delta 101 zcmV-r0Gj`hzyXlJ0gy`>ss5b+0;vF<00W%>1f2i{od5=%00-#M;gMh%0qc=@AOQ=J znk50tk-#bp&%mVs@cgL&g#b*?vGyzk3aJ2%10bmajTAt$A_dwy1`EXiz^%oT;Xqik HXh5hRr9dZY diff --git a/examples/sfx_pitch_envelope.nes b/examples/sfx_pitch_envelope.nes index d75847bde4c349d02acbc762e0ccde40de03d53f..652d4e435bda0847d49c95b449335637a3bd8606 100644 GIT binary patch delta 212 zcmV;_04x8HzyXlJ0gy{Vod5!z00W%>1f2i{od5=%00-#Mss5b+0;vF<00W%>1f2i{od5=%00-#M;gMh;Ph`O65Ty{w zJn#jn08E6ysR8gLsQ`r#sX&baK&c3g13;|v45=`U06?h#g$hjUkNf5kUVR0RUYnQmX&} 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