248 lines
6.9 KiB
Rust
248 lines
6.9 KiB
Rust
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)");
|
|
}
|
|
}
|