use std::fs; use std::path::{Path, PathBuf}; pub struct RomInfo { path: PathBuf, file_size: usize, has_copier_header: bool, header: Option, } struct SnesHeader { title: String, mapping: &'static str, region: &'static str, version: u8, } #[derive(Clone, Copy)] enum Mapping { Low, High, ExtendedHigh, } impl RomInfo { pub fn read(path: &Path) -> Result { let bytes = fs::read(path).map_err(|error| format!("could not read the ROM ({error})"))?; let has_copier_header = bytes.len() >= 512 && bytes.len() % 0x8000 == 512; let rom = if has_copier_header { &bytes[512..] } else { &bytes[..] }; Ok(Self { path: path.to_path_buf(), file_size: bytes.len(), has_copier_header, header: find_header(rom), }) } pub fn summary(&self) -> String { let file_name = self .path .file_name() .and_then(|name| name.to_str()) .unwrap_or("Selected file"); let location = self .path .parent() .map(|parent| parent.display().to_string()) .filter(|parent| !parent.is_empty()) .unwrap_or_else(|| "Unknown".to_string()); let copier_header = if self.has_copier_header { "Yes" } else { "No" }; let mut details = vec![ format!("File: {file_name}"), format!("Location: {location}"), format!("Size: {}", format_file_size(self.file_size)), format!("Copier header: {copier_header}"), ]; if let Some(header) = &self.header { details.splice( 0..0, [ format!("Game title: {}", header.title), format!("Mapping: {}", header.mapping), format!("Region: {}", header.region), format!("Revision: 1.{}", header.version), String::new(), ], ); } else { details.splice( 0..0, [ "Game title: Not found".to_string(), "SNES header: Not recognized".to_string(), String::new(), ], ); } details.join("\n") } } fn find_header(rom: &[u8]) -> Option { let candidates = [ (0x7fc0, Mapping::Low), (0xffc0, Mapping::High), (0x40ffc0, Mapping::ExtendedHigh), ]; candidates .iter() .filter_map(|(offset, mapping)| parse_header(rom, *offset, *mapping)) .max_by_key(|(score, _)| *score) .filter(|(score, _)| *score >= 5) .map(|(_, header)| header) } fn parse_header(rom: &[u8], offset: usize, mapping: Mapping) -> Option<(i32, SnesHeader)> { let bytes = rom.get(offset..offset + 0x40)?; let title = parse_title(&bytes[..21])?; let map_mode = bytes[0x15]; let checksum_complement = u16::from_le_bytes([bytes[0x1c], bytes[0x1d]]); let checksum = u16::from_le_bytes([bytes[0x1e], bytes[0x1f]]); let reset_vector = u16::from_le_bytes([bytes[0x3c], bytes[0x3d]]); let mut score = 0; if mapping_matches(map_mode, mapping) { score += 3; } if checksum != 0 && checksum != u16::MAX && checksum ^ checksum_complement == u16::MAX { score += 5; } if reset_vector >= 0x8000 { score += 3; } Some(( score, SnesHeader { title, mapping: mapping_name(mapping), region: region_name(bytes[0x19]), version: bytes[0x1b], }, )) } fn parse_title(bytes: &[u8]) -> Option { if bytes.iter().any(|byte| !matches!(*byte, 0 | 0x20..=0x7e)) { return None; } let end = bytes .iter() .rposition(|byte| *byte != 0 && *byte != b' ') .map(|position| position + 1)?; Some(String::from_utf8_lossy(&bytes[..end]).to_string()) } fn mapping_matches(map_mode: u8, mapping: Mapping) -> bool { match mapping { Mapping::Low => matches!(map_mode & 0x0f, 0 | 2 | 3), Mapping::High => map_mode & 0x0f == 1, Mapping::ExtendedHigh => map_mode & 0x0f == 5, } } fn mapping_name(mapping: Mapping) -> &'static str { match mapping { Mapping::Low => "LoROM", Mapping::High => "HiROM", Mapping::ExtendedHigh => "ExHiROM", } } fn region_name(region: u8) -> &'static str { match region { 0 => "Japan", 1 => "USA / Canada", 2 => "Europe / Oceania / Asia", 3 => "Sweden", 4 => "Finland", 5 => "Denmark", 6 => "France", 7 => "Netherlands", 8 => "Spain", 9 => "Germany / Austria / Switzerland", 10 => "Italy", 11 => "Hong Kong / China", 12 => "Indonesia", 13 => "South Korea", _ => "Unknown", } } fn format_file_size(bytes: usize) -> String { let exact = format_integer(bytes); if bytes < 1024 { return format!("{exact} bytes"); } const UNITS: [&str; 4] = ["KiB", "MiB", "GiB", "TiB"]; let mut size = bytes as f64 / 1024.0; let mut unit = UNITS[0]; for candidate in &UNITS[1..] { if size < 1024.0 { break; } size /= 1024.0; unit = candidate; } format!("{size:.2} {unit} ({exact} bytes)") } fn format_integer(value: usize) -> String { let digits = value.to_string(); let mut result = String::with_capacity(digits.len() + digits.len() / 3); for (index, character) in digits.chars().enumerate() { if index > 0 && (digits.len() - index).is_multiple_of(3) { result.push(','); } result.push(character); } result } #[cfg(test)] mod tests { use super::{find_header, format_file_size}; #[test] fn reads_a_lorom_header() { let mut rom = vec![0; 0x8000]; let header = &mut rom[0x7fc0..0x8000]; header[..21].copy_from_slice(b"SUPER MARIOWORLD "); header[0x15] = 0x20; header[0x19] = 1; header[0x1b] = 2; header[0x1c..0x1e].copy_from_slice(&0x4321_u16.to_le_bytes()); header[0x1e..0x20].copy_from_slice(&0xbcde_u16.to_le_bytes()); header[0x3c..0x3e].copy_from_slice(&0x8000_u16.to_le_bytes()); let header = find_header(&rom).expect("the header should be recognized"); assert_eq!(header.title, "SUPER MARIOWORLD"); assert_eq!(header.mapping, "LoROM"); assert_eq!(header.region, "USA / Canada"); assert_eq!(header.version, 2); } #[test] fn rejects_data_without_a_snes_header() { assert!(find_header(&vec![0; 0x10000]).is_none()); } #[test] fn formats_file_sizes_for_people() { assert_eq!(format_file_size(512), "512 bytes"); assert_eq!(format_file_size(1_048_576), "1.00 MiB (1,048,576 bytes)"); } }