diff --git a/examples/auto_chr_background.nes b/examples/auto_chr_background.nes index 3459fd3..5fed29a 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 8f646da..d75847b 100644 Binary files a/examples/sfx_pitch_envelope.nes and b/examples/sfx_pitch_envelope.nes differ diff --git a/examples/sprites_and_palettes.nes b/examples/sprites_and_palettes.nes index d6450df..da2669b 100644 Binary files a/examples/sprites_and_palettes.nes and b/examples/sprites_and_palettes.nes differ diff --git a/src/codegen/ir_codegen.rs b/src/codegen/ir_codegen.rs index 3327865..7ea7136 100644 --- a/src/codegen/ir_codegen.rs +++ b/src/codegen/ir_codegen.rs @@ -182,10 +182,20 @@ pub struct IrCodeGen<'a> { /// 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, - /// reserve the default smiley tile at CHR tile 0, and keep the - /// default palette load. Programs that never `draw` pay zero - /// for any of those paths. + /// 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, /// 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 @@ -366,6 +376,7 @@ impl<'a> IrCodeGen<'a> { mul_used: false, div_used: false, oam_used: false, + default_sprite_used: false, source_locs: Vec::new(), next_source_loc: 0, emit_source_locs: false, @@ -1438,12 +1449,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.emit_default_sprite_marker(); self.load_temp(*f); } else if let Some(&tile) = self.sprite_tiles.get(sprite_name) { self.emit(LDA, AM::Immediate(tile)); } else { + self.emit_default_sprite_marker(); self.emit(LDA, AM::Immediate(0)); } self.emit(STA, AM::AbsoluteY(0x0201)); @@ -2092,9 +2116,9 @@ impl<'a> IrCodeGen<'a> { /// Emit the `__oam_used` marker label at most once per program. /// Triggered by `IrOp::DrawSprite`. The linker drops the OAM - /// DMA + shadow-init + default-sprite-tile paths when this - /// marker is absent, shaving cycles out of every NMI and bytes - /// out of PRG/CHR for programs that don't draw sprites. + /// DMA + shadow-init + default-palette paths when this marker + /// is absent, shaving cycles out of every NMI and bytes out of + /// PRG for programs that don't draw sprites. fn emit_oam_marker(&mut self) { if !self.oam_used { self.emit_label("__oam_used"); @@ -2102,6 +2126,21 @@ impl<'a> IrCodeGen<'a> { } } + /// Emit the `__default_sprite_used` marker label at most once + /// per program. Triggered by `IrOp::DrawSprite` when the sprite + /// name doesn't resolve to a user declaration (fall-back to + /// tile 0) or when a runtime `frame:` override could land on + /// tile 0. The linker uses this to decide whether to copy the + /// built-in smiley into CHR tile 0; programs whose draws all + /// resolve to declared sprites with static frames skip the + /// emit and get tile 0 back as a blank background tile. + fn emit_default_sprite_marker(&mut self) { + if !self.default_sprite_used { + self.emit_label("__default_sprite_used"); + self.default_sprite_used = true; + } + } + /// 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 261c577..f5b3a97 100644 --- a/src/linker/mod.rs +++ b/src/linker/mod.rs @@ -771,7 +771,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(); diff --git a/src/linker/tests.rs b/src/linker/tests.rs index 47f430c..22614b6 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); @@ -77,7 +100,15 @@ fn link_rom_size_correct() { #[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 +652,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. 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);