use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use std::path::PathBuf;
use crate::core::state::{ChunkReader, MachineShape, Migrations, Source, StateReader, StateWriter};
use crate::machine::{Machine, catalog};
const GATE: &str = "RSEMU_CONFORMANCE";
const TESTDATA: &str = "RSEMU_TESTDATA";
const DEFAULT_FRAMES: u64 = 600_000;
const QUANTA_PER_CHECK: u32 = 4096;
const DONE: &str = "Tests complete";
const LEDGER: &[&str] = &[];
fn enabled() -> bool {
matches!(
std::env::var(GATE).as_deref(),
Ok("1") | Ok("true") | Ok("yes")
)
}
fn testdata_root() -> PathBuf {
match std::env::var_os(TESTDATA) {
Some(dir) => PathBuf::from(dir),
None => PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata"),
}
}
fn frame_limit() -> u64 {
std::env::var("RSEMU_SMS_FRAMES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_FRAMES)
}
fn corpus() -> Option<PathBuf> {
if !enabled() {
println!("SKIP sms-zexall: set {GATE}=1 to run conformance suites");
return None;
}
let dir = match std::env::var_os("RSEMU_SMS_ZEXALL_DIR") {
Some(d) => PathBuf::from(d),
None => testdata_root().join("sms-zexall"),
};
let name = std::env::var("RSEMU_SMS_ZEXALL_ROM").unwrap_or_else(|_| "zexall.sms".to_string());
let rom = dir.join(&name);
if !rom.is_file() {
println!("SKIP sms-zexall: {} not found", rom.display());
println!(" fetch it with: scripts/fetch-testdata.sh sms");
return None;
}
Some(rom)
}
fn chunk_of(machine: &Machine, path: &str) -> Option<Vec<u8>> {
let entry = machine.device(path)?;
let class = entry.class();
let mut writer = StateWriter::new(MachineShape::new());
{
let mut chunk = writer.chunk(path, class.name, class.version).ok()?;
entry.device().save(&mut chunk).ok()?;
}
let bytes = writer.to_vec().ok()?;
let reader = StateReader::new(&bytes).ok()?;
let chunk = reader
.load(path, class.name, class.version, &Migrations::new())
.ok()?;
Some(chunk.into_data())
}
fn console_text(machine: &Machine) -> Option<String> {
let data = chunk_of(machine, "sdsc")?;
let mut r = ChunkReader::new(&data);
let _version = r.read_u32().ok()?;
r.read_string().ok()
}
fn frames(machine: &Machine) -> Option<u64> {
let data = chunk_of(machine, "vdp")?;
let mut r = ChunkReader::new(&data);
let _version = r.read_u32().ok()?;
let _vram = r.read_bytes().ok()?;
let _cram = r.read_bytes().ok()?;
let _regs = r.read_bytes().ok()?;
let _addr = r.read_u16().ok()?;
for _ in 0..4 {
r.read_u8().ok()?;
}
let _latch = ();
let _status = r.read_u8().ok()?;
let _line_counter = r.read_u8().ok()?;
let _line_irq = r.read_bool().ok()?;
let _vscroll = r.read_u8().ok()?;
let _dot = r.read_u64().ok()?;
let _line = r.read_u16().ok()?;
let _dots = r.read_u64().ok()?;
r.read_u64().ok()
}
#[derive(Debug, PartialEq, Eq)]
struct Verdict {
name: String,
passed: bool,
detail: String,
}
fn parse(text: &str) -> Vec<Verdict> {
let mut out: Vec<Verdict> = Vec::new();
let mut pending: Option<String> = None;
for line in text.lines() {
let trimmed = line.trim_end_matches('\r');
if let Some(rest) = trimmed.strip_prefix(" CRC ") {
let name = pending.take().unwrap_or_else(|| "<unnamed>".to_string());
out.push(Verdict {
name: tidy(&name),
passed: false,
detail: rest.trim().to_string(),
});
} else if let Some(name) = trimmed.strip_suffix("OK") {
pending = None;
out.push(Verdict {
name: tidy(name),
passed: true,
detail: String::new(),
});
} else if !trimmed.trim().is_empty() {
pending = Some(trimmed.to_string());
}
}
out
}
fn tidy(name: &str) -> String {
name.trim_end_matches(['.', ' ']).trim().to_string()
}
#[test]
fn zexall_on_the_shipped_machine() {
let Some(path) = corpus() else {
return;
};
let rom = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) => {
println!("SKIP sms-zexall: cannot read {}: {e}", path.display());
return;
}
};
let mut machine = match catalog::build_catalog("sms-ntsc", &[("cart", &rom)]) {
Ok(m) => m,
Err(e) => panic!("`machine-sms` is on but sms-ntsc will not realize: {e}"),
};
let limit = frame_limit();
let mut complete = false;
while frames(&machine).unwrap_or(u64::MAX) < limit {
for _ in 0..QUANTA_PER_CHECK {
if machine.run_quantum().is_err() {
break;
}
}
if console_text(&machine).is_some_and(|t| t.contains(DONE)) {
complete = true;
break;
}
}
let text = console_text(&machine).unwrap_or_default();
let verdicts = parse(&text);
let passed = verdicts.iter().filter(|v| v.passed).count();
let failed: Vec<&Verdict> = verdicts.iter().filter(|v| !v.passed).collect();
let mut report = format!(
"sms-zexall ({}): {passed}/{} tests agreed{}\n",
path.file_name().unwrap_or_default().to_string_lossy(),
verdicts.len(),
if complete {
", run complete"
} else {
", run TRUNCATED by the frame budget"
}
);
for verdict in &failed {
report.push_str(&format!(" FAIL {}: {}\n", verdict.name, verdict.detail));
}
println!("{report}");
if verdicts.is_empty() {
panic!(
"the exerciser printed nothing in {limit} frames — the debug console, the mapper or \
the core is not working at all:\n{text:?}"
);
}
let unexpected: Vec<&&Verdict> = failed
.iter()
.filter(|v| !LEDGER.contains(&v.name.as_str()))
.collect();
assert!(
unexpected.is_empty(),
"{} test(s) disagreed and are not in the ledger:\n{}",
unexpected.len(),
unexpected
.iter()
.map(|v| format!(" {}: {}", v.name, v.detail))
.collect::<Vec<_>>()
.join("\n")
);
for entry in LEDGER {
assert!(
failed.iter().any(|v| v.name == *entry),
"`{entry}` is in the ledger but passed; the ledger only ever shrinks, so remove it"
);
}
}
#[cfg(test)]
mod parsing {
use super::{parse, tidy};
#[test]
fn a_passing_line_is_a_name_and_ok() {
let v = parse("ld hl,(nnnn)........OK\n");
assert_eq!(v.len(), 1);
assert!(v[0].passed);
assert_eq!(v[0].name, "ld hl,(nnnn)");
}
#[test]
fn a_failing_test_names_itself_on_the_line_before_its_crc() {
let text = "add hl,<bc,de,hl,sp>..\n CRC 12345678 expected 9abcdef0\n";
let v = parse(text);
assert_eq!(v.len(), 1);
assert!(!v[0].passed);
assert_eq!(v[0].name, "add hl,<bc,de,hl,sp>");
assert_eq!(v[0].detail, "12345678 expected 9abcdef0");
}
#[test]
fn a_banner_line_is_not_a_verdict() {
let text = "Z80 instruction exerciser\n* SMS Mode 4\nld a,i..OK\nTests complete\n";
let v = parse(text);
assert_eq!(v.len(), 1, "only the one test line counts");
assert_eq!(v[0].name, "ld a,i");
}
#[test]
fn padding_dots_are_not_part_of_a_name() {
assert_eq!(tidy("neg...................."), "neg");
}
}