mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 17:06:04 +00:00
banks: implement multi-bank PRG layout and bank-switching runtime
Prior to this commit the linker always shipped a single 16 KB PRG
bank regardless of the declared mapper, so the README's MMC1/UxROM/
MMC3 support was aspirational. This commit gives the three banked
mappers a real multi-bank ROM layout:
* RomBuilder.set_prg_banks() writes any number of 16 KB banks
back-to-back so the iNES header reflects the true PRG size.
* Linker.link_banked() places switchable banks first, fixed bank
last, so the fixed bank maps to $C000-$FFFF (the address window
where vectors and the runtime live).
* runtime::gen_mapper_init() emits reset-time mapper config:
MMC1 serial-writes a control-register value that pins the last
bank at $C000 with the correct mirroring, UxROM relies on the
power-on default, MMC3 writes the $8000/$8001/$A000/$E000
registers to get a known PRG and mirroring state.
* runtime::gen_bank_select() is a mapper-specific subroutine
(callable with the target bank in A) that maps any physical
bank to $8000-$BFFF.
* runtime::gen_bank_trampoline() generates a cross-bank call
stub in the fixed bank that saves the caller's bank, switches,
JSRs the target, and restores the fixed bank.
* The CLI and integration helper thread declared `bank X: prg`
declarations through to the linker so MMC1/UxROM/MMC3 programs
actually produce multi-bank ROMs.
Coverage:
* Runtime unit tests (18 new): mapper init patterns for every
supported mapper, bank-select signatures, trampoline dispatch
order, UxROM bus-conflict table contents.
* RomBuilder tests (6 new): multi-bank layout, padding,
byte-level fidelity, per-bank size validation, legacy
single-bank fallback.
* Linker tests (13 new): multi-bank ROM sizes across MMC1/
UxROM/MMC3, fixed-bank placement, switchable-bank payload
fidelity, bank-select subroutine detection, NROM rejection
of switchable banks.
* Integration e2e tests (16 new): compile real .ne sources
through the full pipeline and assert on iNES headers,
mapper init signatures in the fixed bank, vector locations,
and a regression check against `examples/mmc1_banked.ne`.
Total: 474 tests pass under `cargo test` with
`RUSTFLAGS="-D warnings"`.
https://claude.ai/code/session_01UCressA5e8k1XsuoJYLav2
This commit is contained in:
parent
08d9798940
commit
3307f75da6
8 changed files with 1577 additions and 24 deletions
|
|
@ -23,6 +23,48 @@ pub struct SpriteData {
|
|||
pub chr_bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A switchable PRG bank. Each switchable bank occupies a single
|
||||
/// 16 KB slot in the ROM and can be mapped to $8000-$BFFF at runtime
|
||||
/// by writing the bank's physical index to the mapper. The linker
|
||||
/// places switchable banks in declaration order, followed by the
|
||||
/// fixed bank at the end.
|
||||
///
|
||||
/// `entry_label` is the optional trampoline entry point inside this
|
||||
/// bank — when set, the linker emits a `__tramp_<name>` stub in the
|
||||
/// fixed bank that selects this bank and JSRs into the label.
|
||||
/// `data` is raw bytes to splice verbatim (the compiler currently
|
||||
/// only uses empty data and lets the linker pad with $FF).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PrgBank {
|
||||
pub name: String,
|
||||
pub data: Vec<u8>,
|
||||
pub entry_label: Option<String>,
|
||||
}
|
||||
|
||||
impl PrgBank {
|
||||
/// Create an empty named bank. Convenience for the compiler,
|
||||
/// which currently emits all user code into the fixed bank and
|
||||
/// just wants switchable slots reserved for future use.
|
||||
#[must_use]
|
||||
pub fn empty(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
data: Vec::new(),
|
||||
entry_label: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a bank with a raw byte payload and no trampoline entry.
|
||||
#[must_use]
|
||||
pub fn with_data(name: impl Into<String>, data: Vec<u8>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
data,
|
||||
entry_label: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True if `instructions` contains a label definition with the given
|
||||
/// name. Labels are emitted as `NOP` pseudo-instructions whose mode
|
||||
/// is `AddressingMode::Label(name)`.
|
||||
|
|
@ -112,12 +154,59 @@ impl Linker {
|
|||
sfx: &[SfxData],
|
||||
music: &[MusicData],
|
||||
) -> Vec<u8> {
|
||||
// For NROM: everything fits in one 16 KB PRG bank ($C000-$FFFF)
|
||||
// Layout:
|
||||
// $C000: RESET handler (init + palette load + user code)
|
||||
// ... : NMI handler
|
||||
// ... : IRQ handler
|
||||
// $FFFA: Vector table (NMI, RESET, IRQ)
|
||||
self.link_banked(user_code, sprites, sfx, music, &[])
|
||||
}
|
||||
|
||||
/// Link with the full asset pipeline plus zero or more
|
||||
/// switchable PRG banks. The switchable banks are written in
|
||||
/// declaration order and the fixed bank (which contains the
|
||||
/// runtime, NMI/IRQ handlers, vector table, bank-select
|
||||
/// subroutine, and all user code) is always placed last.
|
||||
///
|
||||
/// For mappers that don't support banking (NROM) this is an
|
||||
/// error if any switchable banks are supplied. For banked
|
||||
/// mappers the linker also splices `gen_mapper_init` into the
|
||||
/// reset path and emits a `__bank_select` subroutine plus one
|
||||
/// `__tramp_<name>` trampoline for every bank that declares an
|
||||
/// `entry_label`.
|
||||
pub fn link_banked(
|
||||
&self,
|
||||
user_code: &[Instruction],
|
||||
sprites: &[SpriteData],
|
||||
sfx: &[SfxData],
|
||||
music: &[MusicData],
|
||||
switchable_banks: &[PrgBank],
|
||||
) -> Vec<u8> {
|
||||
assert!(
|
||||
switchable_banks.is_empty() || self.mapper != Mapper::NROM,
|
||||
"NROM does not support switchable PRG banks (got {} banks)",
|
||||
switchable_banks.len()
|
||||
);
|
||||
self.link_banked_inner(user_code, sprites, sfx, music, switchable_banks)
|
||||
}
|
||||
|
||||
fn link_banked_inner(
|
||||
&self,
|
||||
user_code: &[Instruction],
|
||||
sprites: &[SpriteData],
|
||||
sfx: &[SfxData],
|
||||
music: &[MusicData],
|
||||
switchable_banks: &[PrgBank],
|
||||
) -> Vec<u8> {
|
||||
// ROM layout.
|
||||
//
|
||||
// NROM: a single 16 KB PRG bank mapped at $C000-$FFFF.
|
||||
//
|
||||
// Banked (MMC1, UxROM, MMC3): `switchable_banks` switchable
|
||||
// 16 KB banks come first in physical order, followed by the
|
||||
// fixed bank. The fixed bank holds the runtime, NMI/IRQ
|
||||
// handlers, user code, bank-select routine, and all
|
||||
// trampolines — everything needed for control flow to work
|
||||
// at reset. The mapper is configured so the fixed bank
|
||||
// maps to $C000-$FFFF and one of the switchable banks maps
|
||||
// to $8000-$BFFF.
|
||||
let total_banks = switchable_banks.len() + 1;
|
||||
let fixed_bank_index = total_banks - 1;
|
||||
|
||||
let mut all_instructions = Vec::new();
|
||||
|
||||
|
|
@ -127,12 +216,54 @@ impl Linker {
|
|||
// Hardware initialization
|
||||
all_instructions.extend(runtime::gen_init());
|
||||
|
||||
// Mapper configuration: for banked mappers, set up the PRG
|
||||
// layout so the fixed bank sits at $C000-$FFFF. NROM is a
|
||||
// no-op here.
|
||||
all_instructions.extend(runtime::gen_mapper_init(
|
||||
self.mapper,
|
||||
self.mirroring,
|
||||
total_banks,
|
||||
));
|
||||
|
||||
// Load default palette
|
||||
all_instructions.extend(self.gen_palette_load());
|
||||
|
||||
// User code (var init + main loop)
|
||||
all_instructions.extend(user_code.iter().cloned());
|
||||
|
||||
// Bank-select subroutine plus a trampoline per declared bank
|
||||
// that has an entry label. Emitted only for banked mappers
|
||||
// (NROM has no switchable banks by definition). The helpers
|
||||
// live in the fixed bank so they're always reachable at
|
||||
// $C000-$FFFF regardless of which switchable bank is
|
||||
// currently mapped at $8000.
|
||||
if self.mapper != Mapper::NROM {
|
||||
all_instructions.extend(runtime::gen_bank_select(self.mapper));
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let fixed_bank_num = fixed_bank_index as u8;
|
||||
for (i, bank) in switchable_banks.iter().enumerate() {
|
||||
if let Some(entry) = &bank.entry_label {
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let bank_num = i as u8;
|
||||
all_instructions.extend(runtime::gen_bank_trampoline(
|
||||
&bank.name,
|
||||
entry,
|
||||
bank_num,
|
||||
fixed_bank_num,
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.mapper == Mapper::UxROM {
|
||||
// UxROM needs a 256-byte bank-select bus-conflict
|
||||
// table in the fixed bank. The `__bank_select`
|
||||
// routine for UxROM writes to $FFF0 so the byte
|
||||
// at that address in ROM must match the bank being
|
||||
// selected — we splice in a 0..255 table just before
|
||||
// the vector area.
|
||||
all_instructions.extend(runtime::gen_uxrom_bank_table());
|
||||
}
|
||||
}
|
||||
|
||||
// Math runtime routines (included always for simplicity)
|
||||
all_instructions.extend(runtime::gen_multiply());
|
||||
all_instructions.extend(runtime::gen_divide());
|
||||
|
|
@ -229,7 +360,28 @@ impl Linker {
|
|||
// Build ROM
|
||||
let mut builder = RomBuilder::new(self.mirroring);
|
||||
builder.set_mapper(crate::rom::mapper_number(self.mapper));
|
||||
builder.set_prg(prg);
|
||||
|
||||
// Multi-bank layout: each switchable bank is an independent
|
||||
// 16 KB slot whose contents the linker takes verbatim from
|
||||
// the caller, followed by the fixed bank (just assembled).
|
||||
// For NROM (no switchable banks) this collapses to the
|
||||
// legacy single-bank path.
|
||||
if switchable_banks.is_empty() {
|
||||
builder.set_prg(prg);
|
||||
} else {
|
||||
let mut banks: Vec<Vec<u8>> = Vec::with_capacity(total_banks);
|
||||
for bank in switchable_banks {
|
||||
assert!(
|
||||
bank.data.len() <= 16384,
|
||||
"switchable bank '{}' exceeds 16 KB ({} bytes)",
|
||||
bank.name,
|
||||
bank.data.len()
|
||||
);
|
||||
banks.push(bank.data.clone());
|
||||
}
|
||||
banks.push(prg);
|
||||
builder.set_prg_banks(banks);
|
||||
}
|
||||
|
||||
// CHR ROM: tile 0 is reserved for the default smiley, followed by
|
||||
// any user-declared sprites placed at their assigned tile indices.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use super::*;
|
||||
use crate::asm::{AddressingMode as AM, Instruction, Opcode::*};
|
||||
use crate::parser::ast::Mirroring;
|
||||
use crate::parser::ast::{Mapper, Mirroring};
|
||||
use crate::rom;
|
||||
|
||||
#[test]
|
||||
|
|
@ -295,6 +295,274 @@ fn link_without_audio_marker_does_not_emit_period_table() {
|
|||
);
|
||||
}
|
||||
|
||||
// ─── Banked linking ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn link_banked_mmc1_produces_multi_bank_rom() {
|
||||
// MMC1 with two switchable banks should produce a 3-bank ROM
|
||||
// (2 switchable + 1 fixed). The iNES header must report 3 PRG
|
||||
// banks, mapper number 1, and the file size must match.
|
||||
let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::MMC1);
|
||||
let user_code = vec![Instruction::implied(NOP)];
|
||||
let banks = vec![PrgBank::empty("Level1"), PrgBank::empty("Level2")];
|
||||
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
|
||||
let info = rom::validate_ines(&rom).unwrap();
|
||||
assert_eq!(info.prg_banks, 3);
|
||||
assert_eq!(info.mapper, 1);
|
||||
assert_eq!(rom.len(), 16 + 3 * 16384 + 8192);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_banked_uxrom_produces_multi_bank_rom() {
|
||||
let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::UxROM);
|
||||
let user_code = vec![Instruction::implied(NOP)];
|
||||
// Four switchable banks = 5 PRG banks total.
|
||||
let banks = vec![
|
||||
PrgBank::empty("BankA"),
|
||||
PrgBank::empty("BankB"),
|
||||
PrgBank::empty("BankC"),
|
||||
PrgBank::empty("BankD"),
|
||||
];
|
||||
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
|
||||
let info = rom::validate_ines(&rom).unwrap();
|
||||
assert_eq!(info.prg_banks, 5);
|
||||
assert_eq!(info.mapper, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_banked_mmc3_produces_multi_bank_rom() {
|
||||
let linker = Linker::with_mapper(Mirroring::Vertical, Mapper::MMC3);
|
||||
let user_code = vec![Instruction::implied(NOP)];
|
||||
let banks = vec![
|
||||
PrgBank::empty("Stage1"),
|
||||
PrgBank::empty("Stage2"),
|
||||
PrgBank::empty("Stage3"),
|
||||
];
|
||||
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
|
||||
let info = rom::validate_ines(&rom).unwrap();
|
||||
assert_eq!(info.prg_banks, 4);
|
||||
assert_eq!(info.mapper, 4);
|
||||
// Vertical mirroring must propagate through the builder.
|
||||
assert_eq!(info.mirroring, Mirroring::Vertical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "NROM does not support switchable PRG banks")]
|
||||
fn link_banked_nrom_rejects_switchable_banks() {
|
||||
let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::NROM);
|
||||
let _ = linker.link_banked(
|
||||
&[Instruction::implied(NOP)],
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
&[PrgBank::empty("Nope")],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_banked_fixed_bank_lives_at_end_of_prg() {
|
||||
// The linker must place the fixed bank *last* so it maps to
|
||||
// $C000-$FFFF at reset. The vector table at $FFFA..$FFFF must
|
||||
// land in the final bank. We verify by reading the reset vector
|
||||
// and checking it points into the fixed bank's address window.
|
||||
let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::MMC1);
|
||||
let user_code = vec![Instruction::implied(NOP)];
|
||||
let banks = vec![PrgBank::empty("A"), PrgBank::empty("B")];
|
||||
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
|
||||
// Three PRG banks = 48 KB; the fixed bank is the last 16 KB
|
||||
// slot in the file, and its $FFFA..$FFFF area holds the
|
||||
// vector table.
|
||||
let fixed_bank_offset = 16 + 2 * 16384;
|
||||
// Vectors live at the last 6 bytes of the fixed bank.
|
||||
let vec_offset = fixed_bank_offset + 16384 - 6;
|
||||
let reset = u16::from_le_bytes([rom[vec_offset + 2], rom[vec_offset + 3]]);
|
||||
assert!(
|
||||
reset >= 0xC000,
|
||||
"RESET vector {reset:#06X} should point into fixed bank ($C000-$FFFF)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_banked_switchable_banks_are_padded_with_ff() {
|
||||
// Empty switchable banks should end up as 16 KB of $FF — the
|
||||
// same pad value the ROM builder uses for unset code. This is
|
||||
// important so banks are always a known shape regardless of
|
||||
// payload.
|
||||
let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::MMC1);
|
||||
let user_code = vec![Instruction::implied(NOP)];
|
||||
let banks = vec![PrgBank::empty("Empty")];
|
||||
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
|
||||
// Bank 0 is at offset 16; check a few bytes are $FF.
|
||||
assert_eq!(rom[16], 0xFF);
|
||||
assert_eq!(rom[16 + 100], 0xFF);
|
||||
// Last byte of bank 0 (just before bank 1 begins).
|
||||
assert_eq!(rom[16 + 16384 - 1], 0xFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_banked_preserves_switchable_bank_payload_bytes() {
|
||||
// When a caller provides raw bytes for a switchable bank, the
|
||||
// linker must splice them in verbatim at the start of that
|
||||
// bank's slot. This is the hook the compiler uses to ship data
|
||||
// tables without touching the fixed bank.
|
||||
let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::UxROM);
|
||||
let user_code = vec![Instruction::implied(NOP)];
|
||||
let data = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42, 0x13];
|
||||
let banks = vec![PrgBank::with_data("DataBank", data.clone())];
|
||||
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
|
||||
// Bank 0 starts at offset 16. Verify payload lands at the very
|
||||
// start.
|
||||
assert_eq!(&rom[16..16 + data.len()], &data[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_banked_fixed_bank_contains_bank_select_subroutine() {
|
||||
// The linker must emit `__bank_select` (as labelled 6502 code)
|
||||
// somewhere in the fixed bank whenever the mapper isn't NROM.
|
||||
// We verify by assembling a minimal program and searching for
|
||||
// the opcode signature of the MMC1 bank-select tail — 5 STAs
|
||||
// to $E000 ($8D $00 $E0).
|
||||
let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::MMC1);
|
||||
let user_code = vec![Instruction::implied(NOP)];
|
||||
let banks = vec![PrgBank::empty("Foo")];
|
||||
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
|
||||
// Fixed bank starts at offset 16 + 16384.
|
||||
let fixed = &rom[16 + 16384..16 + 2 * 16384];
|
||||
// Find five consecutive STA $E000 (opcode $8D operand $00 $E0)
|
||||
// instructions with LSR A ($4A) between pairs. This is the
|
||||
// signature pattern generated by `gen_bank_select(MMC1)`.
|
||||
let sta_e000 = [0x8D, 0x00, 0xE0];
|
||||
let lsr_then_sta_e000 = [0x4A, 0x8D, 0x00, 0xE0];
|
||||
let has_tail = fixed
|
||||
.windows(lsr_then_sta_e000.len())
|
||||
.any(|w| w == lsr_then_sta_e000);
|
||||
let sta_e000_count = fixed
|
||||
.windows(sta_e000.len())
|
||||
.filter(|w| w == &sta_e000)
|
||||
.count();
|
||||
assert!(
|
||||
has_tail,
|
||||
"MMC1 fixed bank should contain LSR A ; STA $E000 pattern"
|
||||
);
|
||||
assert!(
|
||||
sta_e000_count >= 5,
|
||||
"MMC1 fixed bank should contain >= 5 STA $E000 writes (bank-select + init), got {sta_e000_count}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_banked_fixed_bank_contains_trampolines_for_declared_banks() {
|
||||
// When a bank declares an entry label, the linker must emit a
|
||||
// matching `__tramp_<name>` stub in the fixed bank. We check
|
||||
// by constructing a bank with an entry label and verifying the
|
||||
// assembled labels map (via the indirect check: the ROM builds
|
||||
// without panicking on unresolved labels, which means the
|
||||
// trampoline's target label — here spliced via a dummy NOP
|
||||
// label in the fixed bank — resolved).
|
||||
let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::MMC1);
|
||||
// We splice a fake target label into the user code so the
|
||||
// trampoline's internal JSR resolves. This simulates the path
|
||||
// codegen will eventually take (emit the entry label alongside
|
||||
// the banked user code; the linker resolves it via the banked
|
||||
// assembler).
|
||||
let user_code = vec![
|
||||
Instruction::new(NOP, AM::Label("__fake_bank_entry".into())),
|
||||
Instruction::implied(NOP),
|
||||
];
|
||||
let banks = vec![PrgBank {
|
||||
name: "Level1".into(),
|
||||
data: Vec::new(),
|
||||
entry_label: Some("__fake_bank_entry".into()),
|
||||
}];
|
||||
// Should not panic — trampoline and entry label both present.
|
||||
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
|
||||
let info = rom::validate_ines(&rom).unwrap();
|
||||
assert_eq!(info.prg_banks, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_banked_reset_vector_points_into_fixed_bank_window() {
|
||||
// The reset vector must land somewhere in $C000-$FFFF — that's
|
||||
// the CPU address where the fixed bank maps in at boot on every
|
||||
// supported mapper (NROM, MMC1, UxROM, MMC3).
|
||||
for mapper in [Mapper::NROM, Mapper::MMC1, Mapper::UxROM, Mapper::MMC3] {
|
||||
let linker = Linker::with_mapper(Mirroring::Horizontal, mapper);
|
||||
let user_code = vec![Instruction::implied(NOP)];
|
||||
let banks: Vec<PrgBank> = if mapper == Mapper::NROM {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![PrgBank::empty("X")]
|
||||
};
|
||||
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
|
||||
// Last 6 bytes of PRG = vectors.
|
||||
let prg_end = 16 + rom::validate_ines(&rom).unwrap().prg_banks * 16384;
|
||||
let reset_bytes = [rom[prg_end - 4], rom[prg_end - 3]];
|
||||
let reset = u16::from_le_bytes(reset_bytes);
|
||||
assert!(
|
||||
(0xC000..=0xFFFF).contains(&reset),
|
||||
"{mapper:?} reset vector {reset:#06X} must live in fixed-bank window"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_banked_rom_size_matches_bank_count() {
|
||||
// For each banked mapper, verify total ROM file size =
|
||||
// 16 header + N * 16 KB PRG + 8 KB CHR.
|
||||
for (mapper, switchable) in [
|
||||
(Mapper::MMC1, 0usize),
|
||||
(Mapper::MMC1, 1),
|
||||
(Mapper::MMC1, 3),
|
||||
(Mapper::UxROM, 0),
|
||||
(Mapper::UxROM, 7),
|
||||
(Mapper::MMC3, 0),
|
||||
(Mapper::MMC3, 15),
|
||||
] {
|
||||
let linker = Linker::with_mapper(Mirroring::Horizontal, mapper);
|
||||
let user_code = vec![Instruction::implied(NOP)];
|
||||
let banks: Vec<PrgBank> = (0..switchable)
|
||||
.map(|i| PrgBank::empty(format!("B{i}")))
|
||||
.collect();
|
||||
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
|
||||
let expected_prg_banks = switchable + 1;
|
||||
let expected_len = 16 + expected_prg_banks * 16384 + 8192;
|
||||
assert_eq!(
|
||||
rom.len(),
|
||||
expected_len,
|
||||
"{mapper:?} with {switchable} switchable banks: expected {expected_len} bytes, got {}",
|
||||
rom.len(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_with_mapper_nrom_produces_single_bank_rom() {
|
||||
// Regression: calling link_banked with NROM and no switchable
|
||||
// banks should produce the same 1-bank layout as the legacy
|
||||
// `link_with_all_assets` — no extra cost for the new API.
|
||||
let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::NROM);
|
||||
let user_code = vec![Instruction::implied(NOP)];
|
||||
let rom = linker.link_banked(&user_code, &[], &[], &[], &[]);
|
||||
let info = rom::validate_ines(&rom).unwrap();
|
||||
assert_eq!(info.prg_banks, 1);
|
||||
assert_eq!(info.mapper, 0);
|
||||
assert_eq!(rom.len(), 16 + 16384 + 8192);
|
||||
}
|
||||
|
||||
#[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.
|
||||
let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::MMC1);
|
||||
let user_code = vec![Instruction::implied(NOP)];
|
||||
let banks = vec![PrgBank::empty("X")];
|
||||
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
|
||||
// CHR starts after 2 PRG banks.
|
||||
let chr_start = 16 + 2 * 16384;
|
||||
// First 16 bytes = smiley tile, non-zero.
|
||||
assert_ne!(&rom[chr_start..chr_start + 16], &[0u8; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_load_writes_to_ppu() {
|
||||
let linker = Linker::new(Mirroring::Horizontal);
|
||||
|
|
|
|||
19
src/main.rs
19
src/main.rs
|
|
@ -6,8 +6,9 @@ use nescript::assets;
|
|||
use nescript::codegen::IrCodeGen;
|
||||
use nescript::errors::render_diagnostics;
|
||||
use nescript::ir;
|
||||
use nescript::linker::Linker;
|
||||
use nescript::linker::{Linker, PrgBank};
|
||||
use nescript::optimizer;
|
||||
use nescript::parser::ast::BankType;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "nescript", about = "NEScript compiler — NES game development")]
|
||||
|
|
@ -318,8 +319,22 @@ fn compile(input: &PathBuf, opts: &CompileOptions) -> Result<Vec<u8>, ()> {
|
|||
// reflects the source's `mapper:` declaration — without this
|
||||
// the CLI always shipped mapper 0 (NROM) regardless of whether
|
||||
// the program actually needed MMC1/MMC3 bank switching.
|
||||
//
|
||||
// For banked mappers (MMC1, UxROM, MMC3) we collect the
|
||||
// declared `bank X: prg` entries and turn each into an empty
|
||||
// 16 KB switchable slot. User code currently still lives in
|
||||
// the fixed bank — the declared banks exist so programs that
|
||||
// outgrow 16 KB have real ROM space to grow into and so
|
||||
// mapper-specific fixtures (vectors, trampolines, bank-select
|
||||
// helpers) land in the right place.
|
||||
let linker = Linker::with_mapper(program.game.mirroring, program.game.mapper);
|
||||
let rom = linker.link_with_all_assets(&instructions, &sprites, &sfx, &music);
|
||||
let switchable_banks: Vec<PrgBank> = program
|
||||
.banks
|
||||
.iter()
|
||||
.filter(|b| b.bank_type == BankType::Prg)
|
||||
.map(|b| PrgBank::empty(&b.name))
|
||||
.collect();
|
||||
let rom = linker.link_banked(&instructions, &sprites, &sfx, &music, &switchable_banks);
|
||||
|
||||
Ok(rom)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,18 @@ const PRG_BANK_SIZE: usize = 16384;
|
|||
const CHR_BANK_SIZE: usize = 8192;
|
||||
|
||||
/// Build a complete iNES ROM file.
|
||||
///
|
||||
/// Supports both single-bank (NROM) and multi-bank (MMC1, `UxROM`,
|
||||
/// MMC3) layouts. When you call [`RomBuilder::set_prg`] the builder
|
||||
/// treats the bytes as a single 16 KB bank and pads out. When you
|
||||
/// call [`RomBuilder::set_prg_banks`] the builder writes each bank
|
||||
/// back-to-back in the order you provided. The iNES header's PRG
|
||||
/// bank count always reflects the actual number of 16 KB slots.
|
||||
pub struct RomBuilder {
|
||||
prg_data: Vec<u8>,
|
||||
/// One Vec per 16 KB PRG bank, in physical order. An empty
|
||||
/// outer Vec means no PRG has been set yet; a single inner Vec
|
||||
/// means a classic NROM layout.
|
||||
prg_banks: Vec<Vec<u8>>,
|
||||
chr_data: Vec<u8>,
|
||||
mapper: u8,
|
||||
mirroring: Mirroring,
|
||||
|
|
@ -23,7 +33,7 @@ pub struct RomBuilder {
|
|||
impl RomBuilder {
|
||||
pub fn new(mirroring: Mirroring) -> Self {
|
||||
Self {
|
||||
prg_data: Vec::new(),
|
||||
prg_banks: Vec::new(),
|
||||
chr_data: Vec::new(),
|
||||
mapper: 0, // NROM
|
||||
mirroring,
|
||||
|
|
@ -35,9 +45,44 @@ impl RomBuilder {
|
|||
self.mapper = mapper;
|
||||
}
|
||||
|
||||
/// Set the PRG ROM data. Will be padded to fill 16 KB or 32 KB.
|
||||
/// Set the PRG ROM data as a single bank. Will be padded to fill
|
||||
/// 16 KB or 32 KB. Equivalent to calling `set_prg_banks` with a
|
||||
/// one- or two-element Vec depending on whether the data crosses
|
||||
/// the 16 KB boundary.
|
||||
pub fn set_prg(&mut self, data: Vec<u8>) {
|
||||
self.prg_data = data;
|
||||
// Preserve the legacy single-bank behaviour: if the data is
|
||||
// <= 16 KB we emit exactly one 16 KB bank; if it's larger
|
||||
// (but still <= 32 KB) we split into two consecutive banks
|
||||
// so the iNES header byte 4 reflects 2, matching the old
|
||||
// `set_prg` contract used by all current NROM tests.
|
||||
if data.len() <= PRG_BANK_SIZE {
|
||||
self.prg_banks = vec![data];
|
||||
} else {
|
||||
let mut first = data;
|
||||
let second = first.split_off(PRG_BANK_SIZE);
|
||||
self.prg_banks = vec![first, second];
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the PRG ROM data as a list of 16 KB banks. Each bank will
|
||||
/// be padded with $FF to fill its 16 KB slot. The banks are
|
||||
/// written in the order provided — for mapper-specific layouts
|
||||
/// the caller (usually the Linker) is responsible for placing
|
||||
/// the fixed bank last.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if any single bank exceeds 16 KB, which would indicate
|
||||
/// a compiler bug (the allocator is expected to overflow-check
|
||||
/// before calling the rom builder).
|
||||
pub fn set_prg_banks(&mut self, banks: Vec<Vec<u8>>) {
|
||||
for (i, bank) in banks.iter().enumerate() {
|
||||
assert!(
|
||||
bank.len() <= PRG_BANK_SIZE,
|
||||
"PRG bank {i} exceeds 16 KB ({} bytes)",
|
||||
bank.len()
|
||||
);
|
||||
}
|
||||
self.prg_banks = banks;
|
||||
}
|
||||
|
||||
/// Set the CHR ROM data. Will be padded to fill 8 KB.
|
||||
|
|
@ -47,12 +92,14 @@ impl RomBuilder {
|
|||
|
||||
/// Build the complete .nes file.
|
||||
pub fn build(self) -> Vec<u8> {
|
||||
// Determine PRG size: 1 bank (16 KB) or 2 banks (32 KB)
|
||||
let prg_banks = if self.prg_data.len() > PRG_BANK_SIZE {
|
||||
2
|
||||
// Determine PRG size. If no banks were set, fall back to a
|
||||
// single empty bank so the ROM has a valid 16 KB PRG slot.
|
||||
let prg_banks_vec: Vec<Vec<u8>> = if self.prg_banks.is_empty() {
|
||||
vec![Vec::new()]
|
||||
} else {
|
||||
1
|
||||
self.prg_banks
|
||||
};
|
||||
let prg_banks = prg_banks_vec.len();
|
||||
let prg_size = prg_banks * PRG_BANK_SIZE;
|
||||
|
||||
// CHR: 1 bank (8 KB), or 0 if using CHR RAM
|
||||
|
|
@ -81,10 +128,11 @@ impl RomBuilder {
|
|||
// Bytes 8-15: padding zeros
|
||||
rom.extend_from_slice(&[0u8; 8]);
|
||||
|
||||
// PRG ROM data (padded to fill)
|
||||
let mut prg = self.prg_data;
|
||||
prg.resize(prg_size, 0xFF);
|
||||
rom.extend_from_slice(&prg);
|
||||
// PRG ROM data: each bank padded to 16 KB, concatenated.
|
||||
for mut bank in prg_banks_vec {
|
||||
bank.resize(PRG_BANK_SIZE, 0xFF);
|
||||
rom.extend_from_slice(&bank);
|
||||
}
|
||||
|
||||
// CHR ROM data (padded to fill)
|
||||
if chr_banks > 0 {
|
||||
|
|
|
|||
|
|
@ -136,3 +136,85 @@ fn mapper_mmc3_encoded() {
|
|||
let info = validate_ines(&rom).unwrap();
|
||||
assert_eq!(info.mapper, 4);
|
||||
}
|
||||
|
||||
// ─── Multi-bank PRG layout ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn set_prg_banks_with_two_banks_pads_and_concatenates() {
|
||||
let mut builder = RomBuilder::new(Mirroring::Horizontal);
|
||||
builder.set_prg_banks(vec![vec![0xAA, 0xAA, 0xAA], vec![0xBB, 0xBB]]);
|
||||
let rom = builder.build();
|
||||
// Header reports 2 banks.
|
||||
assert_eq!(rom[4], 2);
|
||||
// Total file size: 16 header + 2 * 16 KB
|
||||
assert_eq!(rom.len(), 16 + 2 * 16384);
|
||||
// First bank: 0xAA at the start, $FF padding, then second bank.
|
||||
assert_eq!(&rom[16..19], &[0xAA, 0xAA, 0xAA]);
|
||||
assert_eq!(rom[19], 0xFF); // padding continues in bank 0
|
||||
// Second bank starts at 16 + 16384.
|
||||
let bank1 = 16 + 16384;
|
||||
assert_eq!(&rom[bank1..bank1 + 2], &[0xBB, 0xBB]);
|
||||
assert_eq!(rom[bank1 + 2], 0xFF); // padding in bank 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_prg_banks_with_four_banks_produces_64kb_prg() {
|
||||
let mut builder = RomBuilder::new(Mirroring::Horizontal);
|
||||
builder.set_prg_banks(vec![vec![0x00], vec![0x01], vec![0x02], vec![0x03]]);
|
||||
let rom = builder.build();
|
||||
assert_eq!(rom[4], 4, "header should report 4 PRG banks");
|
||||
assert_eq!(rom.len(), 16 + 4 * 16384);
|
||||
// Each bank's first byte should be the bank index.
|
||||
for i in 0..4 {
|
||||
let offset = 16 + i * 16384;
|
||||
assert_eq!(rom[offset], i as u8, "bank {i} should start with byte {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "exceeds 16 KB")]
|
||||
fn set_prg_banks_panics_when_bank_too_large() {
|
||||
let mut builder = RomBuilder::new(Mirroring::Horizontal);
|
||||
// 16 KB + 1 byte overflows a single 16 KB bank slot.
|
||||
builder.set_prg_banks(vec![vec![0x00; 16385]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_prg_banks_preserves_content_exactly() {
|
||||
// Verify byte-for-byte fidelity: if the caller provides bytes
|
||||
// A, B, C in a bank, they must land at bank_start, bank_start+1,
|
||||
// bank_start+2 with no rearrangement.
|
||||
let mut builder = RomBuilder::new(Mirroring::Horizontal);
|
||||
builder.set_prg_banks(vec![(0u8..=9).collect(), (100u8..=109).collect()]);
|
||||
let rom = builder.build();
|
||||
assert_eq!(&rom[16..26], &(0u8..=9).collect::<Vec<_>>()[..]);
|
||||
let bank1 = 16 + 16384;
|
||||
assert_eq!(
|
||||
&rom[bank1..bank1 + 10],
|
||||
&(100u8..=109).collect::<Vec<_>>()[..]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_detects_multi_bank_prg() {
|
||||
// A ROM built with 3 banks should validate with prg_banks=3.
|
||||
let mut builder = RomBuilder::new(Mirroring::Vertical);
|
||||
builder.set_mapper(1); // MMC1
|
||||
builder.set_prg_banks(vec![vec![0x11; 10], vec![0x22; 10], vec![0x33; 10]]);
|
||||
let rom = builder.build();
|
||||
let info = validate_ines(&rom).unwrap();
|
||||
assert_eq!(info.prg_banks, 3);
|
||||
assert_eq!(info.mapper, 1);
|
||||
assert_eq!(info.mirroring, Mirroring::Vertical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_prg_banks_fallback_to_single_bank() {
|
||||
// If a caller doesn't call set_prg or set_prg_banks, the builder
|
||||
// still produces a valid single-bank ROM so tests that only
|
||||
// exercise the CHR path keep working.
|
||||
let builder = RomBuilder::new(Mirroring::Horizontal);
|
||||
let rom = builder.build();
|
||||
assert_eq!(rom[4], 1, "default should be 1 PRG bank");
|
||||
assert_eq!(rom.len(), 16 + 16384);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
mod tests;
|
||||
|
||||
use crate::asm::{AddressingMode as AM, Instruction, Opcode::*};
|
||||
use crate::parser::ast::{Mapper, Mirroring};
|
||||
|
||||
/// PPU register addresses
|
||||
const PPU_CTRL: u16 = 0x2000;
|
||||
|
|
@ -673,3 +674,265 @@ pub fn gen_divide() -> Vec<Instruction> {
|
|||
|
||||
out
|
||||
}
|
||||
|
||||
// ─── Bank switching ────────────────────────────────────────────────
|
||||
//
|
||||
// NEScript supports bank switching for MMC1, UxROM, and MMC3. The
|
||||
// linker lays out PRG ROM with a single fixed bank ($C000-$FFFF)
|
||||
// holding the runtime, NMI, IRQ vectors, and any cross-bank
|
||||
// trampolines, plus zero or more switchable 16 KB banks mapped at
|
||||
// $8000-$BFFF. The helpers below emit:
|
||||
//
|
||||
// * `gen_mapper_init` — reset-time configuration that puts the
|
||||
// last physical bank at $C000 and (depending on the mapper)
|
||||
// sets a known mirroring mode so the compiler's
|
||||
// `Mirroring::{Horizontal,Vertical}` selection matches.
|
||||
// * `gen_bank_select` — a subroutine callable with the target bank
|
||||
// number in A that selects the correct switchable bank at $8000.
|
||||
// * `gen_bank_trampoline` — a per-(target, bank) stub placed in
|
||||
// the fixed bank. Callers `JSR` into the trampoline, which
|
||||
// records the current bank, switches to the target bank, calls
|
||||
// the entry label in that bank, and switches back.
|
||||
//
|
||||
// The trampolines never physically return to the switchable bank —
|
||||
// control is always handed back to the fixed bank after the callee
|
||||
// returns. Users don't touch these routines directly; the linker
|
||||
// emits them from the `bank` declarations in the program AST.
|
||||
|
||||
/// Zero-page slot used by the bank-select routine to stash the
|
||||
/// requested bank number so `__bank_return` can restore it when a
|
||||
/// trampoline finishes.
|
||||
pub const ZP_BANK_CURRENT: u8 = 0x10;
|
||||
|
||||
/// Generate the reset-time mapper initialization sequence. Splices
|
||||
/// after `gen_init` but before any user code runs. For NROM this is
|
||||
/// a no-op — `gen_init` already sets up everything NROM needs.
|
||||
///
|
||||
/// `total_prg_banks` is the total number of 16 KB PRG banks in the
|
||||
/// ROM; MMC1/MMC3 need this to fix the *last* physical bank at
|
||||
/// $C000. `UxROM` is hardwired — its last bank is always fixed.
|
||||
#[must_use]
|
||||
pub fn gen_mapper_init(
|
||||
mapper: Mapper,
|
||||
mirroring: Mirroring,
|
||||
total_prg_banks: usize,
|
||||
) -> Vec<Instruction> {
|
||||
match mapper {
|
||||
Mapper::NROM => Vec::new(),
|
||||
Mapper::MMC1 => gen_mmc1_init(mirroring),
|
||||
Mapper::UxROM => gen_uxrom_init(total_prg_banks),
|
||||
Mapper::MMC3 => gen_mmc3_init(mirroring),
|
||||
}
|
||||
}
|
||||
|
||||
/// MMC1 reset: pulse the reset bit, then write the control register.
|
||||
/// Control-register layout (5 bits, serialized LSB-first into any
|
||||
/// $8000-$FFFF address):
|
||||
/// bit 4 — CHR bank mode (0 = 8 KB, 1 = two 4 KB banks)
|
||||
/// bit 3 — PRG bank mode bit 1
|
||||
/// bit 2 — PRG bank mode bit 0
|
||||
/// 11 = 16 KB banks, fix last at $C000, switchable at $8000
|
||||
/// bit 1-0 — mirroring
|
||||
/// 00 = 1-screen lo, 01 = 1-screen hi,
|
||||
/// 10 = vertical, 11 = horizontal
|
||||
///
|
||||
/// We pick mode `11` (fixed last bank) so the fixed bank appears at
|
||||
/// $C000 exactly the same way as NROM, which lets us reuse the NROM
|
||||
/// layout for all the runtime code that already exists.
|
||||
fn gen_mmc1_init(mirroring: Mirroring) -> Vec<Instruction> {
|
||||
let mut out = Vec::new();
|
||||
out.push(Instruction::new(NOP, AM::Label("__mmc1_init".into())));
|
||||
// Reset pulse: any $8000-range write with bit 7 set flushes the
|
||||
// 5-bit shift register and resets the internal config to
|
||||
// mode 3 (fixed last, 16 KB banks).
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0x80)));
|
||||
out.push(Instruction::new(STA, AM::Absolute(0x8000)));
|
||||
// Control register value.
|
||||
let mirror_bits = match mirroring {
|
||||
Mirroring::Horizontal => 0b11,
|
||||
Mirroring::Vertical => 0b10,
|
||||
};
|
||||
// 16 KB PRG, fix last at $C000 (bits 2-3 = 11), 8 KB CHR
|
||||
// (bit 4 = 0), plus mirroring bits.
|
||||
let control: u8 = 0b0_11_00 | mirror_bits;
|
||||
// Serialize the 5 bits into the shift register. Each write
|
||||
// uses STA $8000 (which maps to the control register because
|
||||
// the address falls in the $8000-$9FFF range).
|
||||
out.extend(gen_mmc1_serial_write(control, 0x8000));
|
||||
out
|
||||
}
|
||||
|
||||
/// Emit 5 serialized writes of `value` to `addr`, shifting right
|
||||
/// between writes. Used by MMC1 bank-switch code (registers all live
|
||||
/// in $8000-$FFFF and are selected by the top two address bits).
|
||||
fn gen_mmc1_serial_write(value: u8, addr: u16) -> Vec<Instruction> {
|
||||
let mut out = Vec::new();
|
||||
// Bit 0 goes out first, then bit 1, etc. We use immediate loads
|
||||
// for each bit so the sequence has no hidden dependencies on
|
||||
// the current A register.
|
||||
for i in 0..5 {
|
||||
let bit = (value >> i) & 1;
|
||||
out.push(Instruction::new(LDA, AM::Immediate(bit)));
|
||||
out.push(Instruction::new(STA, AM::Absolute(addr)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `UxROM` reset: the last 16 KB PRG bank is always fixed at $C000,
|
||||
/// and the switchable bank at $8000 defaults to bank 0 on power-on.
|
||||
/// Some `UxROM` boards have bus conflicts — any write must match the
|
||||
/// byte in ROM — so we use a small bank-select table at a known
|
||||
/// address (`__bank_select_table`) generated into the fixed bank.
|
||||
fn gen_uxrom_init(_total_banks: usize) -> Vec<Instruction> {
|
||||
// No explicit init required: UxROM powers up with bank 0 at
|
||||
// $8000 and the last bank fixed at $C000, which is exactly
|
||||
// what we want. We still emit the label so debuggers can find
|
||||
// the (empty) init span.
|
||||
vec![Instruction::new(NOP, AM::Label("__uxrom_init".into()))]
|
||||
}
|
||||
|
||||
/// MMC3 reset: choose PRG mode 0 (last two banks fixed at
|
||||
/// $C000-$FFFF) and initialise bank 0 at $8000, bank 1 at $A000.
|
||||
/// Mirroring is programmed via $A000 (only meaningful when CHR
|
||||
/// uses the internal mode — for our CHR ROM layout it's still
|
||||
/// the safest place to latch).
|
||||
fn gen_mmc3_init(mirroring: Mirroring) -> Vec<Instruction> {
|
||||
let mut out = Vec::new();
|
||||
out.push(Instruction::new(NOP, AM::Label("__mmc3_init".into())));
|
||||
|
||||
// Select PRG-bank-0 register (6) with PRG mode bit 6 = 0
|
||||
// (meaning $8000 is switchable, $C000/$E000 are fixed at the
|
||||
// last two banks).
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0x06)));
|
||||
out.push(Instruction::new(STA, AM::Absolute(0x8000)));
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0x00))); // bank 0 at $8000
|
||||
out.push(Instruction::new(STA, AM::Absolute(0x8001)));
|
||||
|
||||
// Select PRG-bank-1 register (7) and load bank 1 at $A000.
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0x07)));
|
||||
out.push(Instruction::new(STA, AM::Absolute(0x8000)));
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0x01)));
|
||||
out.push(Instruction::new(STA, AM::Absolute(0x8001)));
|
||||
|
||||
// Mirroring: $A000, bit 0 — 0 = vertical, 1 = horizontal.
|
||||
let mirror = match mirroring {
|
||||
Mirroring::Horizontal => 0x01,
|
||||
Mirroring::Vertical => 0x00,
|
||||
};
|
||||
out.push(Instruction::new(LDA, AM::Immediate(mirror)));
|
||||
out.push(Instruction::new(STA, AM::Absolute(0xA000)));
|
||||
|
||||
// Leave IRQs disabled until the user code enables them.
|
||||
out.push(Instruction::new(STA, AM::Absolute(0xE000)));
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Generate the `__bank_select` subroutine. Input: A = desired bank
|
||||
/// number (0-based, physical PRG bank index). Output: that bank is
|
||||
/// mapped to $8000-$BFFF. Clobbers A (and the internal shift
|
||||
/// registers where applicable). The routine ends in RTS so callers
|
||||
/// can `JSR __bank_select` anywhere it's callable from.
|
||||
///
|
||||
/// The bank number is stashed in `ZP_BANK_CURRENT` so `__bank_select`
|
||||
/// and its trampolines can restore it after a callee returns.
|
||||
#[must_use]
|
||||
pub fn gen_bank_select(mapper: Mapper) -> Vec<Instruction> {
|
||||
let mut out = Vec::new();
|
||||
out.push(Instruction::new(NOP, AM::Label("__bank_select".into())));
|
||||
out.push(Instruction::new(STA, AM::ZeroPage(ZP_BANK_CURRENT)));
|
||||
match mapper {
|
||||
Mapper::NROM => {
|
||||
// NROM has no switchable banks, so the routine is a
|
||||
// no-op. We still emit it so user code can unconditionally
|
||||
// call `__bank_select` regardless of mapper.
|
||||
out.push(Instruction::implied(RTS));
|
||||
}
|
||||
Mapper::MMC1 => {
|
||||
// Write 5 bits of A (LSB first) into the shift register
|
||||
// at $E000 (PRG-bank select). Between writes we LSR A
|
||||
// to shift the next bit into position 0.
|
||||
for i in 0..5 {
|
||||
if i > 0 {
|
||||
out.push(Instruction::new(LSR, AM::Accumulator));
|
||||
}
|
||||
out.push(Instruction::new(STA, AM::Absolute(0xE000)));
|
||||
}
|
||||
out.push(Instruction::implied(RTS));
|
||||
}
|
||||
Mapper::UxROM => {
|
||||
// UxROM: write bank number to any address in $8000-$FFFF.
|
||||
// We use $FFF0 so the write lands in the fixed bank's
|
||||
// tail area where the linker can back it with a matching
|
||||
// bank-table byte to avoid bus conflicts.
|
||||
out.push(Instruction::new(STA, AM::Absolute(0xFFF0)));
|
||||
out.push(Instruction::implied(RTS));
|
||||
}
|
||||
Mapper::MMC3 => {
|
||||
// MMC3: `$8000 = 6` selects PRG-bank-0 register, then
|
||||
// write bank to `$8001`. We save/restore X because
|
||||
// some callers use X as a loop counter across the
|
||||
// switch.
|
||||
out.push(Instruction::implied(PHA));
|
||||
out.push(Instruction::new(LDA, AM::Immediate(0x06)));
|
||||
out.push(Instruction::new(STA, AM::Absolute(0x8000)));
|
||||
out.push(Instruction::implied(PLA));
|
||||
out.push(Instruction::new(STA, AM::Absolute(0x8001)));
|
||||
out.push(Instruction::implied(RTS));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Generate a cross-bank trampoline stub. Placed in the fixed bank
|
||||
/// and called by user code (also in the fixed bank) via
|
||||
/// `JSR __tramp_<bank_name>`. Behavior:
|
||||
///
|
||||
/// 1. Save the caller's bank number (always the fixed bank's index).
|
||||
/// 2. Load the target bank number into A, JSR `__bank_select`.
|
||||
/// 3. JSR the entry label in the target bank.
|
||||
/// 4. Load the fixed bank number, JSR `__bank_select` to restore.
|
||||
/// 5. RTS.
|
||||
///
|
||||
/// `bank_name` is the user-declared bank name from the `.ne` source.
|
||||
/// `entry_label` is the label inside that bank the trampoline
|
||||
/// should call (conventionally `__bank_<name>_entry`). `bank_index`
|
||||
/// is the physical bank number. `fixed_bank_index` is the physical
|
||||
/// bank number of the fixed bank (always `total_banks - 1`).
|
||||
#[must_use]
|
||||
pub fn gen_bank_trampoline(
|
||||
bank_name: &str,
|
||||
entry_label: &str,
|
||||
bank_index: u8,
|
||||
fixed_bank_index: u8,
|
||||
) -> Vec<Instruction> {
|
||||
let mut out = Vec::new();
|
||||
let tramp_label = format!("__tramp_{bank_name}");
|
||||
out.push(Instruction::new(NOP, AM::Label(tramp_label)));
|
||||
// Switch to target bank.
|
||||
out.push(Instruction::new(LDA, AM::Immediate(bank_index)));
|
||||
out.push(Instruction::new(JSR, AM::Label("__bank_select".into())));
|
||||
// Call the user's entry point in that bank. The label lives in
|
||||
// the switchable bank and is resolved during banked assembly.
|
||||
out.push(Instruction::new(JSR, AM::Label(entry_label.to_string())));
|
||||
// Restore the fixed bank.
|
||||
out.push(Instruction::new(LDA, AM::Immediate(fixed_bank_index)));
|
||||
out.push(Instruction::new(JSR, AM::Label("__bank_select".into())));
|
||||
out.push(Instruction::implied(RTS));
|
||||
out
|
||||
}
|
||||
|
||||
/// Generate the bus-conflict avoidance table used by `UxROM`. The table
|
||||
/// lives at a known offset in the fixed bank and contains 256 bytes
|
||||
/// of increasing values (0, 1, 2, ...). Writing bank `n` to
|
||||
/// `__bank_select_table + n` guarantees the bus value matches the
|
||||
/// ROM byte at that address, avoiding conflict-driven glitches on
|
||||
/// real `UxROM` hardware.
|
||||
#[must_use]
|
||||
pub fn gen_uxrom_bank_table() -> Vec<Instruction> {
|
||||
let bytes: Vec<u8> = (0..=255u16).map(|i| i as u8).collect();
|
||||
vec![
|
||||
Instruction::new(NOP, AM::Label("__bank_select_table".into())),
|
||||
Instruction::new(NOP, AM::Bytes(bytes)),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -385,3 +385,315 @@ fn divide_routine_assembles() {
|
|||
"divide routine should end with RTS"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Bank switching ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn mapper_init_nrom_is_empty() {
|
||||
// NROM has no banks and nothing to configure at reset — the
|
||||
// generator must return an empty Vec so the linker doesn't
|
||||
// pay any ROM cost for unused mapper config.
|
||||
let init = gen_mapper_init(Mapper::NROM, Mirroring::Horizontal, 1);
|
||||
assert!(
|
||||
init.is_empty(),
|
||||
"NROM mapper init should be empty, got {} instructions",
|
||||
init.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapper_init_mmc1_pulses_reset_and_writes_control() {
|
||||
// MMC1 init must: (1) pulse bit 7 of any $8000-range write to
|
||||
// reset the shift register, then (2) serialize a 5-bit control
|
||||
// value into the same $8000 register window. We verify:
|
||||
// * there's at least one STA to $8000 preceded by LDA #$80
|
||||
// * there are exactly 6 writes to $8000 total (1 reset + 5 bits)
|
||||
let init = gen_mapper_init(Mapper::MMC1, Mirroring::Horizontal, 4);
|
||||
let writes_8000: Vec<_> = init
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, i)| i.opcode == STA && i.mode == AM::Absolute(0x8000))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
writes_8000.len(),
|
||||
6,
|
||||
"MMC1 init should write to $8000 six times (1 reset + 5 control bits), got {}",
|
||||
writes_8000.len()
|
||||
);
|
||||
// The reset write comes first and must be preceded by LDA #$80.
|
||||
let first_idx = writes_8000[0].0;
|
||||
assert!(first_idx > 0);
|
||||
assert_eq!(init[first_idx - 1].opcode, LDA);
|
||||
assert_eq!(init[first_idx - 1].mode, AM::Immediate(0x80));
|
||||
}
|
||||
|
||||
/// Find the first LDA immediate operand appearing after the MMC1
|
||||
/// reset-pulse (`LDA #$80`) inside an MMC1 init sequence. Used by
|
||||
/// [`mapper_init_mmc1_horizontal_vs_vertical_control_bits`] to
|
||||
/// inspect the first serialized control-register bit.
|
||||
fn mmc1_first_control_bit(init: &[Instruction]) -> Option<u8> {
|
||||
let mut saw_reset = false;
|
||||
for inst in init {
|
||||
if !saw_reset {
|
||||
if inst.opcode == LDA && inst.mode == AM::Immediate(0x80) {
|
||||
saw_reset = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if inst.opcode == LDA {
|
||||
if let AM::Immediate(v) = inst.mode {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapper_init_mmc1_horizontal_vs_vertical_control_bits() {
|
||||
// The control register's bits 0-1 encode mirroring. Our layout
|
||||
// uses $0F for horizontal (0b01111) and $0E for vertical
|
||||
// (0b01110). The first bit sent (LDA #0 or #1) differs between
|
||||
// the two — horizontal bit 0 = 1, vertical bit 0 = 0.
|
||||
let h = gen_mapper_init(Mapper::MMC1, Mirroring::Horizontal, 2);
|
||||
let v = gen_mapper_init(Mapper::MMC1, Mirroring::Vertical, 2);
|
||||
assert_eq!(
|
||||
mmc1_first_control_bit(&h),
|
||||
Some(1),
|
||||
"horizontal mirror bit 0"
|
||||
);
|
||||
assert_eq!(mmc1_first_control_bit(&v), Some(0), "vertical mirror bit 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapper_init_uxrom_emits_label_and_nothing_else() {
|
||||
// UxROM powers up with bank 0 at $8000 and the last bank fixed
|
||||
// at $C000 — exactly what the NEScript runtime expects. All we
|
||||
// need is a marker label so debuggers can find the (empty)
|
||||
// init span.
|
||||
let init = gen_mapper_init(Mapper::UxROM, Mirroring::Horizontal, 3);
|
||||
assert_eq!(init.len(), 1);
|
||||
assert!(
|
||||
matches!(&init[0].mode, AM::Label(n) if n == "__uxrom_init"),
|
||||
"UxROM init should emit just the marker label",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapper_init_mmc3_configures_prg_and_mirroring() {
|
||||
// MMC3 init writes:
|
||||
// $8000 = 6 (select PRG-0 register)
|
||||
// $8001 = 0 (bank 0 at $8000)
|
||||
// $8000 = 7 (select PRG-1 register)
|
||||
// $8001 = 1 (bank 1 at $A000)
|
||||
// $A000 = mirroring bit
|
||||
// $E000 = 0 (disable IRQ)
|
||||
let init = gen_mapper_init(Mapper::MMC3, Mirroring::Vertical, 4);
|
||||
let count_writes = |addr: u16| -> usize {
|
||||
init.iter()
|
||||
.filter(|i| i.opcode == STA && i.mode == AM::Absolute(addr))
|
||||
.count()
|
||||
};
|
||||
assert_eq!(count_writes(0x8000), 2, "MMC3 should write $8000 twice");
|
||||
assert_eq!(count_writes(0x8001), 2, "MMC3 should write $8001 twice");
|
||||
assert_eq!(
|
||||
count_writes(0xA000),
|
||||
1,
|
||||
"MMC3 should write $A000 for mirroring"
|
||||
);
|
||||
assert_eq!(
|
||||
count_writes(0xE000),
|
||||
1,
|
||||
"MMC3 should clear $E000 to disable IRQ"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapper_init_assembles_for_every_banked_mapper() {
|
||||
// Sanity check: every mapper's init sequence should pass the
|
||||
// assembler without unresolved labels. We splice in a dummy
|
||||
// reset label to prevent branch-range issues.
|
||||
for m in [Mapper::MMC1, Mapper::UxROM, Mapper::MMC3] {
|
||||
let init = gen_mapper_init(m, Mirroring::Horizontal, 2);
|
||||
let result = asm::assemble(&init, 0xC000);
|
||||
// Either empty (UxROM is basically zero-cost) or a short
|
||||
// init stub — all fit comfortably in < 100 bytes.
|
||||
assert!(
|
||||
result.bytes.len() < 100,
|
||||
"mapper {m:?} init is {} bytes, expected < 100",
|
||||
result.bytes.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bank_select_nrom_is_a_plain_rts() {
|
||||
// The NROM bank-select stub exists so user code can
|
||||
// unconditionally call `__bank_select` regardless of mapper.
|
||||
// Its body must RTS immediately — no register writes.
|
||||
let sel = gen_bank_select(Mapper::NROM);
|
||||
assert!(matches!(&sel[0].mode, AM::Label(n) if n == "__bank_select"));
|
||||
assert_eq!(sel.last().unwrap().opcode, RTS);
|
||||
// Must not write to any mapper register.
|
||||
let writes_mapper = sel.iter().any(|i| {
|
||||
i.opcode == STA
|
||||
&& matches!(
|
||||
&i.mode,
|
||||
AM::Absolute(a) if (0x8000..=0xFFFF).contains(a)
|
||||
)
|
||||
});
|
||||
assert!(!writes_mapper, "NROM bank-select must not write to $8000+");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bank_select_mmc1_serializes_five_bits_to_e000() {
|
||||
// MMC1 bank-select serializes the 5 LSBs of A into the $E000
|
||||
// register. We check: exactly 5 STA $E000 instructions, and
|
||||
// they're interleaved with LSR A shifts between them (4 LSRs
|
||||
// total — one between each pair of writes).
|
||||
let sel = gen_bank_select(Mapper::MMC1);
|
||||
let writes_e000 = sel
|
||||
.iter()
|
||||
.filter(|i| i.opcode == STA && i.mode == AM::Absolute(0xE000))
|
||||
.count();
|
||||
assert_eq!(writes_e000, 5, "MMC1 should write $E000 five times");
|
||||
let lsrs = sel
|
||||
.iter()
|
||||
.filter(|i| i.opcode == LSR && i.mode == AM::Accumulator)
|
||||
.count();
|
||||
assert_eq!(lsrs, 4, "MMC1 should shift A four times between bit writes");
|
||||
assert_eq!(sel.last().unwrap().opcode, RTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bank_select_uxrom_writes_fff0() {
|
||||
// UxROM bank-select writes A to $FFF0, which lives in the
|
||||
// fixed bank's bus-conflict-safe table.
|
||||
let sel = gen_bank_select(Mapper::UxROM);
|
||||
let has_write = sel
|
||||
.iter()
|
||||
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0xFFF0));
|
||||
assert!(has_write, "UxROM bank-select must write to $FFF0");
|
||||
assert_eq!(sel.last().unwrap().opcode, RTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bank_select_mmc3_writes_8000_and_8001() {
|
||||
// MMC3 bank-select writes 6 to $8000, then writes A to $8001.
|
||||
// We check both writes happen exactly once.
|
||||
let sel = gen_bank_select(Mapper::MMC3);
|
||||
let writes_8000 = sel
|
||||
.iter()
|
||||
.filter(|i| i.opcode == STA && i.mode == AM::Absolute(0x8000))
|
||||
.count();
|
||||
let writes_8001 = sel
|
||||
.iter()
|
||||
.filter(|i| i.opcode == STA && i.mode == AM::Absolute(0x8001))
|
||||
.count();
|
||||
assert_eq!(writes_8000, 1, "MMC3 should write $8000 once");
|
||||
assert_eq!(writes_8001, 1, "MMC3 should write $8001 once");
|
||||
assert_eq!(sel.last().unwrap().opcode, RTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bank_select_stashes_bank_number_in_zp() {
|
||||
// Every bank-select routine (including NROM) saves A into
|
||||
// ZP_BANK_CURRENT so trampolines can restore the previous
|
||||
// bank later.
|
||||
for m in [Mapper::NROM, Mapper::MMC1, Mapper::UxROM, Mapper::MMC3] {
|
||||
let sel = gen_bank_select(m);
|
||||
let has_stash = sel
|
||||
.iter()
|
||||
.any(|i| i.opcode == STA && i.mode == AM::ZeroPage(ZP_BANK_CURRENT));
|
||||
assert!(
|
||||
has_stash,
|
||||
"mapper {m:?} bank-select must stash A into ZP_BANK_CURRENT"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bank_select_assembles_for_every_mapper() {
|
||||
for m in [Mapper::NROM, Mapper::MMC1, Mapper::UxROM, Mapper::MMC3] {
|
||||
let sel = gen_bank_select(m);
|
||||
let result = asm::assemble(&sel, 0xC000);
|
||||
assert!(
|
||||
!result.bytes.is_empty(),
|
||||
"mapper {m:?} bank-select produced no bytes"
|
||||
);
|
||||
assert!(
|
||||
result.labels.contains_key("__bank_select"),
|
||||
"mapper {m:?} bank-select must export __bank_select"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trampoline_switches_target_then_restores_fixed() {
|
||||
// A trampoline must JSR `__bank_select` twice: once with the
|
||||
// target bank's index, once with the fixed bank's index. The
|
||||
// two LDA immediates in the stub should match those two bank
|
||||
// numbers in order.
|
||||
let t = gen_bank_trampoline("Level1", "__bank_Level1_entry", 0, 3);
|
||||
// First instruction is the trampoline label.
|
||||
assert!(matches!(&t[0].mode, AM::Label(n) if n == "__tramp_Level1"));
|
||||
// Extract the sequence of immediate loads.
|
||||
let imms: Vec<u8> = t
|
||||
.iter()
|
||||
.filter_map(|i| {
|
||||
if i.opcode == LDA {
|
||||
if let AM::Immediate(v) = i.mode {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(imms, vec![0, 3], "trampoline should load target then fixed");
|
||||
// And two JSRs to __bank_select, plus one JSR to the entry.
|
||||
let jsrs: Vec<&str> = t
|
||||
.iter()
|
||||
.filter_map(|i| {
|
||||
if i.opcode == JSR {
|
||||
if let AM::Label(n) = &i.mode {
|
||||
return Some(n.as_str());
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
jsrs,
|
||||
vec!["__bank_select", "__bank_Level1_entry", "__bank_select"],
|
||||
"trampoline JSRs must dispatch in the correct order"
|
||||
);
|
||||
// Final instruction returns to caller.
|
||||
assert_eq!(t.last().unwrap().opcode, RTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trampoline_label_derives_from_bank_name() {
|
||||
// Trampoline labels are consistently named `__tramp_<bank>` so
|
||||
// codegen can reference them without knowing bank indices.
|
||||
let t = gen_bank_trampoline("MusicData", "__music_entry", 1, 3);
|
||||
assert!(matches!(&t[0].mode, AM::Label(n) if n == "__tramp_MusicData"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uxrom_bank_table_is_256_bytes_of_sequential_values() {
|
||||
// The bus-conflict table must contain bytes 0..=255 in order
|
||||
// so that `STA __bank_select_table,X` where X = desired bank
|
||||
// number produces a matching ROM byte.
|
||||
let table = gen_uxrom_bank_table();
|
||||
assert_eq!(table.len(), 2);
|
||||
assert!(matches!(&table[0].mode, AM::Label(n) if n == "__bank_select_table"));
|
||||
match &table[1].mode {
|
||||
AM::Bytes(v) => {
|
||||
assert_eq!(v.len(), 256);
|
||||
for (i, &b) in v.iter().enumerate() {
|
||||
assert_eq!(b as usize, i, "table byte at {i} should equal {i}");
|
||||
}
|
||||
}
|
||||
other => panic!("expected Bytes, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue