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 = 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 = 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 { 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 = 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_` 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:?}"), } }