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:
parent
6561daff35
commit
bd30ac3010
6 changed files with 95 additions and 38 deletions
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
9c881965 132084
|
||||
7be9372c 132084
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue