#[cfg(test)]
pub(crate) mod tests {
use crate::cartridge::Cartridge;
use crate::console::{Config, HardwareModel, Nes, RamInitMode};
use crate::debugging::{Tracing, init_tracing};
use crate::input::Button;
use std::fs;
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum RomTestResult {
Pass,
Fail(u8),
Timeout,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum RomTestVerification {
StatusByte,
Console { pass_string: String },
ConsoleCrc(&'static [u32]),
}
fn test_default_config() -> Config {
Config {
ram_init_mode: RamInitMode::Zero,
..Default::default()
}
}
pub(crate) struct RomTestRunner {
rom_path: String,
max_frames: u32,
wait_reset: u32,
verification: RomTestVerification,
tv_system_override: Option<crate::console::TimingMode>,
ram_init_mode_override: Option<crate::console::RamInitMode>,
}
impl RomTestRunner {
fn read_console_text(nes: &mut Nes) -> String {
let base_addr = nes.base_nametable_addr();
let text = nes.read_nametable_text(base_addr, 32 * 32);
text.as_bytes()
.chunks(32)
.map(|chunk| String::from_utf8_lossy(chunk).trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
pub fn new(rom_path: &str, max_frames: u32, verification: RomTestVerification) -> Self {
Self {
rom_path: rom_path.to_string(),
max_frames,
wait_reset: 1,
verification,
tv_system_override: None,
ram_init_mode_override: None,
}
}
pub fn new_with_tv_system(
rom_path: &str,
max_frames: u32,
verification: RomTestVerification,
tv_system: crate::console::TimingMode,
) -> Self {
Self {
rom_path: rom_path.to_string(),
max_frames,
wait_reset: 1,
verification,
tv_system_override: Some(tv_system),
ram_init_mode_override: None,
}
}
pub fn new_with_ram_init_mode(
rom_path: &str,
max_frames: u32,
verification: RomTestVerification,
ram_init_mode: crate::console::RamInitMode,
) -> Self {
Self {
rom_path: rom_path.to_string(),
max_frames,
wait_reset: 1,
verification,
tv_system_override: None,
ram_init_mode_override: Some(ram_init_mode),
}
}
pub fn run_test(&mut self) -> RomTestResult {
init_tracing_from_env();
let rom_data = match fs::read(&self.rom_path) {
Ok(data) => data,
Err(e) => {
eprintln!("Failed to load ROM {}: {}", self.rom_path, e);
return RomTestResult::Fail(0x80_u8);
}
};
let cartridge = match Cartridge::load_from_file(
&rom_data,
&self.rom_path,
crate::app_context::AppContext::new(),
) {
Ok(cart) => cart,
Err(e) => {
eprintln!("Failed to parse ROM {}: {}", self.rom_path, e);
return RomTestResult::Fail(0x81_u8);
}
};
let mut config = test_default_config();
if let Some(timing_mode_override) = self.tv_system_override {
config.hardware_model = HardwareModel::from_timing_mode(timing_mode_override);
} else {
config.hardware_model =
HardwareModel::from_timing_mode(cartridge.rom_timing_mode());
}
if let Some(ram_init_mode) = self.ram_init_mode_override {
config.ram_init_mode = ram_init_mode;
}
let mut nes = Nes::new(crate::app_context::AppContext::new_with_config(config));
nes.insert_cartridge(cartridge);
nes.reset(false);
let cpu_cycles_per_frame = match nes.app_context().borrow().config().hardware_model {
HardwareModel::NesNtsc => 29_780u32,
HardwareModel::NesPal => 33_247u32,
};
let mut running = false;
let mut first_nonzero_status = None;
let mut last_prompt: Option<String> = None;
let mut pressed_for_prompt = false;
let mut pending_release: Option<Button> = None;
let mut release_after_frames: u8 = 0;
for frame in 1..=self.max_frames {
let mut current_status = nes.bus().borrow_mut().read_for_testing(0x6000);
if current_status == 0x80 {
running = true;
}
if current_status != 0 && first_nonzero_status.is_none() {
first_nonzero_status = Some((frame, current_status));
}
const STATUS_POLL_INTERVAL: u32 = 256;
for cpu_cycle in 0..cpu_cycles_per_frame {
nes.run_cpu_tick();
if cpu_cycle != 0 && cpu_cycle % STATUS_POLL_INTERVAL == 0 {
current_status = nes.bus().borrow_mut().read_for_testing(0x6000);
if current_status == 0x80 {
running = true;
}
}
}
let status = nes.bus().borrow_mut().read_for_testing(0x6000);
if status == 0x80 {
running = true;
}
if nes.is_ready_to_render() {
nes.clear_ready_to_render();
}
while nes.sample_ready() {
nes.get_sample();
}
if self.verification == RomTestVerification::StatusByte && !running {
continue;
}
if self.verification == RomTestVerification::StatusByte {
if status == 0x00 {
return RomTestResult::Pass;
} else if status > 0x00 && status < 0x80 {
let text = Self::read_console_text(&mut nes);
let uppercase_text = text.to_uppercase();
if uppercase_text.ends_with("PASSED") {
return RomTestResult::Pass;
}
if uppercase_text.contains("FAILED")
|| uppercase_text.contains("ERROR")
|| (text.starts_with("0x") && text.chars().nth(2) != Some('0'))
{
println!("Test failed with status code: 0x{:02X}", status);
println!("Console output:\n{}", text);
return RomTestResult::Fail(status);
}
continue;
} else if status == 0x81 {
if self.wait_reset > 0 {
self.wait_reset -= 1;
} else {
nes.reset(true);
nes.bus().borrow_mut().write_for_testing(0x6000, 0x80);
self.wait_reset = 1;
}
} else if status == 0x80 {
continue;
}
} else if let RomTestVerification::Console { pass_string } = &self.verification {
let text = Self::read_console_text(&mut nes);
if text.to_uppercase().ends_with(pass_string) {
return RomTestResult::Pass;
} else if text.to_uppercase().contains("FAILED")
|| text.to_uppercase().contains("ERROR")
|| (text.starts_with("0x") && text.chars().nth(2) != Some('0'))
{
println!("Test failed!");
println!("Console output:\n{}", text);
return RomTestResult::Fail(1);
} else if let Some(last_line) = text.lines().last().map(|line| line.trim()) {
let prompt_button = match last_line {
"A" => Some(Button::A),
"B" => Some(Button::B),
"Select" => Some(Button::Select),
"Start" => Some(Button::Start),
"Up" => Some(Button::Up),
"Down" => Some(Button::Down),
"Left" => Some(Button::Left),
"Right" => Some(Button::Right),
_ => None,
};
let prompt_changed = last_prompt.as_deref() != Some(last_line);
if prompt_changed {
last_prompt = Some(last_line.to_string());
pressed_for_prompt = false;
}
if let Some(button) = prompt_button
&& !pressed_for_prompt
&& pending_release.is_none()
{
nes.set_button(1, button, true);
pending_release = Some(button);
release_after_frames = 2;
pressed_for_prompt = true;
}
}
} else if let RomTestVerification::ConsoleCrc(expected_crcs) = self.verification {
let text = Self::read_console_text(&mut nes);
if let Some(crc) = parse_crc32_from_console_text(&text) {
if expected_crcs.contains(&crc) {
return RomTestResult::Pass;
}
println!("Test failed! Unexpected CRC 0x{:08X}", crc);
println!("Console output:\n{}", text);
return RomTestResult::Fail(1);
}
}
if let Some(button) = pending_release
&& release_after_frames > 0
{
release_after_frames -= 1;
if release_after_frames == 0 {
nes.set_button(1, button, false);
pending_release = None;
}
}
}
let text = Self::read_console_text(&mut nes);
println!("Test Timed out with output:\n{}", text);
RomTestResult::Timeout
}
}
pub(crate) fn run_address_test(
rom_path: &str,
max_frames: u32,
stop_address: u16,
verifier: fn(&mut Nes) -> bool,
) -> RomTestResult {
init_tracing_from_env();
let rom_data = match fs::read(rom_path) {
Ok(data) => data,
Err(e) => {
eprintln!("Failed to load ROM {}: {}", rom_path, e);
return RomTestResult::Fail(0x80_u8);
}
};
let cartridge = match Cartridge::load_from_file(
&rom_data,
rom_path,
crate::app_context::AppContext::new(),
) {
Ok(cart) => cart,
Err(e) => {
eprintln!("Failed to parse ROM {}: {}", rom_path, e);
return RomTestResult::Fail(0x81_u8);
}
};
let mut config = test_default_config();
config.hardware_model = HardwareModel::from_timing_mode(cartridge.rom_timing_mode());
let mut nes = Nes::new(crate::app_context::AppContext::new_with_config(config));
nes.insert_cartridge(cartridge);
nes.reset(false);
let cpu_cycles_per_frame = match nes.app_context().borrow().config().hardware_model {
HardwareModel::NesNtsc => 29_780u32,
HardwareModel::NesPal => 33_247u32,
};
for _frame in 1..=max_frames {
for _cpu_cycle in 0..cpu_cycles_per_frame {
if nes.cpu_ref().pc() == stop_address {
return if verifier(&mut nes) {
RomTestResult::Pass
} else {
RomTestResult::Fail(1)
};
}
nes.run_cpu_tick();
}
if nes.is_ready_to_render() {
nes.clear_ready_to_render();
}
}
RomTestResult::Timeout
}
pub fn run_nes_for_frames(nes: &mut Nes, frames: u32) {
if frames == 0 {
return;
}
let max_ticks: u64 = 200_000_000;
let mut frames_completed = 0u32;
let mut ticks = 0u64;
while frames_completed < frames {
nes.run_cpu_tick();
ticks += 1;
if ticks > max_ticks {
panic!(
"Timed out running {} frames (only reached {})",
frames, frames_completed
);
}
while nes.sample_ready() {
nes.get_sample();
}
if nes.is_ready_to_render() {
frames_completed += 1;
nes.clear_ready_to_render();
}
}
}
pub(crate) fn init_tracing_from_env() {
let apu_level = match std::env::var("NESER_TRACE_APU") {
Ok(value) => value.parse::<u8>().unwrap_or(1),
Err(_) => return,
};
let cpu_level = match std::env::var("NESER_TRACE_CPU") {
Ok(value) => value.parse::<u8>().unwrap_or(1),
Err(_) => return,
};
if apu_level != 0 || cpu_level != 0 {
init_tracing(Tracing {
enabled: true,
apu: apu_level,
cpu: cpu_level,
..Tracing::default()
});
}
}
fn parse_crc32_from_console_text(text: &str) -> Option<u32> {
for token in text.split_whitespace() {
if token.len() != 8 {
continue;
}
if !token.chars().all(|c| c.is_ascii_hexdigit()) {
continue;
}
if let Ok(value) = u32::from_str_radix(token, 16) {
return Some(value);
}
}
None
}
pub(crate) fn write_checkpoint_png(
path: &std::path::Path,
rgb: &[u8],
width: u32,
height: u32,
) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.expect("checkpoint artifact directory should be created");
}
let file = std::fs::File::create(path).expect("checkpoint image file should be created");
let mut writer = std::io::BufWriter::new(file);
let mut encoder = ::png::Encoder::new(&mut writer, width, height);
encoder.set_color(::png::ColorType::Rgb);
encoder.set_depth(::png::BitDepth::Eight);
let mut png_writer = encoder
.write_header()
.expect("checkpoint PNG header should be written");
png_writer
.write_image_data(rgb)
.expect("checkpoint PNG image data should be written");
drop(png_writer);
use std::io::Write as _;
writer
.flush()
.expect("checkpoint PNG buffer should be flushed");
}
#[macro_export]
macro_rules! setup_rom_test {
($test_name:ident, $rom_path:expr, $timeout:expr) => {
#[test]
fn $test_name() {
let mut runner = $crate::integration_tests::rom_test_runner::tests::RomTestRunner::new(
$rom_path,
$timeout,
$crate::integration_tests::rom_test_runner::tests::RomTestVerification::StatusByte,
);
let result = runner.run_test();
let rom_name = $rom_path.split('/').last().unwrap();
assert_eq!(
result,
$crate::integration_tests::rom_test_runner::tests::RomTestResult::Pass,
"{} should pass",
rom_name
);
}
};
($test_name:ident, $rom_path:expr) => {
setup_rom_test!($test_name, $rom_path, 60 * 30); };
}
#[macro_export]
macro_rules! setup_rom_console_test {
($test_name:ident, $rom_path:expr) => {
setup_rom_console_test!($test_name, $rom_path, "PASSED");
};
($test_name:ident, $rom_path:expr, $pass_string:expr) => {
#[test]
fn $test_name() {
let mut runner = $crate::integration_tests::rom_test_runner::tests::RomTestRunner::new(
$rom_path,
60 * 30,
$crate::integration_tests::rom_test_runner::tests::RomTestVerification::Console {
pass_string: $pass_string.to_string(),
},
);
let result = runner.run_test();
let rom_name = $rom_path.split('/').last().unwrap();
assert_eq!(
result,
$crate::integration_tests::rom_test_runner::tests::RomTestResult::Pass,
"{} should pass",
rom_name
);
}
};
($test_name:ident, $rom_path:expr, $pass_string:expr, $tv_system:expr) => {
#[test]
fn $test_name() {
let mut runner = $crate::integration_tests::rom_test_runner::tests::RomTestRunner::new_with_tv_system(
$rom_path,
60 * 30,
$crate::integration_tests::rom_test_runner::tests::RomTestVerification::Console {
pass_string: $pass_string.to_string(),
},
$tv_system,
);
let result = runner.run_test();
let rom_name = $rom_path.split('/').last().unwrap();
assert_eq!(
result,
$crate::integration_tests::rom_test_runner::tests::RomTestResult::Pass,
"{} should pass",
rom_name
);
}
};
}
#[macro_export]
macro_rules! setup_rom_console_test_with_ram_init {
($test_name:ident, $rom_path:expr, $pass_string:expr, $ram_init_mode:expr) => {
#[test]
fn $test_name() {
let mut runner = $crate::integration_tests::rom_test_runner::tests::RomTestRunner::new_with_ram_init_mode(
$rom_path,
60 * 30,
$crate::integration_tests::rom_test_runner::tests::RomTestVerification::Console {
pass_string: $pass_string.to_string(),
},
$ram_init_mode,
);
let result = runner.run_test();
let rom_name = $rom_path.split('/').last().unwrap();
assert_eq!(
result,
$crate::integration_tests::rom_test_runner::tests::RomTestResult::Pass,
"{} should pass",
rom_name
);
}
};
}
#[macro_export]
macro_rules! setup_rom_console_crc_test {
($test_name:ident, $rom_path:expr, $timeout:expr, $expected:expr) => {
#[test]
fn $test_name() {
let mut runner = $crate::integration_tests::rom_test_runner::tests::RomTestRunner::new(
$rom_path,
$timeout,
$crate::integration_tests::rom_test_runner::tests::RomTestVerification::ConsoleCrc(
$expected,
),
);
let result = runner.run_test();
let rom_name = $rom_path.split('/').last().unwrap();
assert_eq!(
result,
$crate::integration_tests::rom_test_runner::tests::RomTestResult::Pass,
"{} should pass",
rom_name
);
}
};
($test_name:ident, $rom_path:expr, $expected:expr) => {
setup_rom_console_crc_test!($test_name, $rom_path, 60 * 30, $expected); };
}
#[macro_export]
macro_rules! setup_rom_crc_test {
($test_name:ident, $rom_path:expr, $checkpoints:expr) => {
#[test]
fn $test_name() {
let rom_data = std::fs::read($rom_path).expect("ROM should load");
let cartridge = $crate::cartridge::Cartridge::load_from_file(
&rom_data,
$rom_path,
&$crate::app_context::AppContext::new(),
)
.expect("ROM should parse");
let mut config = $crate::console::Config {
ram_init_mode: $crate::console::RamInitMode::Zero,
..Default::default()
};
config.hardware_model =
$crate::console::HardwareModel::from_timing_mode(cartridge.rom_timing_mode());
let mut nes = $crate::console::Nes::new(
$crate::app_context::AppContext::new_with_config(config),
);
nes.insert_cartridge(cartridge);
nes.reset(false);
let checkpoints = $checkpoints;
let capture_screen = std::env::var_os("NESER_CAPTURE_SCREEN").is_some();
let capture_dir =
std::path::PathBuf::from("target/crc_checkpoints").join(stringify!($test_name));
let mut previous_frame = 0u32;
let mut actual: Vec<(u32, u32)> = Vec::with_capacity(checkpoints.len());
for (frame, _expected_crc) in checkpoints.iter().copied() {
assert!(
frame >= previous_frame,
"checkpoint frames must be in non-decreasing order"
);
let delta = frame - previous_frame;
if delta > 0 {
$crate::integration_tests::rom_test_runner::tests::run_nes_for_frames(
&mut nes, delta,
);
}
let screen = nes.get_screen_buffer();
let crc = screen.crc32();
actual.push((frame, crc));
if capture_screen {
let rgb = screen.snapshot();
let file_name = format!("f{:05}_crc_{:08X}.png", frame, crc);
let path = capture_dir.join(file_name);
$crate::integration_tests::rom_test_runner::tests::write_checkpoint_png(
&path, &rgb, 256, 240,
);
}
previous_frame = frame;
}
let expected: Vec<(u32, u32)> = checkpoints.iter().copied().collect();
assert_eq!(
actual, expected,
"CRC checkpoints mismatch for {}",
$rom_path
);
if capture_screen {
println!(
"[crc-checkpoint] generated checkpoint artifacts in {}",
capture_dir.display()
);
}
}
};
}
#[macro_export]
macro_rules! setup_rom_address_test {
($test_name:ident, $rom_path:expr, $stop_address:expr, $verify_fn:expr, $timeout:expr) => {
#[test]
fn $test_name() {
let result = $crate::integration_tests::rom_test_runner::tests::run_address_test(
$rom_path,
$timeout,
$stop_address,
$verify_fn,
);
let rom_name = $rom_path.split('/').last().unwrap();
assert_eq!(
result,
$crate::integration_tests::rom_test_runner::tests::RomTestResult::Pass,
"{} should pass",
rom_name
);
}
};
($test_name:ident, $rom_path:expr, $stop_address:expr, $verify_fn:expr) => {
setup_rom_address_test!($test_name, $rom_path, $stop_address, $verify_fn, 60 * 30); };
}
}