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
|
||||
/// 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: <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 {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
// 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();
|
||||
|
|
|
|||
|
|
@ -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<u8> = (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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue