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

linker: skip default palette + rendering enable for non-visual ROMs

Add an `__oam_used` marker dropped by IrOp::DrawSprite codegen, and
compute a `has_visual_output` flag in the linker from the marker
plus the presence of any user palette / sprite / background. When
that flag is false — i.e. a purely audio- or compute-only program
— the linker skips both the reset-time default palette load and
the `gen_enable_rendering` PPU_MASK write. `gen_init` already
leaves rendering disabled, so the PPU stays silent and palette RAM
stays in its power-on state. ~72 bytes reclaimed for non-visual
programs.

Caveat: audio-only ROMs now display an undefined backdrop colour
instead of the default-palette black. jsnes renders that as a
mid-grey; Mesen/real hardware may vary. Programs that want a
specific backdrop should declare their own palette. The golden
png for `examples/sfx_pitch_envelope` (the one audio-only example
in the set) flips from all-black to all-grey to document this.

`__oam_used` is also consumed by the next two commits (default
smiley CHR gate, OAM DMA gate), so introducing it here keeps the
marker table coherent in one place. Emitting it inline in the
DrawSprite codegen path does shift a handful of peephole-block
boundaries for programs that draw — pixel goldens flip for
`examples/comparisons` by 56 out of 61440 pixels (a one-pixel
sprite-position drift caused by accumulated branch-page-crossing
cycle drift), a cousin of the audio-hash drift already documented
in the prior two commits.

https://claude.ai/code/session_016kM6P7PukktBDqTZexrrAN
This commit is contained in:
Claude 2026-04-16 13:21:56 +00:00
parent 37974611ae
commit 7533ac281e
No known key found for this signature in database
42 changed files with 111 additions and 34 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.

View file

@ -178,6 +178,14 @@ pub struct IrCodeGen<'a> {
/// 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,
/// 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.
oam_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
@ -357,6 +365,7 @@ impl<'a> IrCodeGen<'a> {
ppu_update_used: false,
mul_used: false,
div_used: false,
oam_used: false,
source_locs: Vec::new(),
next_source_loc: 0,
emit_source_locs: false,
@ -1387,6 +1396,14 @@ impl<'a> IrCodeGen<'a> {
y,
frame,
} => {
// Drop the `__oam_used` marker so the linker knows
// to emit the OAM DMA + shadow-init + default-sprite
// 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.
self.emit_oam_marker();
// 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
@ -2073,6 +2090,18 @@ 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.
fn emit_oam_marker(&mut self) {
if !self.oam_used {
self.emit_label("__oam_used");
self.oam_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

View file

@ -411,10 +411,28 @@ 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 (emitted by
// `IrOp::DrawSprite`). 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 below for the other places it cascades.
let has_visual_output = !palettes.is_empty()
|| !sprites.is_empty()
|| !backgrounds.is_empty()
|| has_label(user_code, "__oam_used");
// 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
@ -425,16 +443,17 @@ 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 {
// No user palette: fall back to a sensible built-in
// palette so 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.
} 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));
}
@ -453,8 +472,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());
@ -571,13 +595,13 @@ impl Linker {
for pal in palettes {
all_instructions.extend(runtime::gen_data_block(&pal.label(), pal.colors.to_vec()));
}
// When the program has no user palette, splice the built-in
// default palette blob under `__default_palette` so the
// reset-time loop loader above can resolve it. Programs
// that declare a palette fall through the user path and
// skip this entirely — saves 32 bytes of ROM data when the
// default is unused.
if palettes.is_empty() {
// 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(),

View file

@ -634,14 +634,17 @@ fn link_banked_chr_rom_survives_with_switchable_banks() {
#[test]
fn default_palette_blob_present_when_no_user_palette() {
// With no user palette, the linker emits the shared reset-time
// loop loader (which writes twice to `$2006` and loops writing
// through `$2007`) 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.
// 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 user_code = vec![Instruction::new(NOP, AM::Label("__ir_main_loop".into()))];
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);
// The first four bytes of DEFAULT_PALETTE are {0x0F, 0x00, 0x10,
@ -654,6 +657,27 @@ fn default_palette_blob_present_when_no_user_palette() {
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"
);
}
#[test]
fn no_default_palette_blob_when_user_palette_present() {
// A program that declares its own palette should suppress the

View file

@ -1 +1 @@
e064fdf0 132084
dceef796 132084

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1,023 B

Before After
Before After

View file

@ -1 +1 @@
68089bdb 132084
a9a829f7 132084

View file

@ -1 +1 @@
7b0caf36 132084
be86c2a2 132084

View file

@ -1 +1 @@
5cc6d0f5 132084
c9c2333d 132084

View file

@ -1 +1 @@
714f679e 132084
b7d5b53f 132084

View file

@ -1 +1 @@
c1ba0463 132084
9c881965 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 @@
575adcb6 132084
b7b5b9a0 132084