Files
snespatcher/src/rom.rs
T

248 lines
6.9 KiB
Rust
Raw Normal View History

2026-08-26 21:33:44 -04:00
use std::fs;
use std::path::{Path, PathBuf};
pub struct RomInfo {
path: PathBuf,
file_size: usize,
has_copier_header: bool,
header: Option<SnesHeader>,
}
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<Self, String> {
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<SnesHeader> {
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<String> {
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)");
}
}