1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-09 09:18:01 +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:
Claude 2026-04-13 01:50:51 +00:00
parent 08d9798940
commit 3307f75da6
No known key found for this signature in database
8 changed files with 1577 additions and 24 deletions

View file

@ -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)),
]
}

View file

@ -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:?}"),
}
}