1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-09 09:18:01 +00:00
nescript/src/runtime/tests.rs
Claude b575921c8e
debug: add symbol export, source maps, bounds checks, overrun counter
Implements four items from docs/future-work.md's "Debug instrumentation"
section so debugging on real ROMs is no longer a guessing game:

1. Mesen `.mlb` symbol export via `--symbols <path>`. The linker now
   returns a `LinkedRom { rom, labels, fixed_bank_file_offset }` struct
   from `link_banked_with_ppu_detailed`; `src/linker/debug_symbols.rs`
   renders that plus the analyzer's var allocations into a Mesen-
   compatible label listing (function entry points get `P:` entries
   at PRG-relative offsets; user vars get `R:` entries).

2. Source maps via `--source-map <path>`. IR lowering now emits a
   `SourceLoc(span)` op before every statement; the codegen turns each
   one into a `__src_<N>` label-definition pseudo-op and records the
   span in a side table. Source-marker emission is opt-in
   (`with_source_map(true)`) because labels become peephole block
   boundaries — leaving the markers off preserves byte-identical
   release ROMs.

3. Array bounds checking under `--debug`. Every `ArrayLoad` /
   `ArrayStore` now emits a `CMP #size; BCC ok; JMP __debug_halt; ok:`
   guard, and the codegen emits one shared `__debug_halt` trap at the
   end of the fixed bank (writes $BC to the debug port then wedges in
   a tight `JMP $`). Release builds skip the whole thing.

4. Frame-overrun detection under `--debug`. `gen_nmi` now takes a
   `debug_mode` flag; when on, it checks `ZP_FRAME_FLAG` at the top of
   the handler and increments a counter at `$07FF`
   (`DEBUG_FRAME_OVERRUN_ADDR`) if the flag was still set — meaning
   the main loop didn't reach `wait_frame` before the next vblank.
   User code can read the counter via `peek(0x07FF)`. This is the
   abbreviated form the future-work doc suggested: a bump-a-counter
   hook rather than a full cycle-budget tracker, which would need a
   new builtin. The codegen emits a `__debug_mode` marker label in
   debug mode so the linker can select the overrun-aware NMI variant.

Release ROMs for every committed example are byte-identical before
and after this change (verified with `git diff examples/` after a
full rebuild). All 512 lib tests and 71 integration tests pass;
`cargo fmt` clean; `cargo clippy --all-targets -- -D warnings` clean.

https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 02:39:36 +00:00

726 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use super::*;
use crate::asm;
use crate::asm::{AddressingMode as AM, Opcode::*};
#[test]
fn init_disables_irq() {
let init = gen_init();
assert_eq!(init[0].opcode, SEI);
}
#[test]
fn init_sets_stack_pointer() {
let init = gen_init();
// LDX #$FF, TXS
let has_ldx = init
.iter()
.any(|i| i.opcode == LDX && i.mode == AM::Immediate(0xFF));
let has_txs = init.iter().any(|i| i.opcode == TXS);
assert!(has_ldx, "should load $FF into X");
assert!(has_txs, "should transfer X to stack pointer");
}
#[test]
fn init_disables_ppu() {
let init = gen_init();
// Should write 0 to $2000 and $2001
let writes_ppu_ctrl = init
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x2000));
let writes_ppu_mask = init
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x2001));
assert!(writes_ppu_ctrl, "should disable PPU control");
assert!(writes_ppu_mask, "should disable PPU mask");
}
#[test]
fn init_enables_nmi_at_end() {
let init = gen_init();
// Last STA $2000 should enable NMI (bit 7 set = 0x80)
let nmi_writes: Vec<_> = init
.iter()
.enumerate()
.filter(|(_, i)| i.opcode == STA && i.mode == AM::Absolute(0x2000))
.collect();
assert!(
nmi_writes.len() >= 2,
"should write to PPU_CTRL at least twice"
);
// The last write should be preceded by LDA #$80
let last_write_idx = nmi_writes.last().unwrap().0;
assert!(last_write_idx > 0);
assert_eq!(init[last_write_idx - 1].opcode, LDA);
assert_eq!(init[last_write_idx - 1].mode, AM::Immediate(0x80));
}
#[test]
fn init_assembles_without_error() {
let init = gen_init();
let result = asm::assemble(&init, 0x8000);
// Should produce non-empty output
assert!(!result.bytes.is_empty(), "init should produce bytes");
// Should be under 200 bytes (the plan estimates ~80)
assert!(
result.bytes.len() < 200,
"init is {} bytes, expected < 200",
result.bytes.len()
);
}
#[test]
fn nmi_saves_and_restores_registers() {
let nmi = gen_nmi(false, false, false);
// First three instructions should push A, X, Y
assert_eq!(nmi[0].opcode, PHA);
assert_eq!(nmi[1].opcode, TXA);
assert_eq!(nmi[2].opcode, PHA);
assert_eq!(nmi[3].opcode, TYA);
assert_eq!(nmi[4].opcode, PHA);
// Last instructions should restore and RTI
let len = nmi.len();
assert_eq!(nmi[len - 1].opcode, RTI);
assert_eq!(nmi[len - 2].opcode, PLA);
}
#[test]
fn nmi_triggers_oam_dma() {
let nmi = gen_nmi(false, false, false);
let has_dma = nmi
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4014));
assert!(has_dma, "NMI should trigger OAM DMA");
}
#[test]
fn nmi_reads_controller() {
let nmi = gen_nmi(false, false, false);
// Should write strobe to $4016
let has_strobe = nmi
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4016));
assert!(has_strobe, "NMI should strobe controller");
}
#[test]
fn nmi_sets_frame_flag() {
let nmi = gen_nmi(false, false, false);
let has_flag = nmi
.iter()
.any(|i| i.opcode == STA && i.mode == AM::ZeroPage(ZP_FRAME_FLAG));
assert!(has_flag, "NMI should set frame-ready flag");
}
#[test]
fn nmi_assembles_without_error() {
let nmi = gen_nmi(false, false, false);
let result = asm::assemble(&nmi, 0xF000);
assert!(!result.bytes.is_empty());
assert!(
result.bytes.len() < 150,
"NMI handler is {} bytes, expected < 150",
result.bytes.len()
);
}
#[test]
fn nmi_debug_mode_bumps_overrun_counter() {
// With `debug_mode = true`, the NMI handler must include an
// `INC $07FF` (the frame-overrun counter at
// `DEBUG_FRAME_OVERRUN_ADDR`) guarded by a BEQ that skips the
// bump when the frame flag was clear. Without `debug_mode`,
// neither the `INC` nor the guard label appear so release
// builds keep the top byte of RAM free for user allocation.
let nmi = gen_nmi(false, false, true);
let has_inc = nmi.iter().any(|i| {
i.opcode == INC && matches!(i.mode, AM::Absolute(a) if a == DEBUG_FRAME_OVERRUN_ADDR)
});
assert!(
has_inc,
"debug-mode NMI should INC the overrun counter at $07FF"
);
let release_nmi = gen_nmi(false, false, false);
let has_inc_release = release_nmi.iter().any(|i| {
i.opcode == INC && matches!(i.mode, AM::Absolute(a) if a == DEBUG_FRAME_OVERRUN_ADDR)
});
assert!(
!has_inc_release,
"release NMI must not touch the debug overrun slot"
);
}
#[test]
fn irq_handler_is_just_rti() {
let irq = gen_irq();
assert_eq!(irq.len(), 1);
assert_eq!(irq[0].opcode, RTI);
}
#[test]
fn multiply_routine_assembles() {
let mul = gen_multiply();
// Should have a reasonable number of instructions
assert!(
mul.len() > 5,
"multiply routine too short: {} instructions",
mul.len()
);
let result = asm::assemble(&mul, 0x8000);
assert!(
!result.bytes.is_empty(),
"multiply routine should produce bytes"
);
// Should be under 100 bytes (compact 6502 routine)
assert!(
result.bytes.len() < 100,
"multiply routine is {} bytes, expected < 100",
result.bytes.len()
);
// Should contain the __multiply label
assert!(
result.labels.contains_key("__multiply"),
"should define __multiply label"
);
// Should end with RTS
assert_eq!(
mul.last().unwrap().opcode,
RTS,
"multiply routine should end with RTS"
);
}
// ── Audio driver tests ──
#[test]
fn audio_tick_defines_required_labels() {
let tick = gen_audio_tick();
// The IR codegen JSRs into `__audio_tick`; that's the entry.
let has_entry = tick
.iter()
.any(|i| matches!(&i.mode, AM::Label(n) if n == "__audio_tick"));
assert!(has_entry, "audio tick must define __audio_tick entry label");
// The tick references `__period_table` via SymbolLo/SymbolHi —
// the period table itself is linked in separately.
let refs_period = tick.iter().any(|i| {
matches!(&i.mode, AM::SymbolLo(n) if n == "__period_table")
|| matches!(&i.mode, AM::SymbolHi(n) if n == "__period_table")
});
assert!(
refs_period,
"audio tick must reference the __period_table label"
);
}
#[test]
fn audio_tick_ends_with_rts() {
let tick = gen_audio_tick();
assert_eq!(
tick.last().unwrap().opcode,
RTS,
"audio tick must return to caller"
);
}
#[test]
fn audio_tick_reads_sfx_envelope_via_indirect_y() {
// The sfx branch walks the envelope via (ZP_SFX_PTR_LO),Y with
// Y=0 — each NMI reads one byte through the pointer and writes
// it to $4000. Verify the indirect-indexed load is present.
let tick = gen_audio_tick();
let has_load = tick
.iter()
.any(|i| i.opcode == LDA && i.mode == AM::IndirectY(ZP_SFX_PTR_LO));
assert!(
has_load,
"audio tick must read envelope via (ZP_SFX_PTR_LO),Y"
);
}
#[test]
fn audio_tick_writes_pulse1_envelope_register() {
// After reading the envelope byte the tick writes it to $4000.
let tick = gen_audio_tick();
let has_store = tick
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4000));
assert!(has_store, "audio tick must write pulse-1 envelope to $4000");
}
#[test]
fn audio_tick_mutes_pulse2_on_non_looping_end_of_track() {
// When a non-looping track hits the (0xFF, 0xFF) sentinel, the
// tick writes $30 to $4004 and clears ZP_MUSIC_STATE. We verify
// the mute path exists by checking both writes exist somewhere
// in the tick body.
let tick = gen_audio_tick();
let has_mute = tick
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4004));
assert!(has_mute, "audio tick must mute pulse-2 on end-of-track");
let has_state_clear = tick
.iter()
.any(|i| i.opcode == STA && i.mode == AM::ZeroPage(ZP_MUSIC_STATE));
assert!(
has_state_clear,
"audio tick must clear ZP_MUSIC_STATE on stop"
);
}
#[test]
fn audio_tick_assembles_without_error() {
// Splice the period table into the same assembly pass so the
// tick's SymbolLo/SymbolHi references resolve. The tick also
// uses label-relative branches internally which need to fit
// within ±127 bytes — if the body grows past that the branches
// will panic at assemble time.
let mut combined = gen_audio_tick();
combined.extend(gen_period_table());
let result = asm::assemble(&combined, 0xC000);
assert!(
!result.bytes.is_empty(),
"audio tick + period table should assemble"
);
assert!(
result.labels.contains_key("__audio_tick"),
"audio tick entry label should be exported"
);
assert!(
result.labels.contains_key("__period_table"),
"period table label should be exported"
);
}
#[test]
fn period_table_has_60_entries_of_2_bytes() {
// The table covers C1..B5 inclusive = 60 semitones, 2 bytes
// each for period_lo and period_hi. Total = 120 data bytes
// plus the leading label pseudo-instruction.
let table = gen_period_table();
// Count the raw bytes in the single `Bytes` block.
let total: usize = table
.iter()
.filter_map(|i| match &i.mode {
AM::Bytes(v) => Some(v.len()),
_ => None,
})
.sum();
assert_eq!(total, 120, "period table should be 60 entries × 2 bytes");
}
#[test]
fn period_table_high_bytes_include_length_counter_bit() {
// Every period_hi byte must have bit 3 set ($08) so the length
// counter holds the note indefinitely. Without that bit, pulse
// 2 would silence after a few frames.
let table = gen_period_table();
let bytes: Vec<u8> = table
.iter()
.filter_map(|i| match &i.mode {
AM::Bytes(v) => Some(v.clone()),
_ => None,
})
.flatten()
.collect();
for (i, chunk) in bytes.chunks(2).enumerate() {
let hi = chunk[1];
assert!(
hi & 0x08 != 0,
"period table entry {i} high byte ${hi:02X} missing length-counter bit"
);
}
}
#[test]
fn period_table_a4_matches_440hz() {
// Entry for A4 should produce ~253 period. Sanity check the
// rounding: CPU/(16*440)-1 ≈ 253.12.
let table = gen_period_table();
let bytes: Vec<u8> = table
.iter()
.filter_map(|i| match &i.mode {
AM::Bytes(v) => Some(v.clone()),
_ => None,
})
.flatten()
.collect();
// A4 is semitone 69 in MIDI. C1 is MIDI 24 (entry 0 in the
// table). A4 = entry 69 - 24 = 45. Each entry is 2 bytes.
let lo = bytes[45 * 2];
let hi = bytes[45 * 2 + 1] & 0x07; // strip length-counter bit
let period = u16::from_le_bytes([lo, hi]);
// Expect period ≈ 253 (±1 for rounding).
assert!(
(252..=254).contains(&period),
"A4 period {period} should be ~253"
);
}
#[test]
fn gen_data_block_emits_label_and_bytes() {
let block = gen_data_block("__sfx_test", vec![0xDE, 0xAD, 0xBE, 0xEF]);
assert_eq!(block.len(), 2);
assert!(matches!(&block[0].mode, AM::Label(n) if n == "__sfx_test"));
match &block[1].mode {
AM::Bytes(v) => assert_eq!(v, &[0xDE, 0xAD, 0xBE, 0xEF]),
other => panic!("expected Bytes, got {other:?}"),
}
}
#[test]
fn data_block_assembles_verbatim() {
// A labelled data block must emit exactly the payload bytes
// (no opcode prefix) and register the label at the payload's
// address. Verifies the `NOP+Bytes` pseudo doesn't accidentally
// get wrapped with an instruction byte.
let block = gen_data_block("__test", vec![0x11, 0x22, 0x33]);
let result = asm::assemble(&block, 0x8000);
assert_eq!(result.bytes, vec![0x11, 0x22, 0x33]);
assert_eq!(result.labels.get("__test").copied(), Some(0x8000));
}
#[test]
fn divide_routine_assembles() {
let div = gen_divide();
// Should have a reasonable number of instructions
assert!(
div.len() > 5,
"divide routine too short: {} instructions",
div.len()
);
let result = asm::assemble(&div, 0x8000);
assert!(
!result.bytes.is_empty(),
"divide routine should produce bytes"
);
// Should be under 100 bytes (compact 6502 routine)
assert!(
result.bytes.len() < 100,
"divide routine is {} bytes, expected < 100",
result.bytes.len()
);
// Should contain the __divide label
assert!(
result.labels.contains_key("__divide"),
"should define __divide label"
);
// Should end with RTS
assert_eq!(
div.last().unwrap().opcode,
RTS,
"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:?}"),
}
}