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

Merge pull request #29 from imjasonh/claude/analyze-minimal-rom-YawXr

This commit is contained in:
Jason Hall 2026-04-16 18:13:21 -04:00 committed by GitHub
commit f9198ac52c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 568 additions and 120 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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: <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.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

View file

@ -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<Instruction> {
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
}
}

View file

@ -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<u8> = (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"
);
}

View file

@ -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<Instruction> {
///
/// `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<Instruction> {
let mut out = Vec::new();
// Disable IRQs and set decimal mode off
@ -252,16 +262,24 @@ pub fn gen_init() -> Vec<Instruction> {
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<Instruction> {
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<Instruction> {
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<Instruction> {
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

View file

@ -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

View file

@ -1 +1 @@
7b16a9db 132084
23afaaa6 132084

View file

@ -1 +1 @@
e73a6a73 132084
bc94ed30 132084

View file

@ -1 +1 @@
ea38044c 132084
52d1836f 132084

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Before After
Before After

View file

@ -1 +1 @@
dded3a6a 132084
ea23d9c4 132084

View file

@ -1 +1 @@
f2dbfbf3 132084
555a22e1 132084

View file

@ -1 +1 @@
eec31111 132084
8142ffb7 132084

Binary file not shown.

Before

Width:  |  Height:  |  Size: 711 B

After

Width:  |  Height:  |  Size: 730 B

Before After
Before After

View file

@ -1 +1 @@
4e8bdb33 132084
e3805766 132084

View file

@ -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);