use alloc::sync::Arc;
use alloc::vec;
use alloc::vec::Vec;
use crate::core::device::{Device, ResetKind};
use crate::core::props::Props;
use crate::core::space::{MemAttrs, MemOps, RegionKind, RegionRef};
use crate::core::state::{MachineShape, Migrations, StateReader, StateWriter};
use super::io::{Button, Nationalisation, SmsIo};
use super::mapper::{BANK_SIZE, FIXED_LEN, SegaMapper};
use super::psg::{SAMPLE_DIVISOR, SmsPsg};
use super::sdsc::SdscConsole;
use super::vdp::{DOTS_PER_LINE, SCREEN_WIDTH, SmsVdp, TvRegion, VdpMode};
fn io(region: &RegionRef) -> &Arc<dyn MemOps> {
match region.kind() {
RegionKind::Io(ops) => ops,
other => panic!("expected an I/O region, found {other:?}"),
}
}
fn read(ops: &Arc<dyn MemOps>, offset: u64, attrs: MemAttrs) -> u8 {
let mut byte = [0u8];
ops.read(offset, &mut byte, attrs).expect("a mapped byte");
byte[0]
}
fn write(ops: &Arc<dyn MemOps>, offset: u64, value: u8) {
ops.write(offset, &[value], MemAttrs::DEFAULT)
.expect("an accepted write");
}
fn round_trip<D: Device>(saved: &D, restored: &D, path: &str) {
let class = saved.class();
let encode = |d: &D| {
let mut writer = StateWriter::new(MachineShape::new());
{
let mut chunk = writer
.chunk(path, class.name, class.version)
.expect("a chunk");
d.save(&mut chunk).expect("saves");
}
writer.to_vec().expect("serialises")
};
let bytes = encode(saved);
let reader = StateReader::new(&bytes).expect("well formed");
let chunk = reader
.load(path, class.name, class.version, &Migrations::new())
.expect("the chunk is there");
restored.load(&mut chunk.reader()).expect("loads");
assert_eq!(
encode(restored),
bytes,
"`{}` does not round-trip",
class.name
);
}
fn command(vdp: &SmsVdp, code: u8, addr: u16) {
vdp.write_port(1, (addr & 0xff) as u8);
vdp.write_port(1, ((addr >> 8) as u8 & 0x3f) | (code << 6));
}
#[test]
fn a_control_write_is_two_bytes_and_the_second_carries_the_code() {
let vdp = SmsVdp::new(TvRegion::Ntsc);
command(&vdp, 1, 0x1234);
vdp.write_port(0, 0xa5);
assert_eq!(vdp.peek_vram(0x1234), 0xa5);
vdp.write_port(0, 0x5a);
assert_eq!(vdp.peek_vram(0x1235), 0x5a);
}
#[test]
fn a_register_write_takes_its_value_from_the_first_byte() {
let vdp = SmsVdp::new(TvRegion::Ntsc);
vdp.write_port(1, 0x60);
vdp.write_port(1, 0x81);
assert_eq!(vdp.register(1), 0x60);
}
#[test]
fn a_read_command_prefetches_so_the_first_read_is_the_addressed_byte() {
let vdp = SmsVdp::new(TvRegion::Ntsc);
vdp.poke_vram(0x0100, 0x11);
vdp.poke_vram(0x0101, 0x22);
command(&vdp, 0, 0x0100);
assert_eq!(vdp.read_port(0), 0x11, "the prefetch, not the byte before");
assert_eq!(vdp.read_port(0), 0x22);
}
#[test]
fn a_data_access_clears_the_half_finished_control_latch() {
let vdp = SmsVdp::new(TvRegion::Ntsc);
vdp.write_port(1, 0x34);
vdp.write_port(0, 0x00);
vdp.write_port(1, 0x7f);
vdp.write_port(1, 0x87);
assert_eq!(vdp.register(7), 0x7f);
}
fn run_to_hblank(vdp: &SmsVdp, line: u64) {
vdp.advance_to(line * DOTS_PER_LINE + 256);
}
#[test]
fn reading_the_status_register_clears_the_frame_flag_and_drops_the_line() {
let vdp = SmsVdp::new(TvRegion::Ntsc);
vdp.write_port(1, 0x60);
vdp.write_port(1, 0x81);
run_to_hblank(&vdp, 192);
assert!(
vdp.irq_line(),
"the frame interrupt is asserted at line 192"
);
assert_eq!(vdp.read_port(1) & 0x80, 0x80, "and the flag is set");
assert!(!vdp.irq_line(), "reading $BF is the acknowledge");
}
#[test]
fn a_debug_read_of_the_status_register_acknowledges_nothing() {
let vdp = SmsVdp::new(TvRegion::Ntsc);
vdp.write_port(1, 0x60);
vdp.write_port(1, 0x81);
run_to_hblank(&vdp, 192);
let ports = vdp.region("ports").expect("the port aperture");
let ops = io(&ports);
let debug = MemAttrs::DEBUG;
assert_eq!(read(ops, 1, debug) & 0x80, 0x80, "a monitor sees the flag");
assert!(
vdp.irq_line(),
"and a monitor looking at it must not acknowledge the interrupt"
);
assert_eq!(read(ops, 1, MemAttrs::DEFAULT) & 0x80, 0x80);
assert!(!vdp.irq_line());
}
#[test]
fn a_debug_read_of_the_data_port_does_not_advance_the_address() {
let vdp = SmsVdp::new(TvRegion::Ntsc);
vdp.poke_vram(0x0200, 0x77);
vdp.poke_vram(0x0201, 0x88);
command(&vdp, 0, 0x0200);
let ports = vdp.region("ports").expect("the port aperture");
let ops = io(&ports);
let debug = MemAttrs::DEBUG;
assert_eq!(read(ops, 0, debug), 0x77);
assert_eq!(read(ops, 0, debug), 0x77, "twice, because nothing moved");
assert_eq!(
read(ops, 0, MemAttrs::DEFAULT),
0x77,
"and the guest still sees the same byte"
);
assert_eq!(read(ops, 0, MemAttrs::DEFAULT), 0x88, "then the next one");
}
#[test]
fn a_debug_read_does_not_reset_the_control_latch() {
let vdp = SmsVdp::new(TvRegion::Ntsc);
let ports = vdp.region("ports").expect("the port aperture");
let ops = io(&ports);
vdp.write_port(1, 0x7f);
let _ = read(ops, 1, MemAttrs::DEBUG);
vdp.write_port(1, 0x87);
assert_eq!(vdp.register(7), 0x7f);
}
#[test]
fn the_line_counter_fires_every_reg10_plus_one_lines() {
let vdp = SmsVdp::new(TvRegion::Ntsc);
vdp.write_port(1, 0x10);
vdp.write_port(1, 0x80);
vdp.write_port(1, 0x40);
vdp.write_port(1, 0x81);
vdp.write_port(1, 0x01);
vdp.write_port(1, 0x8a);
vdp.advance_to(300 * DOTS_PER_LINE);
let _ = vdp.read_port(1);
let mut hits = 0;
for line in 0..64u64 {
run_to_hblank(&vdp, 300 + line);
if vdp.irq_line() {
hits += 1;
let _ = vdp.read_port(1);
}
}
assert!(hits > 0, "a line interrupt must fire at all");
}
#[test]
fn a_line_interrupt_stays_asserted_until_the_status_register_is_read() {
let vdp = SmsVdp::new(TvRegion::Ntsc);
vdp.write_port(1, 0x10);
vdp.write_port(1, 0x80);
vdp.write_port(1, 0x40);
vdp.write_port(1, 0x81);
vdp.write_port(1, 0x00);
vdp.write_port(1, 0x8a);
vdp.advance_to(300 * DOTS_PER_LINE + 256);
assert!(vdp.irq_line());
vdp.advance_by(DOTS_PER_LINE / 2);
assert!(vdp.irq_line());
let _ = vdp.read_port(1);
assert!(!vdp.irq_line());
}
#[test]
fn the_frame_flag_is_raised_at_the_first_blanked_line_of_each_mode() {
for (r0, r1, height) in [
(0x04u8, 0x60u8, 192u16),
(0x04, 0x78, 224),
(0x06, 0x68, 240),
] {
let vdp = SmsVdp::new(TvRegion::Pal);
vdp.write_port(1, r0);
vdp.write_port(1, 0x80);
vdp.write_port(1, r1);
vdp.write_port(1, 0x81);
assert_eq!(vdp.mode(), VdpMode::Mode4 { height });
run_to_hblank(&vdp, u64::from(height) - 1);
assert!(!vdp.irq_line(), "not yet at line {}", height - 1);
run_to_hblank(&vdp, u64::from(height));
assert!(vdp.irq_line(), "at line {height}");
}
}
#[test]
fn the_v_counter_runs_are_the_length_of_the_frame() {
for (region, lines) in [(TvRegion::Ntsc, 262u64), (TvRegion::Pal, 313)] {
let vdp = SmsVdp::new(region);
let mut seen = Vec::new();
for line in 0..lines {
vdp.advance_to(line * DOTS_PER_LINE + 1);
seen.push(vdp.vcounter());
}
assert_eq!(seen.len() as u64, lines);
assert_eq!(seen[0], 0, "the frame starts at zero");
assert!(seen.contains(&0xff), "{region:?}: the counter reaches $FF");
}
}
#[test]
fn the_h_counter_folds_342_pixels_into_171_values() {
let vdp = SmsVdp::new(TvRegion::Ntsc);
assert_eq!(vdp.hcounter(), 0x00);
vdp.advance_by(0x93 * 2);
assert_eq!(vdp.hcounter(), 0x93, "the last value before the jump");
vdp.advance_by(2);
assert_eq!(vdp.hcounter(), 0xe9, "and it jumps rather than wrapping");
}
fn mode4(vdp: &SmsVdp) {
vdp.write_port(1, 0x04); vdp.write_port(1, 0x80);
vdp.write_port(1, 0x40); vdp.write_port(1, 0x81);
vdp.write_port(1, 0xff); vdp.write_port(1, 0x82);
vdp.write_port(1, 0x00); vdp.write_port(1, 0x87);
}
#[test]
fn a_mode_4_tile_reaches_the_framebuffer_through_colour_ram() {
let vdp = SmsVdp::new(TvRegion::Ntsc);
mode4(&vdp);
vdp.poke_vram(32, 0xff);
vdp.poke_vram(0x3800, 0x01);
vdp.poke_vram(0x3801, 0x00);
vdp.poke_cram(1, 0x2a);
vdp.advance_to(DOTS_PER_LINE * 262);
assert_eq!(vdp.pixel(0, 0), Some(0x2a));
assert_eq!(vdp.pixel(7, 0), Some(0x2a));
}
#[test]
fn hiding_the_left_column_paints_it_with_the_backdrop() {
let vdp = SmsVdp::new(TvRegion::Ntsc);
mode4(&vdp);
vdp.poke_vram(32, 0xff);
vdp.poke_vram(0x3800, 0x01);
vdp.poke_vram(0x3802, 0x01);
vdp.poke_cram(1, 0x2a);
vdp.poke_cram(16, 0x15);
vdp.write_port(1, 0x24);
vdp.write_port(1, 0x80);
vdp.advance_to(DOTS_PER_LINE * 262);
assert_eq!(vdp.pixel(0, 0), Some(0x15), "backdrop");
assert_eq!(vdp.pixel(8, 0), Some(0x2a), "and the tile resumes at 8");
}
#[test]
fn a_blanked_display_is_the_backdrop_everywhere() {
let vdp = SmsVdp::new(TvRegion::Ntsc);
vdp.write_port(1, 0x04);
vdp.write_port(1, 0x80);
vdp.write_port(1, 0x00); vdp.write_port(1, 0x81);
vdp.poke_cram(16 + 3, 0x3f);
vdp.write_port(1, 0x03); vdp.write_port(1, 0x87);
vdp.advance_to(DOTS_PER_LINE * 262);
for x in [0usize, 100, SCREEN_WIDTH - 1] {
assert_eq!(vdp.pixel(x, 100), Some(0x3f));
}
}
#[test]
fn the_mode_bits_are_not_in_bit_order() {
let vdp = SmsVdp::new(TvRegion::Ntsc);
assert_eq!(vdp.mode(), VdpMode::GraphicsI);
vdp.write_port(1, 0x10);
vdp.write_port(1, 0x81);
assert_eq!(vdp.mode(), VdpMode::Text);
vdp.write_port(1, 0x00);
vdp.write_port(1, 0x81);
vdp.write_port(1, 0x02);
vdp.write_port(1, 0x80);
assert_eq!(vdp.mode(), VdpMode::GraphicsII);
vdp.write_port(1, 0x00);
vdp.write_port(1, 0x80);
vdp.write_port(1, 0x08);
vdp.write_port(1, 0x81);
assert_eq!(vdp.mode(), VdpMode::Multicolor);
}
#[test]
fn a_vdp_round_trips() {
let vdp = SmsVdp::new(TvRegion::Pal);
mode4(&vdp);
vdp.poke_vram(0x1000, 0x5a);
vdp.poke_cram(7, 0x2a);
vdp.advance_to(DOTS_PER_LINE * 40 + 17);
let restored = SmsVdp::new(TvRegion::Pal);
round_trip(&vdp, &restored, "vdp");
assert_eq!(
restored.dots(),
vdp.dots(),
"the derived tick follows the load"
);
assert_eq!(restored.position(), vdp.position());
}
#[test]
fn a_reset_returns_the_chip_to_its_power_on_registers() {
let vdp = SmsVdp::new(TvRegion::Ntsc);
mode4(&vdp);
vdp.advance_by(1000);
vdp.reset(ResetKind::Cold);
assert_eq!(vdp.register(1), 0x00);
assert_eq!(vdp.register(10), 0xff);
assert_eq!(vdp.mode(), VdpMode::GraphicsI);
assert_eq!(
vdp.dots(),
1000,
"a reset does not rewind the device's tick"
);
}
#[test]
fn a_tone_register_takes_two_writes_and_a_volume_takes_one() {
let psg = SmsPsg::new();
psg.write(0x80 | 0x0e); psg.write(0x3f); assert_eq!(psg.tone(0), 0x3fe);
psg.write(0x90 | 0x03); assert_eq!(psg.volume(0), 3);
psg.write(0x0a);
assert_eq!(psg.volume(0), 0x0a);
}
#[test]
fn writing_the_noise_control_resets_the_shift_register() {
let psg = SmsPsg::new();
psg.write(0xe7); psg.write(0xf0); psg.advance_by(SAMPLE_DIVISOR * 64);
let shifted = psg.lfsr();
assert_ne!(shifted, 0x8000, "the register has moved");
psg.write(0xe4);
assert_eq!(psg.lfsr(), 0x8000, "and a control write seeds it again");
}
#[test]
fn a_silent_chip_produces_a_flat_line_and_a_loud_one_does_not() {
let psg = SmsPsg::new();
psg.set_recording(true);
psg.advance_by(SAMPLE_DIVISOR * 32);
let quiet = psg.take_samples();
assert_eq!(quiet.len(), 32);
assert!(quiet.iter().all(|&(l, r)| l == 0 && r == 0));
psg.write(0x80 | 0x02);
psg.write(0x00);
psg.write(0x90);
psg.advance_by(SAMPLE_DIVISOR * 256);
let loud = psg.take_samples();
assert!(
loud.iter().any(|&(l, _)| l > 0) && loud.iter().any(|&(l, _)| l < 0),
"a square wave crosses zero in both directions"
);
}
#[test]
fn nothing_is_recorded_unless_a_sink_asked_for_it() {
let psg = SmsPsg::new();
psg.write(0x90);
psg.advance_by(SAMPLE_DIVISOR * 100);
assert_eq!(psg.queued_samples(), 0);
}
#[test]
fn a_psg_round_trips() {
let psg = SmsPsg::new();
psg.write(0x80 | 0x0e);
psg.write(0x3f);
psg.write(0x90 | 0x04);
psg.write(0xe5);
psg.advance_by(1234);
let restored = SmsPsg::new();
round_trip(&psg, &restored, "psg");
assert_eq!(restored.ticks(), psg.ticks());
}
fn image(banks: u8) -> Vec<u8> {
let mut out = vec![0u8; BANK_SIZE as usize * banks as usize];
for (i, byte) in out.iter_mut().enumerate() {
*byte = (i as u64 / BANK_SIZE) as u8;
}
out
}
fn cart_read(mapper: &SegaMapper, addr: u64) -> u8 {
let rom = mapper.region("rom").expect("the cartridge aperture");
let mut byte = [0u8];
let space = crate::core::space::AddressSpace::new("test", 16);
space.topology().map(rom, 0).expect("mapped");
space
.read_bytes(addr, &mut byte, MemAttrs::DEFAULT)
.expect("a mapped byte");
byte[0]
}
#[test]
fn the_first_kilobyte_never_moves() {
let mapper = SegaMapper::new(&image(4), 0x8000).expect("a board");
assert_eq!(mapper.banks(), 4);
assert_eq!(cart_read(&mapper, 0x0000), 0);
assert_eq!(cart_read(&mapper, FIXED_LEN - 1), 0);
}
#[test]
fn the_three_slots_power_on_showing_the_first_three_banks() {
let mapper = SegaMapper::new(&image(4), 0x8000).expect("a board");
assert_eq!(mapper.bank(0), 0);
assert_eq!(mapper.bank(1), 1);
assert_eq!(mapper.bank(2), 2);
assert_eq!(cart_read(&mapper, 0x0400), 0);
assert_eq!(cart_read(&mapper, 0x4000), 1);
assert_eq!(cart_read(&mapper, 0x8000), 2);
}
#[test]
fn slot_two_becomes_writable_when_the_control_register_says_so() {
let mapper = SegaMapper::new(&image(4), 0x8000).expect("a board");
let regs = mapper.region("regs").expect("the register aperture");
let ops = io(®s);
let rom = mapper.region("rom").expect("the cartridge aperture");
let space = crate::core::space::AddressSpace::new("test", 16);
space.topology().map(rom, 0).expect("mapped");
space
.write_bytes(0x8000, &[0x5a], MemAttrs::DEFAULT)
.expect("swallowed");
let mut byte = [0u8];
space
.read_bytes(0x8000, &mut byte, MemAttrs::DEFAULT)
.expect("read");
assert_eq!(byte[0], 2, "still bank 2");
write(ops, 0, 0x08);
assert!(mapper.ram_enabled());
space
.write_bytes(0x8000, &[0x5a], MemAttrs::DEFAULT)
.expect("stored");
space
.read_bytes(0x8000, &mut byte, MemAttrs::DEFAULT)
.expect("read");
assert_eq!(byte[0], 0x5a);
}
#[test]
fn a_bank_register_wraps_around_the_image() {
let mapper = SegaMapper::new(&image(2), 0x8000).expect("a board");
let regs = mapper.region("regs").expect("the register aperture");
let ops = io(®s);
write(ops, 3, 5);
assert_eq!(cart_read(&mapper, 0x8000), 1);
}
#[test]
fn a_debug_write_does_not_rebank_the_guest() {
let mapper = SegaMapper::new(&image(4), 0x8000).expect("a board");
let regs = mapper.region("regs").expect("the register aperture");
let ops = io(®s);
ops.write(3, &[3], MemAttrs::DEBUG).expect("accepted");
assert_eq!(
mapper.bank(2),
2,
"a monitor must not move the guest's code"
);
}
#[test]
fn a_mapper_round_trips_its_registers_and_its_cartridge_ram() {
let mapper = SegaMapper::new(&image(4), 0x8000).expect("a board");
let regs = mapper.region("regs").expect("the register aperture");
let ops = io(®s);
write(ops, 0, 0x08);
write(ops, 1, 3);
write(ops, 2, 2);
write(ops, 3, 1);
mapper
.ram()
.write_u8(0x10, 0xc3)
.expect("a byte of save RAM");
let restored = SegaMapper::new(&image(4), 0x8000).expect("a board");
round_trip(&mapper, &restored, "cart");
assert_eq!(restored.bank(0), 3);
assert_eq!(restored.ram().read_u8(0x10), Ok(0xc3));
}
#[test]
fn an_unpressed_pad_reads_as_all_ones() {
let chip = SmsIo::new(Nationalisation::Export);
assert_eq!(chip.read_dc(), 0xff);
assert_eq!(chip.read_dd(), 0xff);
}
#[test]
fn a_pressed_button_pulls_its_line_low() {
let chip = SmsIo::new(Nationalisation::Export);
chip.pads().set_pressed(0, Button::Up, true);
assert_eq!(chip.read_dc(), 0xfe);
chip.pads().set_pressed(0, Button::Two, true);
assert_eq!(chip.read_dc(), 0xde);
chip.pads().set_pressed(1, Button::Down, true);
assert_eq!(chip.read_dc(), 0x5e);
chip.pads().set_pressed(1, Button::One, true);
assert_eq!(chip.read_dd(), 0xfb);
}
#[test]
fn switching_the_io_chip_out_makes_both_ports_read_as_ones() {
let chip = SmsIo::new(Nationalisation::Export);
chip.pads().set_pressed(0, Button::Up, true);
assert_eq!(chip.read_dc(), 0xfe);
chip.write_control(0, 0x04);
assert_eq!(chip.read_dc(), 0xff);
assert_eq!(chip.read_dd(), 0xff);
}
#[test]
fn the_th_pins_read_back_their_output_level_on_an_export_console() {
let export = SmsIo::new(Nationalisation::Export);
export.write_control(1, 0xf5);
assert_eq!(export.read_dd() & 0xc0, 0xc0);
export.write_control(1, 0x05);
assert_eq!(export.read_dd() & 0xc0, 0x00);
let japan = SmsIo::new(Nationalisation::Japan);
japan.write_control(1, 0xf5);
assert_eq!(japan.read_dd() & 0xc0, 0x00, "and never on a Japanese one");
}
#[test]
fn the_reset_button_is_both_a_bit_and_a_pin() {
let chip = SmsIo::new(Nationalisation::Export);
assert_eq!(chip.read_dd() & 0x10, 0x10);
chip.pads().set_reset(true);
assert_eq!(chip.read_dd() & 0x10, 0x00, "active low, like everything");
}
#[test]
fn an_io_chip_round_trips() {
let chip = SmsIo::new(Nationalisation::Export);
chip.pads().set_buttons(0, 0x15);
chip.pads().set_pause(true);
chip.write_control(0, 0x04);
chip.write_control(1, 0xf5);
let restored = SmsIo::new(Nationalisation::Export);
round_trip(&chip, &restored, "io");
assert_eq!(restored.buttons(0), 0x15);
assert_eq!(restored.io_control(), 0xf5);
}
#[test]
fn the_debug_console_keeps_what_a_test_rom_prints() {
let console = SdscConsole::new(1024);
for byte in b"OK\n" {
console.write_port(1, *byte);
}
assert_eq!(console.text(), "OK\n");
console.write_port(0, 2);
assert!(console.text().is_empty());
}
#[test]
fn a_cursor_command_consumes_its_parameters_rather_than_printing_them() {
let console = SdscConsole::new(1024);
console.write_port(0, 4); console.write_port(1, 5); console.write_port(1, 10); console.write_port(1, b'A');
assert_eq!(
console.text(),
"A",
"the row and column are parameters, not text"
);
}
#[test]
fn a_suspend_request_is_recorded_and_not_obeyed() {
let console = SdscConsole::new(1024);
assert!(!console.suspend_requested());
console.write_port(0, 1);
assert!(console.suspend_requested());
}
#[test]
fn a_debug_console_round_trips() {
let console = SdscConsole::new(1024);
for byte in b"hello\n" {
console.write_port(1, *byte);
}
console.write_port(0, 3);
let restored = SdscConsole::new(1024);
round_trip(&console, &restored, "sdsc");
assert_eq!(restored.text(), "hello\n");
}
#[test]
fn a_bad_region_name_is_a_configuration_error_rather_than_a_default() {
let mut props = Props::new();
props.insert("region", "secam");
assert!(SmsVdp::from_props(&props).is_err());
}
#[test]
fn every_class_publishes_the_regions_its_schema_promises() {
let vdp = SmsVdp::new(TvRegion::Ntsc);
for name in ["ports", "counters"] {
assert!(vdp.region(name).is_some(), "sms.vdp has no `{name}`");
}
assert!(vdp.region("nonsense").is_none());
let chip = SmsIo::new(Nationalisation::Export);
for name in ["ctrl", "pads"] {
assert!(chip.region(name).is_some(), "sms.io has no `{name}`");
}
let mapper = SegaMapper::new(&image(2), 0x8000).expect("a board");
for name in ["rom", "regs"] {
assert!(mapper.region(name).is_some(), "sms.mapper has no `{name}`");
}
}