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

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
This commit is contained in:
Claude 2026-04-16 13:38:27 +00:00
parent 6561daff35
commit bd30ac3010
No known key found for this signature in database
6 changed files with 95 additions and 38 deletions

Binary file not shown.

Binary file not shown.

View file

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

View file

@ -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<Instruction> {
///
/// `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<Instruction> {
let mut out = Vec::new();
// Disable IRQs and set decimal mode off
@ -252,16 +262,24 @@ pub fn gen_init() -> Vec<Instruction> {
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<Instruction> {
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<Instruction> {
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

View file

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

View file

@ -1 +1 @@
9c881965 132084
7be9372c 132084