diff --git a/examples/arrays_and_functions.nes b/examples/arrays_and_functions.nes index 9faa196..4c008ab 100644 Binary files a/examples/arrays_and_functions.nes and b/examples/arrays_and_functions.nes differ diff --git a/examples/audio_demo.nes b/examples/audio_demo.nes index a8dee48..af2fc4d 100644 Binary files a/examples/audio_demo.nes and b/examples/audio_demo.nes differ diff --git a/examples/auto_chr_background.nes b/examples/auto_chr_background.nes index c921938..7d808ba 100644 Binary files a/examples/auto_chr_background.nes and b/examples/auto_chr_background.nes differ diff --git a/examples/bitwise_ops.nes b/examples/bitwise_ops.nes index 9f298a8..fa1b592 100644 Binary files a/examples/bitwise_ops.nes and b/examples/bitwise_ops.nes differ diff --git a/examples/bouncing_ball.nes b/examples/bouncing_ball.nes index 9959992..19f3d91 100644 Binary files a/examples/bouncing_ball.nes and b/examples/bouncing_ball.nes differ diff --git a/examples/coin_cavern.nes b/examples/coin_cavern.nes index 8fcf88a..4be191b 100644 Binary files a/examples/coin_cavern.nes and b/examples/coin_cavern.nes differ diff --git a/examples/comparisons.nes b/examples/comparisons.nes index 84044f6..d094c40 100644 Binary files a/examples/comparisons.nes and b/examples/comparisons.nes differ diff --git a/examples/friendly_assets.nes b/examples/friendly_assets.nes index 803da81..4590920 100644 Binary files a/examples/friendly_assets.nes and b/examples/friendly_assets.nes differ diff --git a/examples/function_chain.nes b/examples/function_chain.nes index 287a713..2e5045e 100644 Binary files a/examples/function_chain.nes and b/examples/function_chain.nes differ diff --git a/examples/hello_sprite.nes b/examples/hello_sprite.nes index 97eaccf..cd8fced 100644 Binary files a/examples/hello_sprite.nes and b/examples/hello_sprite.nes differ diff --git a/examples/inline_asm_demo.nes b/examples/inline_asm_demo.nes index 97e2b5f..da31d21 100644 Binary files a/examples/inline_asm_demo.nes and b/examples/inline_asm_demo.nes differ diff --git a/examples/logic_ops.nes b/examples/logic_ops.nes index ccdfff4..9399b71 100644 Binary files a/examples/logic_ops.nes and b/examples/logic_ops.nes differ diff --git a/examples/loop_break_continue.nes b/examples/loop_break_continue.nes index 8e4e158..300079b 100644 Binary files a/examples/loop_break_continue.nes and b/examples/loop_break_continue.nes differ diff --git a/examples/match_demo.nes b/examples/match_demo.nes index 9343b05..fe67689 100644 Binary files a/examples/match_demo.nes and b/examples/match_demo.nes differ diff --git a/examples/metasprite_demo.nes b/examples/metasprite_demo.nes index 50f684d..8cf1b75 100644 Binary files a/examples/metasprite_demo.nes and b/examples/metasprite_demo.nes differ diff --git a/examples/mmc1_banked.nes b/examples/mmc1_banked.nes index 7f250d2..56697f2 100644 Binary files a/examples/mmc1_banked.nes and b/examples/mmc1_banked.nes differ diff --git a/examples/mmc3_per_state_split.nes b/examples/mmc3_per_state_split.nes index cd48257..7e5409b 100644 Binary files a/examples/mmc3_per_state_split.nes and b/examples/mmc3_per_state_split.nes differ diff --git a/examples/nested_structs.nes b/examples/nested_structs.nes index df29244..547ab01 100644 Binary files a/examples/nested_structs.nes and b/examples/nested_structs.nes differ diff --git a/examples/noise_triangle_sfx.nes b/examples/noise_triangle_sfx.nes index 8bfe217..c826255 100644 Binary files a/examples/noise_triangle_sfx.nes and b/examples/noise_triangle_sfx.nes differ diff --git a/examples/palette_and_background.nes b/examples/palette_and_background.nes index 88751f6..4e0d548 100644 Binary files a/examples/palette_and_background.nes and b/examples/palette_and_background.nes differ diff --git a/examples/platformer.nes b/examples/platformer.nes index 911e40a..81966a4 100644 Binary files a/examples/platformer.nes and b/examples/platformer.nes differ diff --git a/examples/pong.nes b/examples/pong.nes index aae3928..ba800df 100644 Binary files a/examples/pong.nes and b/examples/pong.nes differ diff --git a/examples/scanline_split.nes b/examples/scanline_split.nes index 93ba691..2365915 100644 Binary files a/examples/scanline_split.nes and b/examples/scanline_split.nes differ diff --git a/examples/sfx_pitch_envelope.nes b/examples/sfx_pitch_envelope.nes index 032cd18..3b55f5e 100644 Binary files a/examples/sfx_pitch_envelope.nes and b/examples/sfx_pitch_envelope.nes differ diff --git a/examples/sha256.nes b/examples/sha256.nes index b3e8569..c5fa92e 100644 Binary files a/examples/sha256.nes and b/examples/sha256.nes differ diff --git a/examples/sprite_flicker_demo.nes b/examples/sprite_flicker_demo.nes index b128bd7..6d57fd4 100644 Binary files a/examples/sprite_flicker_demo.nes and b/examples/sprite_flicker_demo.nes differ diff --git a/examples/sprites_and_palettes.nes b/examples/sprites_and_palettes.nes index 9cf96e7..3bf47ba 100644 Binary files a/examples/sprites_and_palettes.nes and b/examples/sprites_and_palettes.nes differ diff --git a/examples/state_machine.nes b/examples/state_machine.nes index 97bbefa..bc2ec80 100644 Binary files a/examples/state_machine.nes and b/examples/state_machine.nes differ diff --git a/examples/structs_enums_for.nes b/examples/structs_enums_for.nes index 9585948..85cad6b 100644 Binary files a/examples/structs_enums_for.nes and b/examples/structs_enums_for.nes differ diff --git a/examples/two_player.nes b/examples/two_player.nes index cd07add..ce96cd0 100644 Binary files a/examples/two_player.nes and b/examples/two_player.nes differ diff --git a/examples/uxrom_banked.nes b/examples/uxrom_banked.nes index a5e6030..50a6a2c 100644 Binary files a/examples/uxrom_banked.nes and b/examples/uxrom_banked.nes differ diff --git a/examples/uxrom_banked_to_banked.nes b/examples/uxrom_banked_to_banked.nes index a8e6eb7..92e9f64 100644 Binary files a/examples/uxrom_banked_to_banked.nes and b/examples/uxrom_banked_to_banked.nes differ diff --git a/examples/uxrom_user_banked.nes b/examples/uxrom_user_banked.nes index a71fe7e..231c269 100644 Binary files a/examples/uxrom_user_banked.nes and b/examples/uxrom_user_banked.nes differ diff --git a/examples/war.nes b/examples/war.nes index e438709..05fc56d 100644 Binary files a/examples/war.nes and b/examples/war.nes differ diff --git a/src/codegen/ir_codegen.rs b/src/codegen/ir_codegen.rs index 61b9e45..4c046d6 100644 --- a/src/codegen/ir_codegen.rs +++ b/src/codegen/ir_codegen.rs @@ -161,6 +161,53 @@ pub struct IrCodeGen<'a> { /// resulting `__ppu_update_used` marker label to decide whether /// to splice the in-NMI palette/nametable update helper. ppu_update_used: bool, + /// Set to true the first time we lower an `IrOp::Mul`. Drives + /// the `__mul_used` marker label so the linker can skip + /// `gen_multiply` (~50 bytes) for programs that never multiply. + /// The optimizer's strength reduction has already turned + /// multiplies by constant powers of two into shifts by the time + /// codegen runs, so this flag only gets set for real runtime + /// multiplications. + mul_used: bool, + /// Set to true the first time we lower an `IrOp::Div` or + /// `IrOp::Mod` (modulo reuses the divide routine). Drives the + /// `__div_used` marker label so the linker can skip `gen_divide` + /// (~70 bytes) for programs that never divide. Divide by + /// constant powers of two has been strength-reduced to shifts + /// by the optimizer, and modulo by constant powers of two to + /// masks; runtime divide only survives for non-constant or + /// non-power-of-two divisors. + div_used: bool, + /// Set to true the first time we lower an `IrOp::DrawSprite`. + /// Drives the `__oam_used` marker label. The linker reads this + /// to decide whether to splice the OAM DMA into NMI, emit the + /// `$FE`-fill OAM shadow init inside `gen_init`'s RAM clear, + /// and keep the default palette load. Programs that never + /// `draw` pay zero for any of those paths. + oam_used: bool, + /// Set to true the first time a `draw` either references a + /// sprite name that the resolver didn't turn into a user + /// sprite (falling back to tile 0) or uses a runtime `frame:` + /// override (which could index any tile, including 0). Drives + /// the `__default_sprite_used` marker label — the linker uses + /// it to decide whether to reserve CHR tile 0 for the built-in + /// smiley. Programs that only draw explicitly-declared sprites + /// with static names and no `frame:` override leave this flag + /// `false` and reclaim the 16 CHR bytes of tile 0 as a blank + /// background tile. + default_sprite_used: bool, + /// Set to true the first time we lower an `IrOp::ReadInput` + /// targeting player 1. Drives the `__p1_input_used` marker + /// label — the runtime's NMI skips the three instructions that + /// shift `$4016` into `ZP_INPUT_P1` when nobody reads the + /// player-1 byte. + p1_input_used: bool, + /// Set to true the first time we lower an `IrOp::ReadInput` + /// targeting player 2. Drives the `__p2_input_used` marker + /// label — single-player programs skip the `$4017` read inside + /// the NMI's shift loop, saving ~6 bytes of code and ~30 cycles + /// per frame. + p2_input_used: bool, /// Source-location markers produced from [`IrOp::SourceLoc`]. /// Each entry is a `(label_name, span)` pair — the codegen /// emits a unique label-definition pseudo-op at the current @@ -338,6 +385,12 @@ impl<'a> IrCodeGen<'a> { noise_used: false, triangle_used: false, ppu_update_used: false, + mul_used: false, + div_used: false, + oam_used: false, + default_sprite_used: false, + p1_input_used: false, + p2_input_used: false, source_locs: Vec::new(), next_source_loc: 0, emit_source_locs: false, @@ -829,6 +882,15 @@ impl<'a> IrCodeGen<'a> { self.emit(JMP, AM::Label("__debug_halt".to_string())); } + // Pay-as-you-go marker labels for flags that got set during + // the IR walk above. Emitting them here (rather than at the + // call sites) keeps the markers out of peephole-sensitive + // instruction sequences — a label inside `IrOp::DrawSprite` + // or `IrOp::ReadInput`, for instance, would otherwise split + // the dead-load-elimination block and leave stale ZP + // stores in the stream. + self.emit_trailing_markers(); + // Snapshot the fixed-bank instruction stream before we // start emitting the banked function bodies into their own // streams. Programs without any banked functions skip the @@ -1175,7 +1237,14 @@ impl<'a> IrCodeGen<'a> { self.store_temp(*d); } IrOp::Mul(d, a, b) => { - // Software multiply: multiplicand in A, multiplier in $02 + // Software multiply: multiplicand in A, multiplier in $02. + // Flag the `__mul_used` marker so the linker knows to + // link the `__multiply` subroutine in — programs that + // don't multiply skip ~50 bytes of runtime. The label + // itself is emitted at the end of generate() so it + // doesn't disturb peephole-sensitive instruction + // sequences here. + self.mul_used = true; self.load_temp(*a); self.emit(PHA, AM::Implied); // Save for __multiply contract let b_addr = self.temp_addr(*b); @@ -1222,7 +1291,11 @@ impl<'a> IrCodeGen<'a> { IrOp::Div(d, a, b) => { // Software divide: dividend in A, divisor in $02. // `__divide` returns quotient in A and leaves - // remainder in ZP $03. + // remainder in ZP $03. Flag the `__div_used` marker + // so the linker links the `__divide` subroutine in; + // programs that don't divide skip ~70 bytes. The + // label is emitted at the end of generate(). + self.div_used = true; self.load_temp(*a); self.emit(PHA, AM::Implied); let b_addr = self.temp_addr(*b); @@ -1234,7 +1307,10 @@ impl<'a> IrCodeGen<'a> { } IrOp::Mod(d, a, b) => { // Modulo reuses __divide and reads the remainder out - // of ZP $03 afterwards. + // of ZP $03 afterwards. Same `__div_used` marker as + // `IrOp::Div` — modulo doesn't have a separate + // runtime routine. + self.div_used = true; self.load_temp(*a); self.emit(PHA, AM::Implied); let b_addr = self.temp_addr(*b); @@ -1358,6 +1434,16 @@ impl<'a> IrCodeGen<'a> { y, frame, } => { + // Flag the `__oam_used` marker so the linker knows + // to emit the OAM DMA + shadow-init + default-palette + // machinery. Programs that never `draw` skip all of + // those paths — no DMA cycles per NMI, no $FE OAM + // shadow fill, no built-in smiley reserved in CHR, + // and the default palette is suppressed too when the + // program has no other visual output. The label is + // emitted at the end of generate() so it doesn't + // split a peephole block here. + self.oam_used = true; // Runtime OAM-cursor-based draw. Each frame handler // resets `ZP_OAM_CURSOR` to 0 after the OAM clear; a // `draw` loads the cursor into Y, writes the four @@ -1392,12 +1478,25 @@ impl<'a> IrCodeGen<'a> { self.load_temp(*y); self.emit(STA, AM::AbsoluteY(0x0200)); - // Tile index at cursor+1 — frame override, sprite lookup, or 0 + // Tile index at cursor+1 — frame override, sprite lookup, or 0. + // + // The `frame: ` override indexes into CHR at a + // runtime value we can't bound statically, so it + // might land on tile 0 and therefore needs the + // default smiley. The `sprite_name doesn't resolve` + // case falls back to tile 0 explicitly. Both drop + // the `__default_sprite_used` marker so the linker + // keeps the smiley tile in CHR; draws that resolve + // to a user sprite with a static `frame: None` leave + // the marker off and tile 0 becomes user-available + // as a blank (all-$00) background tile. if let Some(f) = frame { + self.default_sprite_used = true; self.load_temp(*f); } else if let Some(&tile) = self.sprite_tiles.get(sprite_name) { self.emit(LDA, AM::Immediate(tile)); } else { + self.default_sprite_used = true; self.emit(LDA, AM::Immediate(0)); } self.emit(STA, AM::AbsoluteY(0x0201)); @@ -1420,6 +1519,17 @@ impl<'a> IrCodeGen<'a> { self.emit(INC, AM::ZeroPage(ZP_OAM_CURSOR)); } IrOp::ReadInput(dest, player) => { + // Flag the per-player input marker so the linker + // can decide whether to keep that port's shift + // block inside NMI. IR uses `player_index` 0 = P1 + // and 1 = P2; the ZP bytes the NMI populates match + // the constants at the top of `runtime/mod.rs`. + // Labels are emitted at the end of generate(). + if *player == 1 { + self.p2_input_used = true; + } else { + self.p1_input_used = true; + } // $01 = P1 input byte, $08 = P2 input byte let addr = if *player == 1 { 0x08 } else { 0x01 }; self.emit(LDA, AM::ZeroPage(addr)); @@ -2018,6 +2128,37 @@ impl<'a> IrCodeGen<'a> { } } + /// Emit the marker labels for flags that got set somewhere + /// during `generate()`'s IR walk. Called once, at the end of + /// `generate()`, so the labels land after every instruction + /// that could have set a flag — never splitting a + /// peephole-sensitive block at the flag's call site. + /// + /// Each label is a zero-byte pseudo-op. The linker looks them + /// up by name via `has_label` and turns on the gated runtime + /// feature. Programs that never set a given flag emit nothing + /// for it — no label, no lookup hit, no gated code. + fn emit_trailing_markers(&mut self) { + if self.mul_used { + self.emit_label("__mul_used"); + } + if self.div_used { + self.emit_label("__div_used"); + } + if self.oam_used { + self.emit_label("__oam_used"); + } + if self.default_sprite_used { + self.emit_label("__default_sprite_used"); + } + if self.p1_input_used { + self.emit_label("__p1_input_used"); + } + if self.p2_input_used { + self.emit_label("__p2_input_used"); + } + } + /// Emit the MMC3 `__irq_user` handler that dispatches on the /// `(current_state, scanline_step)` pair. Supports multiple /// `on scanline(N)` handlers per state — they fire in ascending diff --git a/src/linker/mod.rs b/src/linker/mod.rs index 94ec068..5c9bbe2 100644 --- a/src/linker/mod.rs +++ b/src/linker/mod.rs @@ -157,7 +157,13 @@ const DEFAULT_SPRITE_CHR: [u8; 16] = [ 0b0011_1100, ]; -/// Default palette data for M1 (writes to PPU $3F00). +/// Default palette data for M1 (writes to PPU $3F00). Spliced into +/// PRG under [`DEFAULT_PALETTE_LABEL`] when the program has no +/// user-declared palette, and loaded by +/// [`runtime::gen_initial_palette_load`] via the same indirect-loop +/// path that user palettes use — keeps the reset-time palette +/// loader small (one code path, ~20 bytes) instead of the old +/// 170-byte per-entry unrolled store sequence. const DEFAULT_PALETTE: [u8; 32] = [ // Background palettes 0x0F, 0x00, 0x10, 0x20, // palette 0 (black, dark gray, light gray, white) @@ -171,6 +177,12 @@ const DEFAULT_PALETTE: [u8; 32] = [ 0x0F, 0x12, 0x22, 0x32, // sprite palette 3 ]; +/// Label under which [`DEFAULT_PALETTE`] is spliced into PRG when +/// emitted. Prefixed with `__` so it can never collide with a +/// user-declared palette's label, which the asset pipeline prefixes +/// with `__palette_`. +const DEFAULT_PALETTE_LABEL: &str = "__default_palette"; + impl Linker { pub fn new(mirroring: Mirroring) -> Self { Self { @@ -384,11 +396,25 @@ impl Linker { let mut all_instructions = Vec::new(); + // 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") || has_label(user_code, "__sprite_cycle_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 @@ -399,10 +425,25 @@ impl Linker { total_banks, )); + // 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 (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 // to the built-in default palette so sprites show up in a - // reasonable colour scheme without any user setup. + // reasonable colour scheme without any user setup. Skipped + // entirely for programs with no visual output — those leave + // palette RAM in its power-on state (undefined on real + // hardware, zeros under jsnes / Mesen) which is fine since + // nothing gets rendered. // // IMPORTANT: `gen_init` leaves rendering fully disabled so // these $2006/$2007 writes are safe. We re-enable rendering @@ -413,8 +454,18 @@ impl Linker { // about the first 72 bytes of a 1024-byte nametable load. if let Some(first_palette) = palettes.first() { all_instructions.extend(runtime::gen_initial_palette_load(&first_palette.label())); - } else { - all_instructions.extend(self.gen_palette_load()); + } else if has_visual_output { + // No user palette but the program does render something + // — fall back to a sensible built-in palette so the + // sprites show up in a reasonable colour scheme without + // any user setup. Uses the same indirect loop loader as + // the user-palette path (reads a 32-byte blob through a + // ZP pointer) — ~20 bytes of code plus a 32-byte data + // block that gets spliced in below, versus the ~170 + // bytes the old inline-stores path cost. The data block + // lives alongside the user palette blobs so the label + // resolves in the normal assembly pass. + all_instructions.extend(runtime::gen_initial_palette_load(DEFAULT_PALETTE_LABEL)); } // Load the initial background if the program declared any. @@ -432,8 +483,13 @@ impl Linker { // rendering on. Programs with a declared background get // bg+sprites ($1E); programs without get sprites only ($10) // to preserve the pre-fix behaviour of example ROMs that - // rely on a hidden nametable. - all_instructions.extend(runtime::gen_enable_rendering(has_user_background)); + // rely on a hidden nametable. Programs with no visual + // output at all leave PPU_MASK at $00 from `gen_init` — + // the PPU stays silent, saves 4 bytes and avoids exposing + // an undefined palette on real hardware. + if has_visual_output { + all_instructions.extend(runtime::gen_enable_rendering(has_user_background)); + } // User code (var init + main loop) all_instructions.extend(user_code.iter().cloned()); @@ -471,9 +527,22 @@ impl Linker { } } - // Math runtime routines (included always for simplicity) - all_instructions.extend(runtime::gen_multiply()); - all_instructions.extend(runtime::gen_divide()); + // Math runtime routines. Gated on the `__mul_used` / + // `__div_used` marker labels that the IR codegen drops at + // the first `IrOp::Mul` / `IrOp::Div` / `IrOp::Mod`. The + // optimizer rewrites multiplies and divides by constant + // powers of two into shifts (and modulo by constant powers + // of two into masks) before codegen runs, so these markers + // only fire for genuinely runtime-costly math. Programs + // without any surviving mul or div pay zero bytes here. + let has_mul = has_label(user_code, "__mul_used"); + let has_div = has_label(user_code, "__div_used"); + if has_mul { + all_instructions.extend(runtime::gen_multiply()); + } + if has_div { + all_instructions.extend(runtime::gen_divide()); + } // Audio subsystem — linked in whenever user code touched // audio (detected via the `__audio_used` marker emitted by @@ -537,6 +606,18 @@ impl Linker { for pal in palettes { all_instructions.extend(runtime::gen_data_block(&pal.label(), pal.colors.to_vec())); } + // When the program needs the built-in default palette (i.e. + // it produces visual output but declared no palette of its + // own), splice the 32-byte blob under `__default_palette` + // so the reset-time loop loader above can resolve it. + // Programs that declare a palette OR have no visual output + // skip this entirely. + if palettes.is_empty() && has_visual_output { + all_instructions.extend(runtime::gen_data_block( + DEFAULT_PALETTE_LABEL, + DEFAULT_PALETTE.to_vec(), + )); + } for bg in backgrounds { all_instructions.extend(runtime::gen_data_block( &bg.tiles_label(), @@ -587,11 +668,16 @@ impl Linker { // every frame" hardware symptom into visible flicker that // the eye reconstructs across frames. let has_sprite_cycle = has_label(user_code, "__sprite_cycle_used"); + let has_p1_input = has_label(user_code, "__p1_input_used"); + let has_p2_input = has_label(user_code, "__p2_input_used"); all_instructions.extend(runtime::gen_nmi(runtime::NmiOptions { has_ppu_updates, has_audio, debug_mode, has_sprite_cycle, + has_oam, + has_p2_input, + has_p1_input, })); // IRQ handler @@ -701,7 +787,35 @@ impl Linker { // ranges overlap, but we still bounds-check on copy in // case a future change shifts the layout. let mut chr = vec![0u8; 8192]; - chr[..16].copy_from_slice(&DEFAULT_SPRITE_CHR); + // Tile 0 is reserved for the built-in smiley *only* when + // something in the program depends on it. Three possible + // sources of that dependency: + // + // 1. `__default_sprite_used` marker — user code runs at + // least one `draw` that falls back to tile 0, either + // because the sprite name doesn't resolve or because + // it uses a runtime `frame:` override. + // 2. A background nametable references tile 0 directly. + // Some programs use the smiley as a placeholder + // background tile (see `examples/friendly_assets.ne`). + // 3. A background's CHR was written at base tile 0 — the + // resolver shouldn't allow this today, but checking + // for `chr_base_tile == 0` keeps the gate honest. + // + // Programs whose draws all resolve to declared sprites with + // static frames and whose backgrounds reference tiles 1+ + // skip the smiley emit and reclaim 16 CHR bytes. + let bg_uses_tile_zero = backgrounds.iter().any(|bg| { + // A nametable entry of 0 means "fetch tile 0" — the + // PPU can't tell the difference between sprite and + // background tiles, so we have to preserve the smiley + // when any background map byte reads 0. + bg.tiles.contains(&0) + }); + let has_default_sprite = has_label(user_code, "__default_sprite_used") || bg_uses_tile_zero; + if has_default_sprite { + chr[..16].copy_from_slice(&DEFAULT_SPRITE_CHR); + } for sprite in sprites { let offset = sprite.tile_index as usize * 16; let end = offset + sprite.chr_bytes.len(); @@ -740,24 +854,4 @@ impl Linker { fixed_bank_file_offset, } } - - /// Generate instructions to load the default palette into the PPU. - fn gen_palette_load(&self) -> Vec { - let mut out = Vec::new(); - - // Set PPU address to $3F00 (palette start) - out.push(Instruction::new(LDA, AM::Absolute(0x2002))); // read PPU status to reset latch - out.push(Instruction::new(LDA, AM::Immediate(0x3F))); - out.push(Instruction::new(STA, AM::Absolute(0x2006))); // PPU addr high byte - out.push(Instruction::new(LDA, AM::Immediate(0x00))); - out.push(Instruction::new(STA, AM::Absolute(0x2006))); // PPU addr low byte - - // Write all 32 palette bytes - for &color in &DEFAULT_PALETTE { - out.push(Instruction::new(LDA, AM::Immediate(color))); - out.push(Instruction::new(STA, AM::Absolute(0x2007))); // PPU data - } - - out - } } diff --git a/src/linker/tests.rs b/src/linker/tests.rs index 6b28c10..66ce23d 100644 --- a/src/linker/tests.rs +++ b/src/linker/tests.rs @@ -50,8 +50,14 @@ fn link_has_correct_vector_table() { #[test] fn link_includes_chr_data() { + // When user code signals that it uses the default smiley tile + // (fallback for an unresolved `draw` target), the linker should + // copy the built-in 16-byte smiley CHR blob into tile 0. let linker = Linker::new(Mirroring::Horizontal); - let user_code = vec![Instruction::implied(NOP)]; + let user_code = vec![ + Instruction::new(NOP, AM::Label("__default_sprite_used".into())), + Instruction::implied(NOP), + ]; let rom_data = linker.link(&user_code); // CHR starts after PRG @@ -64,6 +70,23 @@ fn link_includes_chr_data() { ); } +#[test] +fn link_omits_default_smiley_without_marker() { + // Without the `__default_sprite_used` marker the linker should + // leave tile 0 as the all-zero blank tile — programs that draw + // only user-declared sprites or never draw at all reclaim the + // 16 CHR bytes. + let linker = Linker::new(Mirroring::Horizontal); + let user_code = vec![Instruction::implied(NOP)]; + let rom_data = linker.link(&user_code); + let chr_start = 16 + 16384; + assert_eq!( + &rom_data[chr_start..chr_start + 16], + &[0u8; 16], + "tile 0 should be zero when no program draws fall back to the smiley" + ); +} + #[test] fn link_rom_size_correct() { let linker = Linker::new(Mirroring::Horizontal); @@ -74,10 +97,40 @@ 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); - let user_code = vec![Instruction::implied(NOP)]; + // Seed the `__default_sprite_used` marker so the linker still + // emits the smiley tile alongside the user sprite — this test + // asserts the legacy "smiley at tile 0, user sprite at tile 1" + // layout that programs doing both fallback and named draws + // depend on. + let user_code = vec![ + Instruction::new(NOP, AM::Label("__default_sprite_used".into())), + Instruction::implied(NOP), + ]; let sprite_bytes: Vec = (0x20..0x30).collect(); // 16 bytes, one tile let sprites = vec![SpriteData { @@ -621,9 +674,13 @@ fn link_with_mapper_nrom_produces_single_bank_rom() { #[test] fn link_banked_chr_rom_survives_with_switchable_banks() { // The default smiley + any sprites should still appear in CHR - // ROM even when switchable PRG banks are present. + // ROM even when switchable PRG banks are present. Seed the + // `__default_sprite_used` marker so the smiley gate fires. let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::MMC1); - let user_code = vec![Instruction::implied(NOP)]; + let user_code = vec![ + Instruction::new(NOP, AM::Label("__default_sprite_used".into())), + Instruction::implied(NOP), + ]; let banks = vec![PrgBank::empty("X")]; let rom = linker.link_banked(&user_code, &[], &[], &[], &banks); // CHR starts after 2 PRG banks. @@ -633,30 +690,69 @@ fn link_banked_chr_rom_survives_with_switchable_banks() { } #[test] -fn palette_load_writes_to_ppu() { +fn default_palette_blob_present_when_no_user_palette() { + // With no user palette but some visual output (the IR codegen + // drops an `__oam_used` marker whenever it lowers a `draw`), + // the linker emits the shared reset-time loop loader and + // splices a 32-byte `__default_palette` data block into PRG. + // The end-to-end ROM should contain the default palette bytes + // verbatim at some offset in the fixed bank. let linker = Linker::new(Mirroring::Horizontal); - let palette_insts = linker.gen_palette_load(); + let user_code = vec![ + Instruction::new(NOP, AM::Label("__oam_used".into())), + Instruction::new(NOP, AM::Label("__ir_main_loop".into())), + ]; + let rom = linker.link(&user_code); - // Should write to PPU address register ($2006) twice - let ppu_addr_writes: Vec<_> = palette_insts - .iter() - .filter(|i| i.opcode == STA && i.mode == AM::Absolute(0x2006)) - .collect(); - assert_eq!( - ppu_addr_writes.len(), - 2, - "should set PPU address (high and low bytes)" + // The first four bytes of DEFAULT_PALETTE are {0x0F, 0x00, 0x10, + // 0x20}; they should appear verbatim in the PRG portion of the + // iNES file (bytes 16..16+16_384). We look for that 4-byte + // sequence rather than matching the full 32 bytes so this stays + // robust against minor palette tweaks. + let prg = &rom[16..16 + 16_384]; + let found = prg.windows(4).any(|w| w == [0x0F, 0x00, 0x10, 0x20]); + assert!(found, "default palette bytes should appear in PRG"); +} + +#[test] +fn default_palette_suppressed_when_no_visual_output() { + // An audio- or compute-only program (no palette declared, no + // sprites, no backgrounds, no `draw`) shouldn't pay for the + // built-in default palette: no `__default_palette` label in + // the symbol table, and the 32 bytes of palette data must not + // appear anywhere in PRG. + let linker = Linker::new(Mirroring::Horizontal); + let user_code = vec![Instruction::new(NOP, AM::Label("__ir_main_loop".into()))]; + let result = linker.link_banked_with_ppu_detailed(&user_code, &[], &[], &[], &[], &[], &[]); + assert!( + !result.labels.contains_key("__default_palette"), + "default palette label must not be defined without visual output" ); + let prg = &result.rom[16..16 + 16_384]; + assert!( + !prg.windows(4).any(|w| w == [0x0F, 0x00, 0x10, 0x20]), + "default palette bytes must not appear in PRG when suppressed" + ); +} - // Should write 32 palette bytes to $2007 - let ppu_data_writes: Vec<_> = palette_insts - .iter() - .filter(|i| i.opcode == STA && i.mode == AM::Absolute(0x2007)) - .collect(); - assert_eq!( - ppu_data_writes.len(), - 32, - "should write all 32 palette bytes" +#[test] +fn no_default_palette_blob_when_user_palette_present() { + // A program that declares its own palette should suppress the + // built-in fallback entirely — the `__default_palette` label + // never gets emitted, and the assembler's label table doesn't + // contain it. + use crate::assets::PaletteData; + let linker = Linker::new(Mirroring::Horizontal); + let user_code = vec![Instruction::new(NOP, AM::Label("__ir_main_loop".into()))]; + let user_pal = PaletteData { + name: "Menu".into(), + colors: [0x0F; 32], + }; + let result = + linker.link_banked_with_ppu_detailed(&user_code, &[], &[], &[], &[user_pal], &[], &[]); + assert!( + !result.labels.contains_key("__default_palette"), + "default palette must be suppressed when user palette is present" ); } diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index ec322f7..ebe2d57 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,26 @@ 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, + /// 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 ~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 (~100 cycles: the 88-cycle + /// body plus the ~12-cycle strobe and loop scaffold). + pub has_p1_input: bool, } #[must_use] @@ -370,6 +408,9 @@ pub fn gen_nmi(opts: NmiOptions) -> Vec { has_audio, debug_mode, has_sprite_cycle, + has_oam, + has_p2_input, + has_p1_input, } = opts; let mut out = Vec::new(); @@ -444,19 +485,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 @@ -466,28 +514,40 @@ pub fn gen_nmi(opts: NmiOptions) -> Vec { out.extend(gen_ppu_update_apply()); } - // Read controller 1 - out.push(Instruction::new(LDA, AM::Immediate(0x01))); - out.push(Instruction::new(STA, AM::Absolute(JOY1))); - out.push(Instruction::new(LDA, AM::Immediate(0x00))); - out.push(Instruction::new(STA, AM::Absolute(JOY1))); + // Controller sampling. The strobe write to $4016 latches both + // controller ports on the same clock, so the 8-iteration shift + // loop that follows can read whichever of the two the program + // actually uses. Programs that touch no `button.*` at all skip + // the whole block. + if has_p1_input || has_p2_input { + // Strobe: write 1 then 0 to $4016 so both port latches + // capture the current button state. + out.push(Instruction::new(LDA, AM::Immediate(0x01))); + out.push(Instruction::new(STA, AM::Absolute(JOY1))); + out.push(Instruction::new(LDA, AM::Immediate(0x00))); + out.push(Instruction::new(STA, AM::Absolute(JOY1))); - // Read 8 button bits from controller 1 ($4016) into ZP_INPUT_P1 - // and 8 button bits from controller 2 ($4017) into ZP_INPUT_P2 - // simultaneously — shift each port's carry into its ZP byte. - out.push(Instruction::new(LDX, AM::Immediate(0x08))); - out.push(Instruction::new(NOP, AM::Label("__read_input".into()))); - out.push(Instruction::new(LDA, AM::Absolute(JOY1))); - out.push(Instruction::new(LSR, AM::Accumulator)); - out.push(Instruction::new(ROL, AM::ZeroPage(ZP_INPUT_P1))); - out.push(Instruction::new(LDA, AM::Absolute(0x4017))); // JOY2 - out.push(Instruction::new(LSR, AM::Accumulator)); - out.push(Instruction::new(ROL, AM::ZeroPage(ZP_INPUT_P2))); - out.push(Instruction::implied(DEX)); - out.push(Instruction::new( - BNE, - AM::LabelRelative("__read_input".into()), - )); + // 8 iterations of read-and-shift. Each active port costs + // three instructions (LDA abs, LSR A, ROL zp) inside the + // loop; inactive ports emit nothing. + out.push(Instruction::new(LDX, AM::Immediate(0x08))); + out.push(Instruction::new(NOP, AM::Label("__read_input".into()))); + if has_p1_input { + out.push(Instruction::new(LDA, AM::Absolute(JOY1))); + out.push(Instruction::new(LSR, AM::Accumulator)); + out.push(Instruction::new(ROL, AM::ZeroPage(ZP_INPUT_P1))); + } + if has_p2_input { + out.push(Instruction::new(LDA, AM::Absolute(0x4017))); // JOY2 + out.push(Instruction::new(LSR, AM::Accumulator)); + out.push(Instruction::new(ROL, AM::ZeroPage(ZP_INPUT_P2))); + } + out.push(Instruction::implied(DEX)); + out.push(Instruction::new( + BNE, + AM::LabelRelative("__read_input".into()), + )); + } // Debug frame-overrun check. The frame flag is "set on NMI, // cleared by wait_frame". If we see it set at the top of a diff --git a/src/runtime/tests.rs b/src/runtime/tests.rs index a368aef..70dcc63 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,22 +85,68 @@ 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] -fn nmi_reads_controller() { - let nmi = gen_nmi(NmiOptions::default()); +fn nmi_reads_controller_when_p1_requested() { + let nmi = gen_nmi(NmiOptions { + has_p1_input: true, + ..NmiOptions::default() + }); // Should write strobe to $4016 let has_strobe = nmi .iter() .any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4016)); - assert!(has_strobe, "NMI should strobe controller"); + assert!(has_strobe, "NMI should strobe controller when P1 requested"); + let reads_joy1 = nmi + .iter() + .any(|i| i.opcode == LDA && i.mode == AM::Absolute(0x4016)); + assert!(reads_joy1, "NMI should read JOY1 when P1 requested"); +} + +#[test] +fn nmi_skips_strobe_when_no_input() { + // With both `has_p1_input` and `has_p2_input` unset, the NMI + // should emit neither the strobe write to $4016 nor any reads + // from the two controller ports — programs that never touch + // `button.*` pay zero cycles for input sampling. + let nmi = gen_nmi(NmiOptions::default()); + let has_strobe = nmi + .iter() + .any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4016)); + assert!( + !has_strobe, + "NMI must not strobe controllers when no input is requested" + ); + let reads_joy1 = nmi + .iter() + .any(|i| i.opcode == LDA && i.mode == AM::Absolute(0x4016)); + let reads_joy2 = nmi + .iter() + .any(|i| i.opcode == LDA && i.mode == AM::Absolute(0x4017)); + assert!(!reads_joy1, "NMI must not read JOY1 without P1 input"); + assert!(!reads_joy2, "NMI must not read JOY2 without P2 input"); } #[test] @@ -203,8 +249,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/audio_demo.audio.hash b/tests/emulator/goldens/audio_demo.audio.hash index 220a846..8c49484 100644 --- a/tests/emulator/goldens/audio_demo.audio.hash +++ b/tests/emulator/goldens/audio_demo.audio.hash @@ -1 +1 @@ -7b16a9db 132084 +23afaaa6 132084 diff --git a/tests/emulator/goldens/friendly_assets.audio.hash b/tests/emulator/goldens/friendly_assets.audio.hash index d5069db..6f5573b 100644 --- a/tests/emulator/goldens/friendly_assets.audio.hash +++ b/tests/emulator/goldens/friendly_assets.audio.hash @@ -1 +1 @@ -e73a6a73 132084 +bc94ed30 132084 diff --git a/tests/emulator/goldens/noise_triangle_sfx.audio.hash b/tests/emulator/goldens/noise_triangle_sfx.audio.hash index 7efa628..8b0ffae 100644 --- a/tests/emulator/goldens/noise_triangle_sfx.audio.hash +++ b/tests/emulator/goldens/noise_triangle_sfx.audio.hash @@ -1 +1 @@ -ea38044c 132084 +52d1836f 132084 diff --git a/tests/emulator/goldens/palette_and_background.png b/tests/emulator/goldens/palette_and_background.png index 918aab9..1e82376 100644 Binary files a/tests/emulator/goldens/palette_and_background.png and b/tests/emulator/goldens/palette_and_background.png differ diff --git a/tests/emulator/goldens/platformer.audio.hash b/tests/emulator/goldens/platformer.audio.hash index e30b406..e2b0778 100644 --- a/tests/emulator/goldens/platformer.audio.hash +++ b/tests/emulator/goldens/platformer.audio.hash @@ -1 +1 @@ -dded3a6a 132084 +ea23d9c4 132084 diff --git a/tests/emulator/goldens/pong.audio.hash b/tests/emulator/goldens/pong.audio.hash index da68c82..02f3dfa 100644 --- a/tests/emulator/goldens/pong.audio.hash +++ b/tests/emulator/goldens/pong.audio.hash @@ -1 +1 @@ -f2dbfbf3 132084 +555a22e1 132084 diff --git a/tests/emulator/goldens/sfx_pitch_envelope.audio.hash b/tests/emulator/goldens/sfx_pitch_envelope.audio.hash index f4d7072..9f5aba3 100644 --- a/tests/emulator/goldens/sfx_pitch_envelope.audio.hash +++ b/tests/emulator/goldens/sfx_pitch_envelope.audio.hash @@ -1 +1 @@ -eec31111 132084 +8142ffb7 132084 diff --git a/tests/emulator/goldens/sfx_pitch_envelope.png b/tests/emulator/goldens/sfx_pitch_envelope.png index ab3d666..2842948 100644 Binary files a/tests/emulator/goldens/sfx_pitch_envelope.png and b/tests/emulator/goldens/sfx_pitch_envelope.png differ diff --git a/tests/emulator/goldens/war.audio.hash b/tests/emulator/goldens/war.audio.hash index b37a532..9345746 100644 --- a/tests/emulator/goldens/war.audio.hash +++ b/tests/emulator/goldens/war.audio.hash @@ -1 +1 @@ -4e8bdb33 132084 +e3805766 132084 diff --git a/tests/integration_test.rs b/tests/integration_test.rs index c8bd8ac..c8260d5 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1226,11 +1226,15 @@ fn sprite_resolution_uses_tile_index() { "Player sprite CHR bytes should be placed at tile index 1", ); - // The default smiley tile at index 0 should still be non-zero (untouched). + // The default smiley tile at index 0 should be blank ($00) — + // the program's one `draw Player at: (...)` resolves to the + // declared Player sprite at tile 1, so the `__default_sprite_used` + // marker never fires and the linker doesn't copy the smiley + // into tile 0. That frees the 16 bytes for user graphics. let tile0 = &rom_data[chr_start..chr_start + 16]; - assert_ne!( + assert_eq!( tile0, &[0u8; 16], - "tile 0 should still contain the default smiley", + "tile 0 should be blank when no draw falls back to the smiley", ); // In PRG ROM, look for `LDA #$01 ; STA $0201,Y` which writes @@ -1882,12 +1886,16 @@ fn e2e_bank_declarations_dont_affect_nrom_prg_size() { #[test] fn e2e_banked_chr_rom_is_preserved() { // CHR ROM should still contain the default smiley sprite at - // tile 0 regardless of how many PRG banks the ROM has. + // tile 0 regardless of how many PRG banks the ROM has — but + // only when the program actually exercises the fallback. A + // `draw` of an undeclared sprite name drops the marker; we + // rely on that here rather than declaring a sprite so we keep + // testing the "banked ROM still emits the smiley" path. let source = r#" game "CHRCheck" { mapper: MMC1 } bank One: prg bank Two: prg - on frame { wait_frame } + on frame { draw Unknown at: (0, 0) } start Main "#; let rom = compile_banked(source);