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, RomAnalyzerError> {
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.is_some_and(|b| LOROM_MAP_MODES.contains(&b));
let is_hirom_map_mode = hirom_map_mode_byte.is_some_and(|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(RomAnalyzerError::DataTooSmall {
file_size,
required_size: valid_header_offset + 0x20,
details: format!("Checked header at offset: {}.", valid_header_offset),
});
}
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::*;
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<(), RomAnalyzerError> {
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)");
assert_eq!(
analysis.print(),
"test_lorom_jp.sfc\n\
System: Super Nintendo (SNES)\n\
Game Title: TEST GAME TITLE\n\
Mapping: LoROM (Map Mode Unverified)\n\
Region Code: 0x00\n\
Region: Japan"
);
Ok(())
}
#[test]
fn test_analyze_snes_data_hirom_usa() -> Result<(), RomAnalyzerError> {
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<(), RomAnalyzerError> {
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<(), RomAnalyzerError> {
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<(), RomAnalyzerError> {
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_lorom_indonesia() -> Result<(), RomAnalyzerError> {
let data = generate_snes_header(0x80000, 0, 0x0C, false, "TEST GAME TITLE", None); let analysis = analyze_snes_data(&data, "test_lorom_indonesia.sfc")?;
assert_eq!(analysis.source_name, "test_lorom_indonesia.sfc");
assert_eq!(analysis.game_title, "TEST GAME TITLE");
assert_eq!(analysis.mapping_type, "LoROM (Map Mode Unverified)");
assert_eq!(analysis.region_code, 0x0C);
assert_eq!(analysis.region, Region::EUROPE | Region::ASIA);
assert_eq!(analysis.region_string, "Indonesia (PAL)");
Ok(())
}
#[test]
fn test_analyze_snes_data_lorom_common() -> Result<(), RomAnalyzerError> {
let data = generate_snes_header(0x80000, 0, 0x0E, false, "TEST GAME TITLE", None); let analysis = analyze_snes_data(&data, "test_lorom_common.sfc")?;
assert_eq!(analysis.source_name, "test_lorom_common.sfc");
assert_eq!(analysis.game_title, "TEST GAME TITLE");
assert_eq!(analysis.mapping_type, "LoROM (Map Mode Unverified)");
assert_eq!(analysis.region_code, 0x0E);
assert_eq!(
analysis.region,
Region::USA | Region::EUROPE | Region::JAPAN | Region::ASIA
);
assert_eq!(analysis.region_string, "Common / International");
Ok(())
}
#[test]
fn test_analyze_snes_data_minimal_lorom_size() -> Result<(), RomAnalyzerError> {
let data = generate_snes_header(0x7FE0, 0, 0x00, false, "MINIMAL", None);
let analysis = analyze_snes_data(&data, "minimal_lorom.sfc")?;
assert_eq!(analysis.mapping_type, "LoROM (Map Mode Unverified)");
Ok(())
}
#[test]
fn test_validate_snes_checksum_minimal_size() {
let mut data = vec![0; 0x7FE0];
data[0x7FC0 + 0x1C..0x7FC0 + 0x1E].copy_from_slice(&0x5555u16.to_le_bytes());
data[0x7FC0 + 0x1E..0x7FC0 + 0x20].copy_from_slice(&0xAAAAu16.to_le_bytes());
assert!(validate_snes_checksum(&data, 0x7FC0));
}
#[test]
fn test_analyze_snes_data_too_small_for_header() {
let data = vec![0; 0x7FDF]; let result = analyze_snes_data(&data, "too_small_for_header.sfc");
assert!(result.is_err());
let err = result.unwrap_err();
match err {
RomAnalyzerError::DataTooSmall {
file_size,
required_size,
details,
} => {
assert_eq!(file_size, 0x7FDF);
assert_eq!(required_size, 0x7FC0 + 0x20); assert!(details.contains("Checked header at offset"));
}
_ => panic!("Expected DataTooSmall error"),
}
}
#[test]
fn test_analyze_snes_data_hirom_checksum_map_mode_consistent() -> Result<(), RomAnalyzerError> {
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<(), RomAnalyzerError> {
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<(), RomAnalyzerError>
{
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<(), RomAnalyzerError>
{
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<(), RomAnalyzerError> {
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<(), RomAnalyzerError> {
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(())
}
#[test]
fn test_map_region_all_codes() {
let test_cases = vec![
(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),
(0xFF, "Unknown", Region::UNKNOWN),
];
for (code, expected_name, expected_region) in test_cases {
let (name, region) = map_region(code);
assert_eq!(name, expected_name, "Failed for code 0x{:02X}", code);
assert_eq!(region, expected_region, "Failed for code 0x{:02X}", code);
}
}
}