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:
parent
7533ac281e
commit
6561daff35
7 changed files with 127 additions and 17 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue