add: refactored everything
This commit is contained in:
+247
@@ -0,0 +1,247 @@
|
||||
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)");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user