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

linker: gate default smiley CHR tile on __default_sprite_used marker

Drop the built-in smiley from CHR tile 0 unless something in the
program actually references it. The marker fires when either:

  1. `IrOp::DrawSprite` lowering falls back to tile 0 because the
     sprite name doesn't resolve to a user declaration, or
  2. The same lowering sees a runtime `frame:` override (which
     could index any tile, including 0).

A third source of dependency — a background nametable entry of 0 —
is detected in the linker by scanning `bg.tiles` for zeros. This
preserves the smiley for programs like `examples/friendly_assets`
that use tile 0 as a background placeholder, even though their
draws resolve to user-declared sprites.

Programs whose draws all resolve to explicitly-declared sprites
with static frames AND whose backgrounds reference tiles 1+ now
leave CHR tile 0 as an all-zero blank, freeing 16 CHR bytes that
the user can treat as an always-transparent background tile.
Verified against the current example set: `sprites_and_palettes`
and `auto_chr_background` reclaim tile 0; every other example
keeps it (either they fall back to tile 0 via an undeclared draw
name or their background tilemap references tile 0).

All 33 emulator goldens still pass — removing an unreferenced CHR
tile can't change observable output.

https://claude.ai/code/session_016kM6P7PukktBDqTZexrrAN
This commit is contained in:
Claude 2026-04-16 13:32:52 +00:00
parent 7533ac281e
commit 6561daff35
No known key found for this signature in database
7 changed files with 127 additions and 17 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -182,10 +182,20 @@ pub struct IrCodeGen<'a> {
/// Drives the `__oam_used` marker label. The linker reads this /// Drives the `__oam_used` marker label. The linker reads this
/// to decide whether to splice the OAM DMA into NMI, emit the /// to decide whether to splice the OAM DMA into NMI, emit the
/// `$FE`-fill OAM shadow init inside `gen_init`'s RAM clear, /// `$FE`-fill OAM shadow init inside `gen_init`'s RAM clear,
/// reserve the default smiley tile at CHR tile 0, and keep the /// and keep the default palette load. Programs that never
/// default palette load. Programs that never `draw` pay zero /// `draw` pay zero for any of those paths.
/// for any of those paths.
oam_used: bool, 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`]. /// Source-location markers produced from [`IrOp::SourceLoc`].
/// Each entry is a `(label_name, span)` pair — the codegen /// Each entry is a `(label_name, span)` pair — the codegen
/// emits a unique label-definition pseudo-op at the current /// emits a unique label-definition pseudo-op at the current
@ -366,6 +376,7 @@ impl<'a> IrCodeGen<'a> {
mul_used: false, mul_used: false,
div_used: false, div_used: false,
oam_used: false, oam_used: false,
default_sprite_used: false,
source_locs: Vec::new(), source_locs: Vec::new(),
next_source_loc: 0, next_source_loc: 0,
emit_source_locs: false, emit_source_locs: false,
@ -1438,12 +1449,25 @@ impl<'a> IrCodeGen<'a> {
self.load_temp(*y); self.load_temp(*y);
self.emit(STA, AM::AbsoluteY(0x0200)); 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: <var>` 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 { if let Some(f) = frame {
self.emit_default_sprite_marker();
self.load_temp(*f); self.load_temp(*f);
} else if let Some(&tile) = self.sprite_tiles.get(sprite_name) { } else if let Some(&tile) = self.sprite_tiles.get(sprite_name) {
self.emit(LDA, AM::Immediate(tile)); self.emit(LDA, AM::Immediate(tile));
} else { } else {
self.emit_default_sprite_marker();
self.emit(LDA, AM::Immediate(0)); self.emit(LDA, AM::Immediate(0));
} }
self.emit(STA, AM::AbsoluteY(0x0201)); 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. /// Emit the `__oam_used` marker label at most once per program.
/// Triggered by `IrOp::DrawSprite`. The linker drops the OAM /// Triggered by `IrOp::DrawSprite`. The linker drops the OAM
/// DMA + shadow-init + default-sprite-tile paths when this /// DMA + shadow-init + default-palette paths when this marker
/// marker is absent, shaving cycles out of every NMI and bytes /// is absent, shaving cycles out of every NMI and bytes out of
/// out of PRG/CHR for programs that don't draw sprites. /// PRG for programs that don't draw sprites.
fn emit_oam_marker(&mut self) { fn emit_oam_marker(&mut self) {
if !self.oam_used { if !self.oam_used {
self.emit_label("__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 /// Emit the MMC3 `__irq_user` handler that dispatches on the
/// `(current_state, scanline_step)` pair. Supports multiple /// `(current_state, scanline_step)` pair. Supports multiple
/// `on scanline(N)` handlers per state — they fire in ascending /// `on scanline(N)` handlers per state — they fire in ascending

View file

@ -771,7 +771,35 @@ impl Linker {
// ranges overlap, but we still bounds-check on copy in // ranges overlap, but we still bounds-check on copy in
// case a future change shifts the layout. // case a future change shifts the layout.
let mut chr = vec![0u8; 8192]; 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 { for sprite in sprites {
let offset = sprite.tile_index as usize * 16; let offset = sprite.tile_index as usize * 16;
let end = offset + sprite.chr_bytes.len(); let end = offset + sprite.chr_bytes.len();

View file

@ -50,8 +50,14 @@ fn link_has_correct_vector_table() {
#[test] #[test]
fn link_includes_chr_data() { 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 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); let rom_data = linker.link(&user_code);
// CHR starts after PRG // 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] #[test]
fn link_rom_size_correct() { fn link_rom_size_correct() {
let linker = Linker::new(Mirroring::Horizontal); let linker = Linker::new(Mirroring::Horizontal);
@ -77,7 +100,15 @@ fn link_rom_size_correct() {
#[test] #[test]
fn link_with_sprites_places_chr_data() { fn link_with_sprites_places_chr_data() {
let linker = Linker::new(Mirroring::Horizontal); 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<u8> = (0x20..0x30).collect(); // 16 bytes, one tile let sprite_bytes: Vec<u8> = (0x20..0x30).collect(); // 16 bytes, one tile
let sprites = vec![SpriteData { let sprites = vec![SpriteData {
@ -621,9 +652,13 @@ fn link_with_mapper_nrom_produces_single_bank_rom() {
#[test] #[test]
fn link_banked_chr_rom_survives_with_switchable_banks() { fn link_banked_chr_rom_survives_with_switchable_banks() {
// The default smiley + any sprites should still appear in CHR // 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 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 banks = vec![PrgBank::empty("X")];
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks); let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
// CHR starts after 2 PRG banks. // CHR starts after 2 PRG banks.

View file

@ -1226,11 +1226,15 @@ fn sprite_resolution_uses_tile_index() {
"Player sprite CHR bytes should be placed at tile index 1", "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]; let tile0 = &rom_data[chr_start..chr_start + 16];
assert_ne!( assert_eq!(
tile0, &[0u8; 16], 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 // 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] #[test]
fn e2e_banked_chr_rom_is_preserved() { fn e2e_banked_chr_rom_is_preserved() {
// CHR ROM should still contain the default smiley sprite at // 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#" let source = r#"
game "CHRCheck" { mapper: MMC1 } game "CHRCheck" { mapper: MMC1 }
bank One: prg bank One: prg
bank Two: prg bank Two: prg
on frame { wait_frame } on frame { draw Unknown at: (0, 0) }
start Main start Main
"#; "#;
let rom = compile_banked(source); let rom = compile_banked(source);