use crate::{NesError, NesResult};
use anyhow::anyhow;
use enum_dispatch::enum_dispatch;
use serde::{Deserialize, Serialize};
use std::fmt::Write;
#[cfg(not(target_arch = "wasm32"))]
use std::path::{Path, PathBuf};
pub const CONFIG_DIR: &str = ".config/tetanes";
pub const SAVE_DIR: &str = "save";
pub const SRAM_DIR: &str = "sram";
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[must_use]
pub enum NesRegion {
#[default]
Ntsc,
Pal,
Dendy,
}
impl NesRegion {
pub const fn as_slice() -> &'static [Self] {
&[NesRegion::Ntsc, NesRegion::Pal, NesRegion::Dendy]
}
}
impl AsRef<str> for NesRegion {
fn as_ref(&self) -> &str {
match self {
Self::Ntsc => "NTSC",
Self::Pal => "PAL",
Self::Dendy => "Dendy",
}
}
}
impl TryFrom<&str> for NesRegion {
type Error = NesError;
fn try_from(value: &str) -> NesResult<Self> {
match value {
"NTSC" => Ok(Self::Ntsc),
"PAL" => Ok(Self::Pal),
"Dendy" => Ok(Self::Dendy),
_ => Err(anyhow!("invalid nes region")),
}
}
}
impl From<usize> for NesRegion {
fn from(value: usize) -> Self {
match value {
1 => Self::Pal,
2 => Self::Dendy,
_ => Self::Ntsc,
}
}
}
#[enum_dispatch(Mapper)]
pub trait Regional {
fn region(&self) -> NesRegion {
NesRegion::default()
}
fn set_region(&mut self, _region: NesRegion) {}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[must_use]
pub enum Kind {
Soft,
Hard,
}
#[enum_dispatch(Mapper)]
pub trait Reset {
fn reset(&mut self, _kind: Kind) {}
}
#[enum_dispatch(Mapper)]
pub trait Clock {
fn clock(&mut self) -> usize {
0
}
fn clock_to(&mut self, _clocks: u64) {}
}
#[macro_export]
macro_rules! hashmap {
{ $($key:expr => $value:expr),* $(,)? } => {{
let mut m = ::std::collections::HashMap::new();
$(
m.insert($key, $value);
)*
m
}};
($hm:ident, { $($key:expr => $value:expr),* $(,)? } ) => ({
$(
$hm.insert($key, $value);
)*
});
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn config_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("./"))
.join(CONFIG_DIR)
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn config_path<P: AsRef<Path>>(path: P) -> PathBuf {
config_dir().join(path)
}
#[must_use]
pub fn hexdump(data: &[u8], addr_offset: usize) -> Vec<String> {
use std::cmp;
let mut addr = 0;
let len = data.len();
let mut last_line_same = false;
let mut output = Vec::new();
let mut last_line = String::with_capacity(80);
while addr <= len {
let end = cmp::min(addr + 16, len);
let line_data = &data[addr..end];
let line_len = line_data.len();
let mut line = String::with_capacity(80);
for byte in line_data.iter() {
let _ = write!(line, " {byte:02X}");
}
if line_len % 16 > 0 {
let words_left = (16 - line_len) / 2;
for _ in 0..3 * words_left {
line.push(' ');
}
}
if line_len > 0 {
line.push_str(" |");
for c in line_data {
if (*c as char).is_ascii() && !(*c as char).is_control() {
let _ = write!(line, "{}", (*c as char));
} else {
line.push('.');
}
}
line.push('|');
}
if last_line == line {
if !last_line_same {
last_line_same = true;
output.push("*".to_string());
}
} else {
last_line_same = false;
output.push(format!("{:08x} {}", addr + addr_offset, line));
}
last_line = line;
addr += 16;
}
output
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::{
control_deck::ControlDeck,
input::Slot,
mapper::{Mapper, MapperRevision},
nes::event::{Action, NesState, Setting},
ppu::Ppu,
video::VideoFilter,
};
use anyhow::Context;
use once_cell::sync::Lazy;
use pix_engine::prelude::{Image, PixelFormat};
use serde::{Deserialize, Serialize};
use std::fmt::Write;
use std::{
collections::hash_map::DefaultHasher,
env,
fs::{self, File},
hash::{Hash, Hasher},
io::{BufReader, BufWriter},
path::{Path, PathBuf},
};
pub(crate) const RESULT_DIR: &str = "test_results";
static INIT_TESTS: Lazy<bool> = Lazy::new(|| {
let result_dir = PathBuf::from(RESULT_DIR);
if result_dir.exists() {
fs::remove_dir_all(result_dir).expect("cleared test results dir");
}
true
});
static PASS_DIR: Lazy<PathBuf> = Lazy::new(|| {
let directory = PathBuf::from(RESULT_DIR).join("pass");
fs::create_dir_all(&directory).expect("created pass test results dir");
directory
});
static FAIL_DIR: Lazy<PathBuf> = Lazy::new(|| {
let directory = PathBuf::from(RESULT_DIR).join("fail");
fs::create_dir_all(&directory).expect("created fail test results dir");
directory
});
#[macro_export]
macro_rules! test_roms {
($directory:expr, $( $(#[ignore = $reason:expr])? $test:ident ),* $(,)?) => {$(
$(#[ignore = $reason])?
#[test]
fn $test() {
$crate::common::tests::test_rom($directory, stringify!($test));
}
)*};
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[must_use]
struct TestFrame {
number: u32,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
hash: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
slot: Option<Slot>,
#[serde(skip_serializing_if = "Option::is_none")]
action: Option<Action>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[must_use]
struct RomTest {
name: String,
frames: Vec<TestFrame>,
}
fn get_rom_tests(directory: &str) -> (PathBuf, Vec<RomTest>) {
let file = PathBuf::from(directory)
.join("tests")
.with_extension("json");
let tests = File::open(&file)
.and_then(|file| {
Ok(serde_json::from_reader::<_, Vec<RomTest>>(BufReader::new(
file,
))?)
})
.expect("valid rom test data");
(file, tests)
}
fn load_control_deck<P: AsRef<Path>>(path: P) -> ControlDeck {
let path = path.as_ref();
let mut rom = BufReader::new(File::open(path).expect("failed to open path"));
let mut deck = ControlDeck::default();
deck.load_rom(&path.to_string_lossy(), &mut rom)
.expect("failed to load rom");
deck.set_filter(VideoFilter::Pixellate);
deck.set_region(NesRegion::Ntsc);
deck
}
fn handle_frame_action(test_frame: &TestFrame, deck: &mut ControlDeck) {
if let Some(action) = test_frame.action {
log::debug!("{:?}", action);
match action {
Action::Nes(state) => match state {
NesState::SoftReset => deck.reset(Kind::Soft),
NesState::HardReset => deck.reset(Kind::Hard),
NesState::MapperRevision(board) => match board {
MapperRevision::Mmc3(revision) => {
if let Mapper::Txrom(ref mut mapper) = deck.mapper_mut() {
mapper.set_revision(revision);
}
}
_ => panic!("unhandled MapperRevision {board:?}"),
},
_ => panic!("unhandled Nes state: {state:?}"),
},
Action::Setting(setting) => match setting {
Setting::SetVideoFilter(filter) => deck.set_filter(filter),
Setting::SetNesFormat(format) => deck.set_region(format),
_ => panic!("unhandled Setting: {setting:?}"),
},
Action::Joypad(button) => {
let slot = test_frame.slot.unwrap_or(Slot::One);
let joypad = deck.joypad_mut(slot);
joypad.set_button(button.into(), true);
}
_ => (),
}
}
}
fn handle_snapshot(
test: &str,
test_frame: &TestFrame,
deck: &mut ControlDeck,
count: usize,
) -> Option<(u64, u64, u32, PathBuf)> {
test_frame.hash.map(|expected| {
let mut hasher = DefaultHasher::new();
let frame = deck.frame_buffer();
frame.hash(&mut hasher);
let actual = hasher.finish();
log::debug!(
"frame : {}, matched: {}",
test_frame.number,
expected == actual
);
let result_dir = if env::var("UPDATE_SNAPSHOT").is_ok() || expected == actual {
&*PASS_DIR
} else {
&*FAIL_DIR
};
let mut filename = test.to_owned();
if let Some(ref name) = test_frame.name {
let _ = write!(filename, "_{name}");
} else if count > 0 {
let _ = write!(filename, "_{}", count + 1);
}
let screenshot = result_dir
.join(PathBuf::from(filename))
.with_extension("png");
Image::from_bytes(Ppu::WIDTH, Ppu::HEIGHT, frame, PixelFormat::Rgba)
.expect("valid frame")
.save(&screenshot)
.expect("result screenshot");
(expected, actual, test_frame.number, screenshot)
})
}
pub(crate) fn test_rom(directory: &str, test_name: &str) {
if !&*INIT_TESTS {
log::debug!("Initialized tests");
}
let (test_file, mut tests) = get_rom_tests(directory);
let mut test = tests.iter_mut().find(|test| test.name.eq(test_name));
assert!(test.is_some(), "No test found matching {test_name:?}");
let test = test.as_mut().expect("definitely has a test");
let rom = PathBuf::from(directory)
.join(PathBuf::from(&test.name))
.with_extension("nes");
assert!(rom.exists(), "No test rom found for {rom:?}");
let mut deck = load_control_deck(&rom);
if env::var("RUST_LOG").is_ok() {
let _ = pretty_env_logger::try_init();
}
let mut results = Vec::new();
for test_frame in test.frames.iter() {
log::debug!("{} - {:?}", test_frame.number, deck.joypad_mut(Slot::One));
while deck.frame_number() < test_frame.number {
deck.clock_frame().expect("valid frame clock");
deck.clear_audio_samples();
deck.joypad_mut(Slot::One).reset(Kind::Soft);
deck.joypad_mut(Slot::Two).reset(Kind::Soft);
}
handle_frame_action(test_frame, &mut deck);
if let Some(result) = handle_snapshot(&test.name, test_frame, &mut deck, results.len())
{
results.push(result);
}
}
let mut update_required = false;
for (mut expected, actual, frame_number, screenshot) in results {
if env::var("UPDATE_SNAPSHOT").is_ok() && expected != actual {
expected = actual;
update_required = true;
if let Some(ref mut frame) = test
.frames
.iter_mut()
.find(|frame| frame.number == frame_number)
{
frame.hash = Some(actual);
}
}
assert_eq!(
expected, actual,
"mismatched snapshot for {rom:?} -> {screenshot:?}",
);
}
if update_required {
File::create(test_file)
.context("failed to open rom test file")
.and_then(|file| {
serde_json::to_writer_pretty(BufWriter::new(file), &tests)
.context("failed to serialize rom data")
})
.expect("failed to update snapshot");
}
}
}