use std::error::Error;
use log::error;
use serde::Serialize;
use crate::error::RomAnalyzerError;
use crate::region::{Region, check_region_mismatch};
const MAP_MODE_OFFSET: usize = 0x15;
const LOROM_MAP_MODES: &[u8] = &[0x20, 0x30, 0x25, 0x35];
const HIROM_MAP_MODES: &[u8] = &[0x21, 0x31, 0x22, 0x32];
#[derive(Debug, PartialEq, Clone, Serialize)]
pub struct SnesAnalysis {
pub source_name: String,
pub region: Region,
pub region_string: String,
pub region_mismatch: bool,
pub region_code: u8,
pub game_title: String,
pub mapping_type: String,
}
impl SnesAnalysis {
pub fn print(&self) -> String {
format!(
"{}\n\
System: Super Nintendo (SNES)\n\
Game Title: {}\n\
Mapping: {}\n\
Region Code: 0x{:02X}\n\
Region: {}",
self.source_name, self.game_title, self.mapping_type, self.region_code, self.region
)
}
}
pub fn map_region(code: u8) -> (&'static str, Region) {
match code {
0x00 => ("Japan (NTSC)", Region::JAPAN),
0x01 => ("USA / Canada (NTSC)", Region::USA),
0x02 => (
"Europe / Oceania / Asia (PAL)",
Region::EUROPE | Region::ASIA,
),
0x03 => ("Sweden / Scandinavia (PAL)", Region::EUROPE),
0x04 => ("Finland (PAL)", Region::EUROPE),
0x05 => ("Denmark (PAL)", Region::EUROPE),
0x06 => ("France (PAL)", Region::EUROPE),
0x07 => ("Netherlands (PAL)", Region::EUROPE),
0x08 => ("Spain (PAL)", Region::EUROPE),
0x09 => ("Germany (PAL)", Region::EUROPE),
0x0A => ("Italy (PAL)", Region::EUROPE),
0x0B => ("China (PAL)", Region::CHINA),
0x0C => ("Indonesia (PAL)", Region::EUROPE | Region::ASIA),
0x0D => ("South Korea (NTSC)", Region::KOREA),
0x0E => (
"Common / International",
Region::USA | Region::EUROPE | Region::JAPAN | Region::ASIA,
),
0x0F => ("Canada (NTSC)", Region::USA),
0x10 => ("Brazil (NTSC)", Region::USA),
0x11 => ("Australia (PAL)", Region::EUROPE),
0x12 => ("Other (Variation 1)", Region::UNKNOWN),
0x13 => ("Other (Variation 2)", Region::UNKNOWN),
0x14 => ("Other (Variation 3)", Region::UNKNOWN),
_ => ("Unknown", Region::UNKNOWN),
}
}
pub fn validate_snes_checksum(rom_data: &[u8], header_offset: usize) -> bool {
if header_offset + 0x20 > rom_data.len() {
return false;
}
let complement_bytes: [u8; 2] =
match rom_data[header_offset + 0x1C..header_offset + 0x1E].try_into() {
Ok(b) => b,
Err(_) => return false, };
let checksum_bytes: [u8; 2] =
match rom_data[header_offset + 0x1E..header_offset + 0x20].try_into() {
Ok(b) => b,
Err(_) => return false, };
let complement = u16::from_le_bytes(complement_bytes);
let checksum = u16::from_le_bytes(checksum_bytes);
(checksum as u32 + complement as u32) == 0xFFFF
}
pub fn analyze_snes_data(data: &[u8], source_name: &str) -> Result<SnesAnalysis, Box<dyn Error>> {
let file_size = data.len();
let mut header_offset = 0;
if file_size >= 512 && (file_size % 1024 == 512) {
header_offset = 512;
}
let lorom_header_start = 0x7FC0 + header_offset; let hirom_header_start = 0xFFC0 + header_offset;
let mapping_type: String;
let valid_header_offset: usize;
let lorom_checksum_valid = validate_snes_checksum(data, lorom_header_start);
let hirom_checksum_valid = validate_snes_checksum(data, hirom_header_start);
let lorom_map_mode_byte = if lorom_header_start + MAP_MODE_OFFSET < file_size {
Some(data[lorom_header_start + MAP_MODE_OFFSET])
} else {
None
};
let hirom_map_mode_byte = if hirom_header_start + MAP_MODE_OFFSET < file_size {
Some(data[hirom_header_start + MAP_MODE_OFFSET])
} else {
None
};
let is_lorom_map_mode = lorom_map_mode_byte.map_or(false, |b| LOROM_MAP_MODES.contains(&b));
let is_hirom_map_mode = hirom_map_mode_byte.map_or(false, |b| HIROM_MAP_MODES.contains(&b));
if hirom_checksum_valid && is_hirom_map_mode {
mapping_type = "HiROM".to_string();
valid_header_offset = hirom_header_start;
} else if lorom_checksum_valid && is_lorom_map_mode {
mapping_type = "LoROM".to_string();
valid_header_offset = lorom_header_start;
} else if hirom_checksum_valid {
mapping_type = "HiROM (Map Mode Unverified)".to_string();
valid_header_offset = hirom_header_start;
error!(
"[!] HiROM checksum valid for {}, but Map Mode byte (0x{:02X?}) is not a typical HiROM value. Falling back to HiROM.",
source_name, hirom_map_mode_byte
);
} else if lorom_checksum_valid {
mapping_type = "LoROM (Map Mode Unverified)".to_string();
valid_header_offset = lorom_header_start;
error!(
"[!] LoROM checksum valid for {}, but Map Mode byte (0x{:02X?}) is not a typical LoROM value. Falling back to LoROM.",
source_name, lorom_map_mode_byte
);
} else {
error!(
"[!] Checksum validation failed for {}. Attempting to read header from LoROM location ({:X}) as fallback.",
source_name, lorom_header_start
);
mapping_type = "LoROM (Unverified)".to_string();
valid_header_offset = lorom_header_start; }
if valid_header_offset + 0x20 > file_size {
return Err(Box::new(RomAnalyzerError::new(&format!(
"ROM data is too small or header is invalid. File size: {} bytes. Checked header at offset: {}. Required minimum size for header region: {}.",
file_size,
valid_header_offset,
valid_header_offset + 0x20
))));
}
let region_byte_offset = valid_header_offset + 0x19; let region_code = data[region_byte_offset];
let (region_name, region) = map_region(region_code);
let game_title = String::from_utf8_lossy(&data[valid_header_offset..valid_header_offset + 21])
.trim_matches(char::from(0)) .trim()
.to_string();
let region_mismatch = check_region_mismatch(source_name, region);
Ok(SnesAnalysis {
source_name: source_name.to_string(),
region,
region_string: region_name.to_string(),
region_mismatch,
region_code,
game_title,
mapping_type,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error;
fn generate_snes_header(
rom_size: usize,
copier_header_offset: usize,
region_code: u8,
is_hirom: bool,
title: &str,
map_mode_byte: Option<u8>,
) -> Vec<u8> {
let mut data = vec![0; rom_size];
let header_start = (if is_hirom { 0xFFC0 } else { 0x7FC0 }) + copier_header_offset;
if header_start + 0x20 > rom_size {
panic!(
"Provided ROM size {} is too small for SNES header at offset {} (needs at least {}).",
rom_size,
header_start,
header_start + 0x20
);
}
let mut title_bytes: Vec<u8> = title.as_bytes().to_vec();
title_bytes.truncate(21);
title_bytes.resize(21, b' ');
data[header_start..header_start + 21].copy_from_slice(&title_bytes);
data[header_start + 0x19] = region_code;
if let Some(map_mode) = map_mode_byte {
data[header_start + MAP_MODE_OFFSET] = map_mode;
}
let complement: u16 = 0x5555;
let checksum: u16 = 0xFFFF - complement;
data[header_start + 0x1C..header_start + 0x1E].copy_from_slice(&complement.to_le_bytes());
data[header_start + 0x1E..header_start + 0x20].copy_from_slice(&checksum.to_le_bytes());
data
}
#[test]
fn test_analyze_snes_data_lorom_japan() -> Result<(), Box<dyn Error>> {
let data = generate_snes_header(0x80000, 0, 0x00, false, "TEST GAME TITLE", None); let analysis = analyze_snes_data(&data, "test_lorom_jp.sfc")?;
assert_eq!(analysis.source_name, "test_lorom_jp.sfc");
assert_eq!(analysis.game_title, "TEST GAME TITLE");
assert_eq!(analysis.mapping_type, "LoROM (Map Mode Unverified)");
assert_eq!(analysis.region_code, 0x00);
assert_eq!(analysis.region, Region::JAPAN);
assert_eq!(analysis.region_string, "Japan (NTSC)");
Ok(())
}
#[test]
fn test_analyze_snes_data_hirom_usa() -> Result<(), Box<dyn Error>> {
let data = generate_snes_header(0x100000, 0, 0x01, true, "TEST GAME TITLE", None); let analysis = analyze_snes_data(&data, "test_hirom_us.sfc")?;
assert_eq!(analysis.source_name, "test_hirom_us.sfc");
assert_eq!(analysis.game_title, "TEST GAME TITLE");
assert_eq!(analysis.mapping_type, "HiROM (Map Mode Unverified)");
assert_eq!(analysis.region_code, 0x01);
assert_eq!(analysis.region, Region::USA);
assert_eq!(analysis.region_string, "USA / Canada (NTSC)");
Ok(())
}
#[test]
fn test_analyze_snes_data_lorom_europe_copier_header() -> Result<(), Box<dyn Error>> {
let data = generate_snes_header(0x80000 + 512, 512, 0x02, false, "TEST GAME TITLE", None); let analysis = analyze_snes_data(&data, "test_lorom_eur_copier.sfc")?;
assert_eq!(analysis.source_name, "test_lorom_eur_copier.sfc");
assert_eq!(analysis.game_title, "TEST GAME TITLE");
assert_eq!(analysis.mapping_type, "LoROM (Map Mode Unverified)"); assert_eq!(analysis.region_code, 0x02);
assert_eq!(analysis.region, Region::EUROPE | Region::ASIA);
assert_eq!(analysis.region_string, "Europe / Oceania / Asia (PAL)");
Ok(())
}
#[test]
fn test_analyze_snes_data_hirom_canada_copier_header() -> Result<(), Box<dyn Error>> {
let data = generate_snes_header(
0x100200,
512, 0x0F, true, "TEST GAME TITLE",
None,
);
let analysis = analyze_snes_data(&data, "test_hirom_can_copier.sfc")?;
assert_eq!(analysis.source_name, "test_hirom_can_copier.sfc");
assert_eq!(analysis.game_title, "TEST GAME TITLE");
assert_eq!(analysis.mapping_type, "HiROM (Map Mode Unverified)");
assert_eq!(analysis.region_code, 0x0F);
assert_eq!(analysis.region, Region::USA);
assert_eq!(analysis.region_string, "Canada (NTSC)");
Ok(())
}
#[test]
fn test_analyze_snes_data_unknown_region() -> Result<(), Box<dyn Error>> {
let data = generate_snes_header(0x80000, 0, 0xFF, false, "TEST GAME TITLE", None); let analysis = analyze_snes_data(&data, "test_lorom_unknown.sfc")?;
assert_eq!(analysis.source_name, "test_lorom_unknown.sfc");
assert_eq!(analysis.game_title, "TEST GAME TITLE");
assert_eq!(analysis.mapping_type, "LoROM (Map Mode Unverified)");
assert_eq!(analysis.region_code, 0xFF);
assert_eq!(analysis.region, Region::UNKNOWN);
assert_eq!(analysis.region_string, "Unknown");
Ok(())
}
#[test]
fn test_analyze_snes_data_invalid_checksum() -> Result<(), Box<dyn Error>> {
let mut data = generate_snes_header(
0x8000, 0,
0x01, false, "INVALID CHECKSUM", None,
);
let checksum_start = 0x7FC0 + 0x1C;
data[checksum_start..checksum_start + 4].copy_from_slice(&[0x00, 0x00, 0x00, 0x00]);
let analysis = analyze_snes_data(&data, "test_invalid_checksum.sfc")?;
assert_eq!(analysis.source_name, "test_invalid_checksum.sfc");
assert_eq!(analysis.game_title, "INVALID CHECKSUM");
assert_eq!(analysis.mapping_type, "LoROM (Unverified)"); assert_eq!(analysis.region_code, 0x01);
assert_eq!(analysis.region, Region::USA);
assert_eq!(analysis.region_string, "USA / Canada (NTSC)");
Ok(())
}
#[test]
fn test_analyze_snes_data_too_small() {
let data = vec![0; 0x1000]; let result = analyze_snes_data(&data, "too_small.sfc");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("too small or header is invalid")
);
}
#[test]
fn test_analyze_snes_data_hirom_checksum_map_mode_consistent() -> Result<(), Box<dyn Error>> {
let data =
generate_snes_header(0x100000, 0, 0x01, true, "TEST HIROM CONSISTENT", Some(0x21)); let analysis = analyze_snes_data(&data, "test_hirom_consistent.sfc")?;
assert_eq!(analysis.mapping_type, "HiROM");
assert_eq!(analysis.game_title, "TEST HIROM CONSISTENT");
Ok(())
}
#[test]
fn test_analyze_snes_data_lorom_checksum_map_mode_consistent() -> Result<(), Box<dyn Error>> {
let data =
generate_snes_header(0x80000, 0, 0x00, false, "TEST LOROM CONSISTENT", Some(0x20)); let analysis = analyze_snes_data(&data, "test_lorom_consistent.sfc")?;
assert_eq!(analysis.mapping_type, "LoROM");
assert_eq!(analysis.game_title, "TEST LOROM CONSISTENT");
Ok(())
}
#[test]
fn test_analyze_snes_data_hirom_checksum_map_mode_inconsistent() -> Result<(), Box<dyn Error>> {
let data = generate_snes_header(
0x100000,
0,
0x01,
true,
"TEST HIROM INCONSISTENT",
Some(0x20),
); let analysis = analyze_snes_data(&data, "test_hirom_inconsistent.sfc")?;
assert_eq!(analysis.mapping_type, "HiROM (Map Mode Unverified)");
assert_eq!(analysis.game_title, "TEST HIROM INCONSISTE");
Ok(())
}
#[test]
fn test_analyze_snes_data_lorom_checksum_map_mode_inconsistent() -> Result<(), Box<dyn Error>> {
let data = generate_snes_header(
0x80000,
0,
0x00,
false,
"TEST LOROM INCONSISTENT",
Some(0x21),
); let analysis = analyze_snes_data(&data, "test_lorom_inconsistent.sfc")?;
assert_eq!(analysis.mapping_type, "LoROM (Map Mode Unverified)");
assert_eq!(analysis.game_title, "TEST LOROM INCONSISTE");
Ok(())
}
#[test]
fn test_analyze_snes_data_no_valid_checksum_map_mode_consistent_hirom_only()
-> Result<(), Box<dyn Error>> {
let mut data = generate_snes_header(
0x100000,
0,
0x01,
true,
"TEST NO CHECKSUM HIROM MAP",
Some(0x21),
); let lorom_checksum_start = 0x7FC0 + 0x1C;
data[lorom_checksum_start..lorom_checksum_start + 4]
.copy_from_slice(&[0x00, 0x00, 0x00, 0x00]);
let hirom_checksum_start = 0xFFC0 + 0x1C;
data[hirom_checksum_start..hirom_checksum_start + 4]
.copy_from_slice(&[0x00, 0x00, 0x00, 0x00]);
let analysis = analyze_snes_data(&data, "test_no_checksum_hirom_map.sfc")?;
assert_eq!(analysis.mapping_type, "LoROM (Unverified)"); Ok(())
}
#[test]
fn test_analyze_snes_data_no_valid_checksum_map_mode_consistent_lorom_only()
-> Result<(), Box<dyn Error>> {
let mut data = generate_snes_header(
0x80000,
0,
0x00,
false,
"TEST NO CHECKSUM LOROM MAP",
Some(0x20),
); let lorom_checksum_start = 0x7FC0 + 0x1C;
data[lorom_checksum_start..lorom_checksum_start + 4]
.copy_from_slice(&[0x00, 0x00, 0x00, 0x00]);
let hirom_checksum_start = 0xFFC0 + 0x1C;
data[hirom_checksum_start..hirom_checksum_start + 4]
.copy_from_slice(&[0x00, 0x00, 0x00, 0x00]);
let analysis = analyze_snes_data(&data, "test_no_checksum_lorom_map.sfc")?;
assert_eq!(analysis.mapping_type, "LoROM (Unverified)"); Ok(())
}
}