1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-09 01:16:12 +00:00

linker: shrink default palette load from inline stores to loop

The reset-time "no user palette" path was emitting 32 unrolled
`LDA #imm / STA $2007` pairs (~170 bytes) to write the built-in
palette. Replace it with the same indirect-loop loader the
user-palette path already uses (runtime::gen_initial_palette_load),
with the 32-byte default palette spliced into PRG under a
`__default_palette` data block. Net saving is ~120 bytes — ~20
bytes of code + 32 bytes of data vs ~170 bytes of unrolled stores.

Delete `Linker::gen_palette_load` (dead after the refactor) and its
unit test. Replace with two tests covering the observable
behaviour: the default palette bytes appear in PRG when no user
palette is declared, and the `__default_palette` label is
suppressed when the user does declare a palette.

Audio goldens flip again for audio_demo, noise_triangle_sfx, and
sfx_pitch_envelope. These are the three audio examples that don't
declare their own palette — shrinking the default-palette load
shifts their audio tick's absolute address by ~120 bytes, which
changes branch page-crossing timing and therefore the exact APU
register write sample offsets. Same class of drift as the
mul/divide gating commit.

https://claude.ai/code/session_016kM6P7PukktBDqTZexrrAN
This commit is contained in:
Claude 2026-04-16 13:07:45 +00:00
parent 033d399565
commit 37974611ae
No known key found for this signature in database
32 changed files with 74 additions and 46 deletions

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 {
@ -414,7 +426,16 @@ impl Linker {
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());
// 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.
all_instructions.extend(runtime::gen_initial_palette_load(DEFAULT_PALETTE_LABEL));
}
// Load the initial background if the program declared any.
@ -550,6 +571,18 @@ 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() {
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(),
@ -753,24 +786,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

@ -633,30 +633,45 @@ 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, 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.
let linker = Linker::new(Mirroring::Horizontal);
let palette_insts = linker.gen_palette_load();
let user_code = vec![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");
}
// 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"
);
}