use crate::gba::Gba;
use crate::gba::bios::EMBEDDED_BIOS;
use crate::gba::cpu::bus::Bus;
use crate::platform::app_context::AppContext;
use crate::platform::emulator::Emulator;
use std::path::{Path, PathBuf};
const MAX_CYCLES: u64 = 480_000_000;
const IDLE_PROBE_STABLE_PC_THRESHOLD: u32 = 1;
const ARM_BRANCH_SELF_OPCODE: u32 = 0xEAFF_FFFE;
const THUMB_BRANCH_SELF_OPCODE: u16 = 0xE7FE;
pub(crate) const GBA_CYCLES_PER_FRAME: u64 = 280_896;
const FRAME_SETTLE_MAX_CYCLES: u64 = GBA_CYCLES_PER_FRAME * 2;
pub const MGBA_MEMORY_PROPRIETARY_BIOS_ENV: &str = "NESER_GBA_PROPRIETARY_BIOS";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Suite {
Arm,
Thumb,
Nes,
Memory,
SaveNone,
SaveSram,
SaveFlash64,
SaveFlash128,
PpuHello,
PpuShades,
PpuStripes,
FuzzArmDataProcessing,
FuzzArmAny,
FuzzThumbDataProcessing,
FuzzThumbAny,
FuzzArmMixed,
ArmWrestler,
Mgba,
}
impl Suite {
fn rom_path_str(self) -> &'static str {
match self {
Self::Arm => "roms/gba/automated_tests/gba-tests/arm/arm.gba",
Self::Thumb => "roms/gba/automated_tests/gba-tests/thumb/thumb.gba",
Self::Nes => "roms/gba/automated_tests/gba-tests/nes/nes.gba",
Self::Memory => "roms/gba/automated_tests/gba-tests/memory/memory.gba",
Self::SaveNone => "roms/gba/automated_tests/gba-tests/save/none.gba",
Self::SaveSram => "roms/gba/automated_tests/gba-tests/save/sram.gba",
Self::SaveFlash64 => "roms/gba/automated_tests/gba-tests/save/flash64.gba",
Self::SaveFlash128 => "roms/gba/automated_tests/gba-tests/save/flash128.gba",
Self::PpuHello => "roms/gba/automated_tests/gba-tests/ppu/hello.gba",
Self::PpuShades => "roms/gba/automated_tests/gba-tests/ppu/shades.gba",
Self::PpuStripes => "roms/gba/automated_tests/gba-tests/ppu/stripes.gba",
Self::FuzzArmDataProcessing => {
"roms/gba/automated_tests/FuzzARM/ARM_DataProcessing.gba"
}
Self::FuzzArmAny => "roms/gba/automated_tests/FuzzARM/ARM_Any.gba",
Self::FuzzThumbDataProcessing => {
"roms/gba/automated_tests/FuzzARM/THUMB_DataProcessing.gba"
}
Self::FuzzThumbAny => "roms/gba/automated_tests/FuzzARM/THUMB_Any.gba",
Self::FuzzArmMixed => "roms/gba/automated_tests/FuzzARM/FuzzARM.gba",
Self::ArmWrestler => "roms/gba/automated_tests/armwrestler/armwrestler-gba-fixed.gba",
Self::Mgba => "roms/gba/automated_tests/mgba-emu-suite/suite.gba",
}
}
fn rom_path(self) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(self.rom_path_str())
}
fn result_register(self) -> Option<(usize, &'static str)> {
match self {
Self::Arm => Some((12, "r12")),
Self::Thumb => Some((7, "r7")),
Self::Nes => Some((12, "r12")),
Self::Memory => Some((12, "r12")),
Self::SaveNone => Some((12, "r12")),
Self::SaveSram => Some((12, "r12")),
Self::SaveFlash64 => Some((12, "r12")),
Self::SaveFlash128 => Some((12, "r12")),
Self::PpuHello => Some((12, "r12")),
Self::PpuShades => Some((12, "r12")),
Self::PpuStripes => Some((12, "r12")),
Self::FuzzArmDataProcessing
| Self::FuzzArmAny
| Self::FuzzThumbDataProcessing
| Self::FuzzThumbAny
| Self::FuzzArmMixed
| Self::ArmWrestler
| Self::Mgba => None,
}
}
fn capture_stem(self) -> &'static str {
match self {
Self::Arm => "arm",
Self::Thumb => "thumb",
Self::Nes => "nes",
Self::Memory => "memory",
Self::SaveNone => "save_none",
Self::SaveSram => "save_sram",
Self::SaveFlash64 => "save_flash64",
Self::SaveFlash128 => "save_flash128",
Self::PpuHello => "ppu_hello",
Self::PpuShades => "ppu_shades",
Self::PpuStripes => "ppu_stripes",
Self::FuzzArmDataProcessing => "fuzzarm_data_processing",
Self::FuzzArmAny => "fuzzarm_any",
Self::FuzzThumbDataProcessing => "fuzzthumb_data_processing",
Self::FuzzThumbAny => "fuzzthumb_any",
Self::FuzzArmMixed => "fuzzarm_mixed",
Self::ArmWrestler => "armwrestler",
Self::Mgba => "mgba_suite",
}
}
pub(crate) fn label(self) -> &'static str {
self.capture_stem()
}
pub(crate) fn is_fuzzarm(self) -> bool {
matches!(
self,
Self::FuzzArmDataProcessing
| Self::FuzzArmAny
| Self::FuzzThumbDataProcessing
| Self::FuzzThumbAny
| Self::FuzzArmMixed
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitReason {
IdleLoopDetected,
ExceptionVectorTrap,
CycleLimitReached,
CartStopped,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SuiteResult {
pub passed: bool,
pub failing_index: u32,
pub cycles: u64,
pub pc: u32,
pub cpsr: u32,
pub thumb: bool,
pub opcode_at_pc: u32,
pub framebuffer_crc32: u32,
pub reg_name: Option<&'static str>,
pub exit_reason: ExitReason,
pub ewram_dump: Option<String>,
}
pub fn run_suite(suite: Suite) -> SuiteResult {
let rom_path = suite.rom_path();
let rom = std::fs::read(&rom_path).unwrap_or_else(|e| {
panic!("failed to read suite ROM {}: {e}", rom_path.display());
});
let mut gba = Gba::new(AppContext::default());
gba.bus_mut().load_bios(EMBEDDED_BIOS);
gba.bus_mut().write8(0x03007FFC, 1); gba.load_rom(&rom, rom_path.to_str().unwrap_or("gba-suite-rom"))
.unwrap_or_else(|e| {
panic!("failed to load suite ROM {}: {e}", rom_path.display());
});
let mut cycles = 0u64;
let mut last_pc: Option<u32> = None;
let mut stable_pc_count: u32 = 0;
while cycles < MAX_CYCLES {
let tick_cycles = gba.run_tick_for_tests() as u64;
if tick_cycles == 0 {
let pc = gba.cpu_pc();
settle_framebuffer_for_result(&mut gba, ExitReason::CartStopped);
let result = result_from_register(&mut gba, suite, cycles, pc, ExitReason::CartStopped);
maybe_write_capture_png(&gba, suite, result.framebuffer_crc32);
return result;
}
cycles += tick_cycles;
let pc = gba.cpu_pc();
if Some(pc) == last_pc {
stable_pc_count = stable_pc_count.saturating_add(1);
} else {
stable_pc_count = 0;
last_pc = Some(pc);
}
if stable_pc_count >= IDLE_PROBE_STABLE_PC_THRESHOLD {
let is_idle = if gba.cpu_thumb() {
let opcode = gba.bus_mut().peek16(pc);
is_thumb_branch_to_self(opcode)
} else {
let opcode = gba.bus_mut().peek32(pc);
is_arm_branch_to_self(opcode, pc)
};
if is_idle {
let reason = if is_bios_exception_vector(pc) {
ExitReason::ExceptionVectorTrap
} else {
ExitReason::IdleLoopDetected
};
settle_framebuffer_for_result(&mut gba, reason);
let result = result_from_register(&mut gba, suite, cycles, pc, reason);
maybe_write_capture_png(&gba, suite, result.framebuffer_crc32);
return result;
}
}
}
let pc = gba.cpu_pc();
settle_framebuffer_for_result(&mut gba, ExitReason::CycleLimitReached);
let result = result_from_register(&mut gba, suite, cycles, pc, ExitReason::CycleLimitReached);
maybe_write_capture_png(&gba, suite, result.framebuffer_crc32);
result
}
fn result_from_register(
gba: &mut Gba,
suite: Suite,
cycles: u64,
pc: u32,
exit_reason: ExitReason,
) -> SuiteResult {
let (failing_index, reg_name) = match suite.result_register() {
Some((reg_index, name)) => (gba.cpu_reg(reg_index), Some(name)),
None => (0, None),
};
let cpsr = gba.cpu_cpsr();
let thumb = gba.cpu_thumb();
let opcode_at_pc = if thumb {
gba.bus_mut().peek16(pc) as u32
} else {
gba.bus_mut().peek32(pc)
};
let framebuffer_crc32 = gba.screen_crc32();
let passed = failing_index == 0 && exit_reason == ExitReason::IdleLoopDetected;
let ewram_dump = if suite.is_fuzzarm() {
Some(dump_fuzzarm_ewram(gba))
} else {
None
};
SuiteResult {
passed,
failing_index,
cycles,
pc,
cpsr,
thumb,
opcode_at_pc,
framebuffer_crc32,
reg_name,
exit_reason,
ewram_dump,
}
}
fn settle_framebuffer_for_result(gba: &mut Gba, exit_reason: ExitReason) {
if exit_reason != ExitReason::IdleLoopDetected {
return;
}
for _ in 0..2 {
if gba.is_ready_to_render() {
gba.clear_ready_to_render();
}
let mut settle_cycles = 0u64;
while settle_cycles < FRAME_SETTLE_MAX_CYCLES {
let tick_cycles = gba.run_tick_for_tests() as u64;
if tick_cycles == 0 {
return;
}
settle_cycles += tick_cycles;
if gba.is_ready_to_render() {
gba.clear_ready_to_render();
break;
}
}
}
}
fn maybe_write_capture_png(gba: &Gba, suite: Suite, framebuffer_crc32: u32) {
if std::env::var_os("NESER_CAPTURE_SCREEN").is_none() {
return;
}
let path = capture_output_path(suite, framebuffer_crc32);
let rgb = gba.screen_snapshot();
crate::platform::png_utils::write_rgb_png(&path, &rgb, Gba::SCREEN_WIDTH, Gba::SCREEN_HEIGHT);
println!(
"[gba-suite-capture] saved {} (crc=0x{:08X})",
path.display(),
framebuffer_crc32
);
}
fn capture_output_path(suite: Suite, framebuffer_crc32: u32) -> PathBuf {
let file_name = format!("{}_crc_{:08X}.png", suite.capture_stem(), framebuffer_crc32);
PathBuf::from("target/gba_suite_checkpoints").join(file_name)
}
fn dump_fuzzarm_ewram(gba: &mut Gba) -> String {
const EWRAM_BASE: u32 = 0x0200_0000;
let mode_word = gba.bus_mut().peek32(EWRAM_BASE);
let mode = match &mode_word.to_le_bytes() {
b"AAAA" => "ARM",
b"TTTT" => "THUMB",
_ => return format!("(no valid FuzzARM dump; mode word=0x{mode_word:08X})"),
};
let op_word1 = gba.bus_mut().peek32(EWRAM_BASE + 4);
let op_word2 = gba.bus_mut().peek32(EWRAM_BASE + 8);
let op_word3 = gba.bus_mut().peek32(EWRAM_BASE + 12);
let mut opcode_bytes = Vec::with_capacity(12);
opcode_bytes.extend_from_slice(&op_word1.to_le_bytes());
opcode_bytes.extend_from_slice(&op_word2.to_le_bytes());
opcode_bytes.extend_from_slice(&op_word3.to_le_bytes());
let opcode_str = String::from_utf8_lossy(&opcode_bytes).trim().to_string();
let init_r0 = gba.bus_mut().peek32(EWRAM_BASE + 16);
let init_r1 = gba.bus_mut().peek32(EWRAM_BASE + 20);
let init_r2 = gba.bus_mut().peek32(EWRAM_BASE + 24);
let init_cpsr = gba.bus_mut().peek32(EWRAM_BASE + 28);
let got_r3 = gba.bus_mut().peek32(EWRAM_BASE + 32);
let got_r4 = gba.bus_mut().peek32(EWRAM_BASE + 36);
let got_cpsr = gba.bus_mut().peek32(EWRAM_BASE + 44);
let exp_r3 = gba.bus_mut().peek32(EWRAM_BASE + 48);
let exp_r4 = gba.bus_mut().peek32(EWRAM_BASE + 52);
let exp_cpsr = gba.bus_mut().peek32(EWRAM_BASE + 60);
format!(
"FuzzARM {mode} failure: {opcode_str}\n\
Initial: r0=0x{init_r0:08X} r1=0x{init_r1:08X} r2=0x{init_r2:08X} CPSR=0x{init_cpsr:08X}\n\
Got: r3=0x{got_r3:08X} r4=0x{got_r4:08X} CPSR=0x{got_cpsr:08X}\n\
Expected:r3=0x{exp_r3:08X} r4=0x{exp_r4:08X} CPSR=0x{exp_cpsr:08X}"
)
}
fn is_arm_branch_to_self(opcode: u32, pc: u32) -> bool {
if opcode >> 28 != 0xE {
return false;
}
if (opcode & 0x0E00_0000) != 0x0A00_0000 {
return false;
}
if (opcode & 0x0100_0000) != 0 {
return false;
}
let imm24 = (opcode & 0x00FF_FFFF) as i32;
let signed_imm24 = (imm24 << 8) >> 8;
let offset = signed_imm24 << 2;
let target = pc.wrapping_add(8).wrapping_add(offset as u32);
target == pc
}
fn is_thumb_branch_to_self(opcode: u16) -> bool {
opcode == THUMB_BRANCH_SELF_OPCODE
}
fn is_bios_exception_vector(pc: u32) -> bool {
const BIOS_EXCEPTION_VECTORS: [u32; 5] = [0x04, 0x0C, 0x10, 0x18, 0x1C];
BIOS_EXCEPTION_VECTORS.contains(&pc)
}
pub const ARMWRESTLER_TEST_PAGE_COUNT: usize = 8;
const BTN_A: u8 = 0x01;
const BTN_B: u8 = 0x02;
const BTN_START: u8 = 0x08;
const BTN_UP: u8 = 0x10;
const BTN_DOWN: u8 = 0x20;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArmWrestlerResult {
pub page_crcs: Vec<u32>,
pub cycles: u64,
}
pub fn run_armwrestler() -> ArmWrestlerResult {
let rom_path = Suite::ArmWrestler.rom_path();
let rom = std::fs::read(&rom_path).unwrap_or_else(|e| {
panic!("failed to read armwrestler ROM {}: {e}", rom_path.display());
});
let mut gba = Gba::new(AppContext::default());
gba.bus_mut().load_bios(EMBEDDED_BIOS);
gba.bus_mut().write8(0x03007FFC, 1); gba.load_rom(&rom, rom_path.to_str().unwrap_or("armwrestler"))
.unwrap_or_else(|e| {
panic!("failed to load armwrestler ROM {}: {e}", rom_path.display());
});
let mut cycles: u64 = 0;
let mut page_crcs: Vec<u32> = Vec::new();
let mut menu_crc = 0u32;
let mut stable = 0u32;
for _ in 0..120 {
assert!(
run_until_frame_ready(&mut gba, &mut cycles),
"armwrestler: halted during boot"
);
gba.clear_ready_to_render();
let crc = gba.screen_crc32();
if crc == menu_crc {
stable += 1;
if stable >= 5 {
break;
}
} else {
menu_crc = crc;
stable = 1;
}
}
for _ in 0..10 {
assert!(
run_until_frame_ready(&mut gba, &mut cycles),
"armwrestler: halted during boot settle"
);
gba.clear_ready_to_render();
}
let press_and_wait = |gba: &mut Gba, cycles: &mut u64, button: u8, prev_crc: u32| -> u32 {
for _attempt in 0..3 {
gba.set_joypad_button_states(0, button);
for _ in 0..2 {
assert!(run_until_frame_ready(gba, cycles), "halted during press");
gba.clear_ready_to_render();
}
gba.set_joypad_button_states(0, 0);
for _ in 0..8 {
assert!(
run_until_frame_ready(gba, cycles),
"halted waiting for redraw"
);
gba.clear_ready_to_render();
}
let crc = gba.screen_crc32();
if crc != prev_crc {
return crc;
}
}
gba.screen_crc32()
};
gba.set_joypad_button_states(0, BTN_START);
assert!(
run_until_frame_ready(&mut gba, &mut cycles),
"halted during title dismiss"
);
gba.clear_ready_to_render();
gba.set_joypad_button_states(0, 0);
for _ in 0..10 {
assert!(
run_until_frame_ready(&mut gba, &mut cycles),
"halted after title dismiss"
);
gba.clear_ready_to_render();
}
let mut current_crc = gba.screen_crc32();
current_crc = press_and_wait(&mut gba, &mut cycles, BTN_START, current_crc);
page_crcs.push(current_crc);
maybe_write_armwrestler_png(&gba, 0, current_crc);
for page_idx in 1..5 {
current_crc = press_and_wait(&mut gba, &mut cycles, BTN_START, current_crc);
page_crcs.push(current_crc);
maybe_write_armwrestler_png(&gba, page_idx, current_crc);
}
current_crc = press_and_wait(&mut gba, &mut cycles, BTN_START, current_crc);
assert_eq!(armwrestler_cursor(&mut gba), 0);
for expected in 1..=5 {
current_crc = press_and_wait(&mut gba, &mut cycles, BTN_DOWN, current_crc);
assert_eq!(armwrestler_cursor(&mut gba), expected);
}
for expected in (3..=4).rev() {
current_crc = press_and_wait(&mut gba, &mut cycles, BTN_UP, current_crc);
assert_eq!(armwrestler_cursor(&mut gba), expected);
}
current_crc = press_and_wait(&mut gba, &mut cycles, BTN_START, current_crc);
page_crcs.push(current_crc);
maybe_write_armwrestler_png(&gba, 5, current_crc);
for page_idx in 6..8 {
current_crc = press_and_wait(&mut gba, &mut cycles, BTN_START, current_crc);
page_crcs.push(current_crc);
maybe_write_armwrestler_png(&gba, page_idx, current_crc);
}
ArmWrestlerResult { page_crcs, cycles }
}
fn armwrestler_cursor(gba: &mut Gba) -> u8 {
(gba.bus_mut().peek32(0x0300_0010) & 0xFF) as u8
}
fn run_until_frame_ready(gba: &mut Gba, cycles: &mut u64) -> bool {
let max_cycle_budget = GBA_CYCLES_PER_FRAME * 2;
let mut spent: u64 = 0;
while spent < max_cycle_budget {
let tick = gba.run_tick_for_tests() as u64;
if tick == 0 {
return false;
}
*cycles += tick;
spent += tick;
if gba.is_ready_to_render() {
return true;
}
}
false
}
pub const MGBA_SUITE_COUNT: usize = 14;
pub const MGBA_SUITE_KEYS: [&str; MGBA_SUITE_COUNT] = [
"mgba_memory",
"mgba_io_read",
"mgba_timing",
"mgba_timers",
"mgba_timer_irq",
"mgba_shifter",
"mgba_carry",
"mgba_multiply_long",
"mgba_bios_math",
"mgba_dma",
"mgba_sio_read",
"mgba_sio_timing",
"mgba_misc_edge",
"mgba_video",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MgbaSuiteResult {
pub suite_crcs: Vec<u32>,
pub cycles: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MgbaMemoryFailure {
pub test_name: String,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MgbaMemoryLog {
pub raw_log: String,
pub passed_count: Option<u32>,
pub total_count: Option<u32>,
pub failures: Vec<MgbaMemoryFailure>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MgbaMemoryDiagnosticResult {
pub framebuffer_crc32: u32,
pub passed_count: Option<u32>,
pub total_count: Option<u32>,
pub raw_log: String,
pub failures: Vec<MgbaMemoryFailure>,
}
pub fn mgba_memory_diagnostic_from_sram(
framebuffer_crc32: u32,
sram: &[u8],
) -> MgbaMemoryDiagnosticResult {
mgba_memory_diagnostic_from_log(framebuffer_crc32, parse_mgba_memory_sram_log(sram))
}
fn mgba_memory_diagnostic_from_log(
framebuffer_crc32: u32,
log: MgbaMemoryLog,
) -> MgbaMemoryDiagnosticResult {
MgbaMemoryDiagnosticResult {
framebuffer_crc32,
passed_count: log.passed_count,
total_count: log.total_count,
raw_log: log.raw_log,
failures: log.failures,
}
}
fn mgba_memory_diagnostic_from_gba(gba: &Gba) -> MgbaMemoryDiagnosticResult {
let log = parse_mgba_memory_sram_log(gba.bus().mgba_log_snapshot().as_bytes());
mgba_memory_diagnostic_from_log(gba.screen_crc32(), log)
}
fn mgba_io_read_diagnostic_from_gba(gba: &Gba) -> MgbaMemoryDiagnosticResult {
mgba_named_sub_suite_diagnostic_from_gba(gba, "I/O read tests")
}
fn mgba_timing_diagnostic_from_gba(gba: &Gba) -> MgbaMemoryDiagnosticResult {
mgba_named_sub_suite_diagnostic_from_gba(gba, "Timing tests")
}
fn mgba_timers_diagnostic_from_gba(gba: &Gba) -> MgbaMemoryDiagnosticResult {
mgba_named_sub_suite_diagnostic_from_gba(gba, "Timer count-up tests")
}
fn mgba_timer_irq_diagnostic_from_gba(gba: &Gba) -> MgbaMemoryDiagnosticResult {
mgba_named_sub_suite_diagnostic_from_gba(gba, "Timer IRQ tests")
}
fn mgba_dma_diagnostic_from_gba(gba: &Gba) -> MgbaMemoryDiagnosticResult {
mgba_named_sub_suite_diagnostic_from_gba(gba, "DMA tests")
}
fn mgba_sio_read_diagnostic_from_gba(gba: &Gba) -> MgbaMemoryDiagnosticResult {
mgba_named_sub_suite_diagnostic_from_gba(gba, "SIO register R/W tests")
}
fn mgba_sio_timing_diagnostic_from_gba(gba: &Gba) -> MgbaMemoryDiagnosticResult {
mgba_named_sub_suite_diagnostic_from_gba(gba, "SIO timing tests")
}
fn mgba_misc_edge_diagnostic_from_gba(gba: &Gba) -> MgbaMemoryDiagnosticResult {
mgba_named_sub_suite_diagnostic_from_gba(gba, "Misc. edge case tests")
}
fn mgba_bios_math_diagnostic_from_gba(gba: &Gba) -> MgbaMemoryDiagnosticResult {
mgba_named_sub_suite_diagnostic_from_gba(gba, "BIOS math tests")
}
fn mgba_named_sub_suite_diagnostic_from_gba(
gba: &Gba,
suite_name: &str,
) -> MgbaMemoryDiagnosticResult {
let log = parse_mgba_sub_suite_log(
gba.bus().mgba_log_snapshot().as_bytes(),
gba.bus().sram_snapshot(),
suite_name,
);
mgba_memory_diagnostic_from_log(gba.screen_crc32(), log)
}
pub fn run_mgba_memory_diagnostics() -> MgbaMemoryDiagnosticResult {
let (gba, _rom) = boot_mgba_suite();
run_mgba_memory_diagnostics_from_gba(gba)
}
pub fn run_mgba_io_read_diagnostics() -> MgbaMemoryDiagnosticResult {
let (gba, _rom) = boot_mgba_suite();
run_mgba_sub_suite_diagnostics_from_gba(gba, 1, "I/O read diagnostics")
}
pub fn run_mgba_timing_diagnostics() -> MgbaMemoryDiagnosticResult {
let (gba, _rom) = boot_mgba_suite();
run_mgba_sub_suite_diagnostics_from_gba(gba, 2, "Timing diagnostics")
}
pub fn run_mgba_timers_diagnostics() -> MgbaMemoryDiagnosticResult {
let (gba, _rom) = boot_mgba_suite();
run_mgba_sub_suite_diagnostics_from_gba(gba, 3, "Timers diagnostics")
}
pub fn run_mgba_timer_irq_diagnostics() -> MgbaMemoryDiagnosticResult {
let (gba, _rom) = boot_mgba_suite();
run_mgba_sub_suite_diagnostics_from_gba(gba, 4, "Timer IRQ diagnostics")
}
pub fn run_mgba_dma_diagnostics() -> MgbaMemoryDiagnosticResult {
let (gba, _rom) = boot_mgba_suite();
run_mgba_sub_suite_diagnostics_from_gba(gba, 9, "DMA diagnostics")
}
pub fn run_mgba_sio_read_diagnostics() -> MgbaMemoryDiagnosticResult {
let (gba, _rom) = boot_mgba_suite();
run_mgba_sub_suite_diagnostics_from_gba(gba, 10, "SIO read diagnostics")
}
pub fn run_mgba_sio_timing_diagnostics() -> MgbaMemoryDiagnosticResult {
let (gba, _rom) = boot_mgba_suite();
run_mgba_sub_suite_diagnostics_from_gba(gba, 11, "SIO timing diagnostics")
}
pub fn run_mgba_misc_edge_diagnostics() -> MgbaMemoryDiagnosticResult {
let (gba, _rom) = boot_mgba_suite();
run_mgba_sub_suite_diagnostics_from_gba(gba, 12, "Misc. edge diagnostics")
}
pub fn run_mgba_bios_math_diagnostics() -> MgbaMemoryDiagnosticResult {
let (gba, _rom) = boot_mgba_suite();
run_mgba_sub_suite_diagnostics_from_gba(gba, 8, "BIOS math diagnostics")
}
pub fn run_mgba_io_read_diagnostics_after_bios_intro() -> MgbaMemoryDiagnosticResult {
let (gba, _rom) = boot_mgba_suite_without_bios_intro_skip();
run_mgba_sub_suite_diagnostics_from_gba_with_boot_frames(
gba,
1,
"I/O read diagnostics after BIOS intro",
300,
)
}
fn run_mgba_memory_diagnostics_from_gba(gba: Gba) -> MgbaMemoryDiagnosticResult {
run_mgba_sub_suite_diagnostics_from_gba(gba, 0, "mgba Memory diagnostics")
}
fn run_mgba_sub_suite_diagnostics_from_gba(
gba: Gba,
suite_index: usize,
label: &str,
) -> MgbaMemoryDiagnosticResult {
run_mgba_sub_suite_diagnostics_from_gba_with_boot_frames(gba, suite_index, label, 10)
}
fn run_mgba_sub_suite_diagnostics_from_gba_with_boot_frames(
mut gba: Gba,
suite_index: usize,
label: &str,
boot_frames: u32,
) -> MgbaMemoryDiagnosticResult {
let mut cycles: u64 = 0;
for _ in 0..boot_frames {
assert!(
run_until_frame_ready(&mut gba, &mut cycles),
"{label}: timed out during initial boot"
);
gba.clear_ready_to_render();
}
for _ in 0..suite_index {
press_button(&mut gba, &mut cycles, BTN_DOWN);
}
press_button(&mut gba, &mut cycles, BTN_A);
const STABLE_FRAMES: u32 = 30;
const MAX_SUITE_FRAMES: u32 = 2000;
assert!(
run_until_frame_ready(&mut gba, &mut cycles),
"{label}: timed out getting initial frame"
);
gba.clear_ready_to_render();
let initial_crc = gba.screen_crc32();
let mut prev_crc: u32 = initial_crc;
let mut stable_count: u32 = 1;
let mut saw_change_from_initial = false;
for frame in 1..MAX_SUITE_FRAMES {
assert!(
run_until_frame_ready(&mut gba, &mut cycles),
"{label}: timed out at frame {frame}"
);
gba.clear_ready_to_render();
let crc = gba.screen_crc32();
if !saw_change_from_initial {
if crc != initial_crc {
saw_change_from_initial = true;
prev_crc = crc;
stable_count = 1;
}
} else if crc == prev_crc {
stable_count += 1;
if stable_count >= STABLE_FRAMES {
break;
}
} else {
prev_crc = crc;
stable_count = 1;
}
}
match suite_index {
1 => mgba_io_read_diagnostic_from_gba(&gba),
2 => mgba_timing_diagnostic_from_gba(&gba),
3 => mgba_timers_diagnostic_from_gba(&gba),
4 => mgba_timer_irq_diagnostic_from_gba(&gba),
8 => mgba_bios_math_diagnostic_from_gba(&gba),
9 => mgba_dma_diagnostic_from_gba(&gba),
10 => mgba_sio_read_diagnostic_from_gba(&gba),
11 => mgba_sio_timing_diagnostic_from_gba(&gba),
12 => mgba_misc_edge_diagnostic_from_gba(&gba),
_ => mgba_memory_diagnostic_from_gba(&gba),
}
}
pub fn run_mgba_memory_diagnostics_with_bios_path(
bios_path: Option<&Path>,
) -> Result<Option<MgbaMemoryDiagnosticResult>, String> {
let Some(bios_path) = bios_path else {
return Ok(None);
};
let bios = std::fs::read(bios_path)
.map_err(|e| format!("failed to read GBA BIOS image {}: {e}", bios_path.display()))?;
if bios.len() != crate::gba::bus::memory::BIOS_SIZE {
return Err(format!(
"GBA BIOS image {} has invalid size: expected {} bytes, found {}",
bios_path.display(),
crate::gba::bus::memory::BIOS_SIZE,
bios.len()
));
}
let (gba, _rom) = boot_mgba_suite_with_bios(&bios);
Ok(Some(run_mgba_memory_diagnostics_from_gba(gba)))
}
pub fn run_mgba_memory_diagnostics_with_proprietary_bios()
-> Result<Option<MgbaMemoryDiagnosticResult>, String> {
let Some(path) = std::env::var_os(MGBA_MEMORY_PROPRIETARY_BIOS_ENV) else {
return Ok(None);
};
let path = PathBuf::from(path);
run_mgba_memory_diagnostics_with_bios_path(Some(&path))
}
pub fn parse_mgba_memory_sram_log(bytes: &[u8]) -> MgbaMemoryLog {
let raw_log = extract_mgba_log_text(bytes);
let (passed_count, total_count) = parse_mgba_score(&raw_log);
let failures = raw_log
.lines()
.filter(|line| line.to_ascii_lowercase().contains("fail"))
.map(parse_mgba_failure_line)
.collect();
MgbaMemoryLog {
raw_log,
passed_count,
total_count,
failures,
}
}
fn parse_mgba_sub_suite_log(
score_bytes: &[u8],
failure_bytes: &[u8],
suite_name: &str,
) -> MgbaMemoryLog {
let score_log = extract_mgba_debug_suite_log(score_bytes, suite_name);
let failure_log = extract_ascii_segments(failure_bytes)
.into_iter()
.filter(|segment| segment.to_ascii_lowercase().contains("fail"))
.collect::<Vec<_>>()
.join("\n");
let raw_log = [score_log.as_str(), failure_log.as_str()]
.into_iter()
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
.join("\n");
let (passed_count, total_count) = parse_mgba_score(&score_log);
let failures = failure_log
.lines()
.filter(|line| line.to_ascii_lowercase().contains("fail"))
.map(parse_mgba_failure_line)
.collect();
MgbaMemoryLog {
raw_log,
passed_count,
total_count,
failures,
}
}
fn extract_mgba_log_text(bytes: &[u8]) -> String {
extract_ascii_segments(bytes)
.into_iter()
.find(|segment| segment.contains("Memory") && parse_mgba_score(segment).0.is_some())
.unwrap_or_default()
}
fn extract_mgba_debug_suite_log(bytes: &[u8], suite_name: &str) -> String {
let text = String::from_utf8_lossy(bytes);
let Some(begin) = text.find(&format!("BEGIN: {suite_name}")) else {
return String::new();
};
let suffix = &text[begin..];
let Some(end) = suffix.find("END: ") else {
return suffix.to_string();
};
let end_suffix = &suffix[end..];
let score_end = end_suffix
.find(|ch: char| {
!(ch.is_ascii_digit()
|| ch == '/'
|| ch == 'E'
|| ch == 'N'
|| ch == 'D'
|| ch == ':'
|| ch == ' ')
})
.unwrap_or(end_suffix.len());
suffix[..end + score_end].to_string()
}
fn extract_ascii_segments(bytes: &[u8]) -> Vec<String> {
let mut segments = Vec::new();
let mut current = Vec::new();
for &byte in bytes {
if byte.is_ascii_graphic() || matches!(byte, b' ' | b'\n' | b'\r' | b'\t') {
current.push(byte);
} else if !current.is_empty() {
segments.push(std::mem::take(&mut current));
}
}
if !current.is_empty() {
segments.push(current);
}
segments
.into_iter()
.map(|segment| {
String::from_utf8_lossy(&segment)
.trim_matches(['\r', '\n', '\t', ' '])
.to_string()
})
.filter(|segment| !segment.is_empty())
.collect()
}
fn parse_mgba_score(raw_log: &str) -> (Option<u32>, Option<u32>) {
if let Some((_, suffix)) = raw_log.rsplit_once("END:") {
for token in suffix.split(|ch: char| !(ch.is_ascii_digit() || ch == '/')) {
if let Some((passed, total)) = token.split_once('/') {
let Ok(passed) = passed.parse::<u32>() else {
continue;
};
let Ok(total) = total.parse::<u32>() else {
continue;
};
return (Some(passed), Some(total));
}
}
}
for token in raw_log.split(|ch: char| !(ch.is_ascii_digit() || ch == '/')) {
if let Some((passed, total)) = token.split_once('/') {
let Ok(passed) = passed.parse::<u32>() else {
continue;
};
let Ok(total) = total.parse::<u32>() else {
continue;
};
return (Some(passed), Some(total));
}
}
(None, None)
}
fn parse_mgba_failure_line(line: &str) -> MgbaMemoryFailure {
if let Some((test_name, detail)) = line.split_once(':') {
MgbaMemoryFailure {
test_name: test_name.trim().to_string(),
detail: detail.trim().to_string(),
}
} else {
MgbaMemoryFailure {
test_name: line.trim().to_string(),
detail: String::new(),
}
}
}
pub fn boot_mgba_suite() -> (Gba, Vec<u8>) {
boot_mgba_suite_with_bios(EMBEDDED_BIOS)
}
fn boot_mgba_suite_with_bios(bios: &[u8]) -> (Gba, Vec<u8>) {
boot_mgba_suite_with_bios_intro_skip(bios, true)
}
fn boot_mgba_suite_without_bios_intro_skip() -> (Gba, Vec<u8>) {
boot_mgba_suite_with_bios_intro_skip(EMBEDDED_BIOS, false)
}
fn boot_mgba_suite_with_bios_intro_skip(bios: &[u8], skip_intro: bool) -> (Gba, Vec<u8>) {
let rom_path = Suite::Mgba.rom_path();
let rom = std::fs::read(&rom_path).unwrap_or_else(|e| {
panic!("failed to read mgba suite ROM {}: {e}", rom_path.display());
});
let mut gba = Gba::new(AppContext::default());
gba.bus_mut().load_bios(bios);
if skip_intro {
gba.bus_mut().write8(0x03007FFC, 1); }
gba.load_rom(&rom, rom_path.to_str().unwrap_or("mgba-emu-suite"))
.unwrap_or_else(|e| {
panic!("failed to load mgba suite ROM {}: {e}", rom_path.display());
});
(gba, rom)
}
pub fn run_mgba_suite() -> MgbaSuiteResult {
let (mut gba, _rom) = boot_mgba_suite();
let mut cycles: u64 = 0;
let mut suite_crcs: Vec<u32> = Vec::with_capacity(MGBA_SUITE_COUNT);
for _ in 0..10 {
assert!(
run_until_frame_ready(&mut gba, &mut cycles),
"mgba suite: timed out during initial boot"
);
gba.clear_ready_to_render();
}
#[allow(clippy::needless_range_loop)]
for suite_idx in 0..MGBA_SUITE_COUNT {
if suite_idx > 0 {
press_button(&mut gba, &mut cycles, BTN_DOWN);
}
let menu_crc = gba.screen_crc32();
press_button(&mut gba, &mut cycles, BTN_A);
const STABLE_FRAMES: u32 = 30;
const MAX_SUITE_FRAMES: u32 = 2000;
assert!(
run_until_frame_ready(&mut gba, &mut cycles),
"mgba suite '{}': timed out getting initial frame",
MGBA_SUITE_KEYS[suite_idx]
);
gba.clear_ready_to_render();
let initial_crc = gba.screen_crc32();
let mut prev_crc: u32 = initial_crc;
let mut stable_count: u32 = 1;
let mut saw_change_from_initial = false;
for frame in 1..MAX_SUITE_FRAMES {
assert!(
run_until_frame_ready(&mut gba, &mut cycles),
"mgba suite '{}': timed out at frame {frame}",
MGBA_SUITE_KEYS[suite_idx]
);
gba.clear_ready_to_render();
let crc = gba.screen_crc32();
if !saw_change_from_initial {
if crc != initial_crc {
saw_change_from_initial = true;
prev_crc = crc;
stable_count = 1;
}
} else {
if crc == prev_crc {
stable_count += 1;
if stable_count >= STABLE_FRAMES {
break;
}
} else {
prev_crc = crc;
stable_count = 1;
}
}
}
let crc = gba.screen_crc32();
maybe_write_mgba_png(&gba, suite_idx, crc);
suite_crcs.push(crc);
const MAX_B_RETRIES: u32 = 20;
for attempt in 0..MAX_B_RETRIES {
press_button(&mut gba, &mut cycles, BTN_B);
for _ in 0..5 {
assert!(
run_until_frame_ready(&mut gba, &mut cycles),
"mgba suite: timed out returning to menu after '{}' (attempt {attempt})",
MGBA_SUITE_KEYS[suite_idx]
);
gba.clear_ready_to_render();
}
let current_crc = gba.screen_crc32();
if current_crc == menu_crc {
break;
}
}
}
MgbaSuiteResult { suite_crcs, cycles }
}
fn press_button(gba: &mut Gba, cycles: &mut u64, button: u8) {
gba.set_joypad_button_states(1, button);
assert!(
run_until_frame_ready(gba, cycles),
"mgba suite: timed out during button press (0x{button:02X})"
);
gba.clear_ready_to_render();
gba.set_joypad_button_states(1, 0);
assert!(
run_until_frame_ready(gba, cycles),
"mgba suite: timed out during button release"
);
gba.clear_ready_to_render();
}
fn maybe_write_mgba_png(gba: &Gba, suite_index: usize, crc: u32) {
if std::env::var_os("NESER_CAPTURE_SCREEN").is_none() {
return;
}
let key = MGBA_SUITE_KEYS[suite_index];
let file_name = format!("{key}_crc_{crc:08X}.png");
let path = PathBuf::from("target/gba_suite_checkpoints").join(file_name);
let rgb = gba.screen_snapshot();
crate::platform::png_utils::write_rgb_png(&path, &rgb, Gba::SCREEN_WIDTH, Gba::SCREEN_HEIGHT);
println!(
"[mgba-suite-capture] saved {} (suite={key}, crc=0x{crc:08X})",
path.display()
);
}
const BTN_RIGHT: u8 = 0x80;
pub const VIDEO_TEST_NAMES: [&str; 7] = [
"Basic Mode 3",
"Basic Mode 4",
"Degenerate OBJ transforms",
"Layer toggle",
"Layer toggle 2",
"OAM Update Delay",
"Window offscreen reset",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VideoTestResult {
pub actual_crc: u32,
pub expected_crc: u32,
pub matches: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MgbaVideoResult {
pub tests: Vec<VideoTestResult>,
pub cycles: u64,
}
pub fn run_mgba_video_tests() -> MgbaVideoResult {
let (mut gba, _rom) = boot_mgba_suite();
let mut cycles: u64 = 0;
for _ in 0..10 {
assert!(
run_until_frame_ready(&mut gba, &mut cycles),
"video: timed out during initial boot"
);
gba.clear_ready_to_render();
}
for _ in 0..13 {
press_button(&mut gba, &mut cycles, BTN_DOWN);
}
press_button(&mut gba, &mut cycles, BTN_A);
wait_for_stability(&mut gba, &mut cycles, "video sub-menu");
let mut tests: Vec<VideoTestResult> = Vec::with_capacity(7);
for (test_idx, test_name) in VIDEO_TEST_NAMES.iter().enumerate() {
if test_idx > 0 {
press_button(&mut gba, &mut cycles, BTN_DOWN);
}
let menu_crc = gba.screen_crc32();
press_button(&mut gba, &mut cycles, BTN_A);
wait_for_stability(&mut gba, &mut cycles, test_name);
press_button(&mut gba, &mut cycles, BTN_START);
wait_for_stability(&mut gba, &mut cycles, test_name);
let actual_crc = gba.screen_crc32();
maybe_write_video_png(&gba, test_idx, "actual", actual_crc);
press_button(&mut gba, &mut cycles, BTN_RIGHT);
wait_for_stability(&mut gba, &mut cycles, test_name);
let expected_crc = gba.screen_crc32();
maybe_write_video_png(&gba, test_idx, "expected", expected_crc);
tests.push(VideoTestResult {
actual_crc,
expected_crc,
matches: actual_crc == expected_crc,
});
const MAX_B_RETRIES: u32 = 20;
let mut returned = false;
for attempt in 0..MAX_B_RETRIES {
press_button(&mut gba, &mut cycles, BTN_B);
for _ in 0..5 {
assert!(
run_until_frame_ready(&mut gba, &mut cycles),
"video: timed out returning to sub-menu after test {} (attempt {attempt})",
test_idx
);
gba.clear_ready_to_render();
}
if gba.screen_crc32() == menu_crc {
returned = true;
break;
}
}
assert!(
returned,
"video: failed to return to sub-menu after test {test_idx} ({test_name}) after {MAX_B_RETRIES} retries"
);
}
MgbaVideoResult { tests, cycles }
}
fn wait_for_stability(gba: &mut Gba, cycles: &mut u64, label: &str) {
const STABLE_FRAMES: u32 = 10;
const MAX_FRAMES: u32 = 300;
assert!(
run_until_frame_ready(gba, cycles),
"video '{label}': timed out getting initial frame"
);
gba.clear_ready_to_render();
let initial_crc = gba.screen_crc32();
let mut prev_crc = initial_crc;
let mut stable_count: u32 = 1;
let mut saw_change = false;
for frame in 1..MAX_FRAMES {
assert!(
run_until_frame_ready(gba, cycles),
"video '{label}': timed out at frame {frame}"
);
gba.clear_ready_to_render();
let crc = gba.screen_crc32();
if !saw_change {
if crc != initial_crc {
saw_change = true;
prev_crc = crc;
stable_count = 1;
} else {
stable_count += 1;
if stable_count >= STABLE_FRAMES {
return;
}
}
} else if crc == prev_crc {
stable_count += 1;
if stable_count >= STABLE_FRAMES {
return;
}
} else {
prev_crc = crc;
stable_count = 1;
}
}
assert!(
!saw_change,
"video '{label}': screen changed but never stabilised within {MAX_FRAMES} frames"
);
}
fn maybe_write_video_png(gba: &Gba, test_index: usize, view: &str, crc: u32) {
if std::env::var_os("NESER_CAPTURE_SCREEN").is_none() {
return;
}
let name = VIDEO_TEST_NAMES[test_index]
.to_lowercase()
.replace(' ', "_");
let file_name = format!("video_{name}_{view}_crc_{crc:08X}.png");
let path = PathBuf::from("target/gba_suite_checkpoints").join(file_name);
let rgb = gba.screen_snapshot();
crate::platform::png_utils::write_rgb_png(&path, &rgb, Gba::SCREEN_WIDTH, Gba::SCREEN_HEIGHT);
println!(
"[video-capture] saved {} (test={}, view={view}, crc=0x{crc:08X})",
path.display(),
VIDEO_TEST_NAMES[test_index]
);
}
fn maybe_write_armwrestler_png(gba: &Gba, page_index: usize, crc: u32) {
if std::env::var_os("NESER_CAPTURE_SCREEN").is_none() {
return;
}
let file_name = format!("armwrestler_page{page_index}_crc_{crc:08X}.png");
let path = PathBuf::from("target/gba_suite_checkpoints").join(file_name);
let rgb = gba.screen_snapshot();
crate::platform::png_utils::write_rgb_png(&path, &rgb, Gba::SCREEN_WIDTH, Gba::SCREEN_HEIGHT);
println!(
"[armwrestler-capture] saved {} (page={page_index}, crc=0x{crc:08X})",
path.display()
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::platform::app_context::AppContext;
#[test]
fn arm_branch_to_self_detects_idle_opcode() {
assert!(is_arm_branch_to_self(ARM_BRANCH_SELF_OPCODE, 0x0800_1000));
}
#[test]
fn arm_branch_to_self_rejects_bl() {
assert!(!is_arm_branch_to_self(0xEBFF_FFFE, 0x0800_1000));
}
#[test]
fn thumb_branch_to_self_detects_idle_opcode() {
assert!(is_thumb_branch_to_self(THUMB_BRANCH_SELF_OPCODE));
}
#[test]
fn thumb_branch_to_self_rejects_forward_branch() {
assert!(!is_thumb_branch_to_self(0xE000));
}
#[test]
fn thumb_branch_to_self_rejects_conditional_branch() {
assert!(!is_thumb_branch_to_self(0xD0FE));
}
#[test]
fn non_idle_exit_is_not_counted_as_pass_even_if_index_is_zero() {
let mut gba = Gba::new(AppContext::default());
let result = result_from_register(
&mut gba,
Suite::Arm,
12_345,
0x0800_0000,
ExitReason::CycleLimitReached,
);
assert!(!result.passed);
assert_eq!(result.failing_index, 0);
}
#[test]
fn exception_vector_branch_to_self_is_not_counted_as_pass() {
assert!(is_bios_exception_vector(0x18));
assert!(is_bios_exception_vector(0x04));
assert!(!is_bios_exception_vector(0x08));
let mut gba = Gba::new(AppContext::default());
let result = result_from_register(
&mut gba,
Suite::Arm,
42,
0x18,
ExitReason::ExceptionVectorTrap,
);
assert!(!result.passed);
}
#[test]
fn capture_output_path_uses_expected_location_and_name() {
let path = capture_output_path(Suite::PpuStripes, 0x8C90_CEE0);
assert_eq!(
path,
PathBuf::from("target/gba_suite_checkpoints/ppu_stripes_crc_8C90CEE0.png")
);
}
#[test]
fn suite_capture_stem_is_stable() {
assert_eq!(Suite::Arm.capture_stem(), "arm");
assert_eq!(Suite::Thumb.capture_stem(), "thumb");
assert_eq!(Suite::Nes.capture_stem(), "nes");
assert_eq!(Suite::Memory.capture_stem(), "memory");
assert_eq!(Suite::SaveNone.capture_stem(), "save_none");
assert_eq!(Suite::SaveSram.capture_stem(), "save_sram");
assert_eq!(Suite::SaveFlash64.capture_stem(), "save_flash64");
assert_eq!(Suite::SaveFlash128.capture_stem(), "save_flash128");
assert_eq!(Suite::PpuHello.capture_stem(), "ppu_hello");
assert_eq!(Suite::PpuShades.capture_stem(), "ppu_shades");
assert_eq!(Suite::PpuStripes.capture_stem(), "ppu_stripes");
assert_eq!(
Suite::FuzzArmDataProcessing.capture_stem(),
"fuzzarm_data_processing"
);
assert_eq!(Suite::FuzzArmAny.capture_stem(), "fuzzarm_any");
assert_eq!(
Suite::FuzzThumbDataProcessing.capture_stem(),
"fuzzthumb_data_processing"
);
assert_eq!(Suite::FuzzThumbAny.capture_stem(), "fuzzthumb_any");
assert_eq!(Suite::FuzzArmMixed.capture_stem(), "fuzzarm_mixed");
assert_eq!(Suite::ArmWrestler.capture_stem(), "armwrestler");
assert_eq!(Suite::Mgba.capture_stem(), "mgba_suite");
}
#[test]
fn mgba_memory_sram_log_parser_extracts_score_and_failures() {
let bytes = b"Memory: 1379/1552\nCPU ROM OOB: FAIL expected=00000000 actual=FFFFFFFF\nDMA3 SRAM mirror: fail source mismatch\0\xFF\xFF";
let log = parse_mgba_memory_sram_log(bytes);
assert_eq!(log.passed_count, Some(1379));
assert_eq!(log.total_count, Some(1552));
assert_eq!(
log.raw_log,
"Memory: 1379/1552\nCPU ROM OOB: FAIL expected=00000000 actual=FFFFFFFF\nDMA3 SRAM mirror: fail source mismatch"
);
assert_eq!(
log.failures,
vec![
MgbaMemoryFailure {
test_name: "CPU ROM OOB".to_string(),
detail: "FAIL expected=00000000 actual=FFFFFFFF".to_string(),
},
MgbaMemoryFailure {
test_name: "DMA3 SRAM mirror".to_string(),
detail: "fail source mismatch".to_string(),
},
]
);
}
#[test]
fn mgba_sub_suite_log_parser_combines_debug_score_and_sram_failures() {
let debug = b"BEGIN: I/O read testsPASS: BG0CNTFAIL: BG0HOFSEND: 129/130";
let sram = b"Game Boy Advance Test Suite\n===\0BG0HOFS: Got 0xFFFF vs 0xDEAD: FAIL\0";
let log = parse_mgba_sub_suite_log(debug, sram, "I/O read tests");
assert_eq!(log.passed_count, Some(129));
assert_eq!(log.total_count, Some(130));
assert_eq!(
log.failures,
vec![MgbaMemoryFailure {
test_name: "BG0HOFS".to_string(),
detail: "Got 0xFFFF vs 0xDEAD: FAIL".to_string(),
}]
);
}
#[test]
fn mgba_sub_suite_log_parser_prefers_end_score_over_test_names_with_slashes() {
let debug = b"BEGIN: BIOS math testsMath test: Div 00000000/00000000END: 452/615";
let sram = b"r0: Got 00000000 vs 00000001: FAIL\0";
let log = parse_mgba_sub_suite_log(debug, sram, "BIOS math tests");
assert_eq!(log.passed_count, Some(452));
assert_eq!(log.total_count, Some(615));
}
#[test]
fn mgba_memory_diagnostic_from_sram_includes_crc_and_log() {
let bytes = b"Memory: 1436/1552\nBIOS OOB: fail open bus\0";
let result = mgba_memory_diagnostic_from_sram(0x2298_4983, bytes);
assert_eq!(result.framebuffer_crc32, 0x2298_4983);
assert_eq!(result.passed_count, Some(1436));
assert_eq!(result.total_count, Some(1552));
assert_eq!(result.raw_log, "Memory: 1436/1552\nBIOS OOB: fail open bus");
assert_eq!(
result.failures,
vec![MgbaMemoryFailure {
test_name: "BIOS OOB".to_string(),
detail: "fail open bus".to_string(),
}]
);
}
#[test]
fn mgba_memory_diagnostic_from_gba_uses_screen_crc_and_mgba_log() {
let mut gba = Gba::new(AppContext::default());
gba.bus_mut().write16(0x04FF_F780, 0xC0DE);
for (offset, byte) in b"Memory: 1/2\nROM OOB: fail value\0".iter().enumerate() {
gba.bus_mut().write8(0x04FF_F600 + offset as u32, *byte);
}
gba.bus_mut().write16(0x04FF_F700, 0x0100);
let expected_crc = gba.screen_crc32();
let result = mgba_memory_diagnostic_from_gba(&gba);
assert_eq!(result.framebuffer_crc32, expected_crc);
assert_eq!(result.passed_count, Some(1));
assert_eq!(result.total_count, Some(2));
assert_eq!(result.raw_log, "Memory: 1/2\nROM OOB: fail value");
}
}