mirror of
https://github.com/imjasonh/nescript
synced 2026-07-09 09:18:01 +00:00
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
726 lines
24 KiB
Rust
726 lines
24 KiB
Rust
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:?}"),
|
||
}
|
||
}
|