1use std::error::Error;
10
11use log::error;
12use serde::Serialize;
13
14use crate::error::RomAnalyzerError;
15use crate::region::{Region, check_region_mismatch};
16
17const MAP_MODE_OFFSET: usize = 0x15;
19
20const LOROM_MAP_MODES: &[u8] = &[0x20, 0x30, 0x25, 0x35];
22const HIROM_MAP_MODES: &[u8] = &[0x21, 0x31, 0x22, 0x32];
23
24#[derive(Debug, PartialEq, Clone, Serialize)]
26pub struct SnesAnalysis {
27 pub source_name: String,
29 pub region: Region,
31 pub region_string: String,
33 pub region_mismatch: bool,
35 pub region_code: u8,
37 pub game_title: String,
39 pub mapping_type: String,
41}
42
43impl SnesAnalysis {
44 pub fn print(&self) -> String {
46 format!(
47 "{}\n\
48 System: Super Nintendo (SNES)\n\
49 Game Title: {}\n\
50 Mapping: {}\n\
51 Region Code: 0x{:02X}\n\
52 Region: {}",
53 self.source_name, self.game_title, self.mapping_type, self.region_code, self.region
54 )
55 }
56}
57
58pub fn map_region(code: u8) -> (&'static str, Region) {
93 match code {
94 0x00 => ("Japan (NTSC)", Region::JAPAN),
95 0x01 => ("USA / Canada (NTSC)", Region::USA),
96 0x02 => (
97 "Europe / Oceania / Asia (PAL)",
98 Region::EUROPE | Region::ASIA,
99 ),
100 0x03 => ("Sweden / Scandinavia (PAL)", Region::EUROPE),
101 0x04 => ("Finland (PAL)", Region::EUROPE),
102 0x05 => ("Denmark (PAL)", Region::EUROPE),
103 0x06 => ("France (PAL)", Region::EUROPE),
104 0x07 => ("Netherlands (PAL)", Region::EUROPE),
105 0x08 => ("Spain (PAL)", Region::EUROPE),
106 0x09 => ("Germany (PAL)", Region::EUROPE),
107 0x0A => ("Italy (PAL)", Region::EUROPE),
108 0x0B => ("China (PAL)", Region::CHINA),
109 0x0C => ("Indonesia (PAL)", Region::EUROPE | Region::ASIA),
110 0x0D => ("South Korea (NTSC)", Region::KOREA),
111 0x0E => (
112 "Common / International",
113 Region::USA | Region::EUROPE | Region::JAPAN | Region::ASIA,
114 ),
115 0x0F => ("Canada (NTSC)", Region::USA),
116 0x10 => ("Brazil (NTSC)", Region::USA),
117 0x11 => ("Australia (PAL)", Region::EUROPE),
118 0x12 => ("Other (Variation 1)", Region::UNKNOWN),
119 0x13 => ("Other (Variation 2)", Region::UNKNOWN),
120 0x14 => ("Other (Variation 3)", Region::UNKNOWN),
121 _ => ("Unknown", Region::UNKNOWN),
122 }
123}
124
125pub fn validate_snes_checksum(rom_data: &[u8], header_offset: usize) -> bool {
141 if header_offset + 0x20 > rom_data.len() {
143 return false;
144 }
145
146 let complement_bytes: [u8; 2] =
149 match rom_data[header_offset + 0x1C..header_offset + 0x1E].try_into() {
150 Ok(b) => b,
151 Err(_) => return false, };
153 let checksum_bytes: [u8; 2] =
154 match rom_data[header_offset + 0x1E..header_offset + 0x20].try_into() {
155 Ok(b) => b,
156 Err(_) => return false, };
158
159 let complement = u16::from_le_bytes(complement_bytes);
160 let checksum = u16::from_le_bytes(checksum_bytes);
161
162 (checksum as u32 + complement as u32) == 0xFFFF
164}
165
166pub fn analyze_snes_data(data: &[u8], source_name: &str) -> Result<SnesAnalysis, Box<dyn Error>> {
189 let file_size = data.len();
190 let mut header_offset = 0;
191
192 if file_size >= 512 && (file_size % 1024 == 512) {
194 header_offset = 512;
196 }
199
200 let lorom_header_start = 0x7FC0 + header_offset; let hirom_header_start = 0xFFC0 + header_offset; let mapping_type: String;
207 let valid_header_offset: usize;
208
209 let lorom_checksum_valid = validate_snes_checksum(data, lorom_header_start);
210 let hirom_checksum_valid = validate_snes_checksum(data, hirom_header_start);
211
212 let lorom_map_mode_byte = if lorom_header_start + MAP_MODE_OFFSET < file_size {
214 Some(data[lorom_header_start + MAP_MODE_OFFSET])
215 } else {
216 None
217 };
218 let hirom_map_mode_byte = if hirom_header_start + MAP_MODE_OFFSET < file_size {
219 Some(data[hirom_header_start + MAP_MODE_OFFSET])
220 } else {
221 None
222 };
223
224 let is_lorom_map_mode = lorom_map_mode_byte.map_or(false, |b| LOROM_MAP_MODES.contains(&b));
225 let is_hirom_map_mode = hirom_map_mode_byte.map_or(false, |b| HIROM_MAP_MODES.contains(&b));
226
227 if hirom_checksum_valid && is_hirom_map_mode {
231 mapping_type = "HiROM".to_string();
232 valid_header_offset = hirom_header_start;
233 } else if lorom_checksum_valid && is_lorom_map_mode {
234 mapping_type = "LoROM".to_string();
235 valid_header_offset = lorom_header_start;
236 } else if hirom_checksum_valid {
237 mapping_type = "HiROM (Map Mode Unverified)".to_string();
238 valid_header_offset = hirom_header_start;
239 error!(
240 "[!] HiROM checksum valid for {}, but Map Mode byte (0x{:02X?}) is not a typical HiROM value. Falling back to HiROM.",
241 source_name, hirom_map_mode_byte
242 );
243 } else if lorom_checksum_valid {
244 mapping_type = "LoROM (Map Mode Unverified)".to_string();
245 valid_header_offset = lorom_header_start;
246 error!(
247 "[!] LoROM checksum valid for {}, but Map Mode byte (0x{:02X?}) is not a typical LoROM value. Falling back to LoROM.",
248 source_name, lorom_map_mode_byte
249 );
250 } else {
251 error!(
253 "[!] Checksum validation failed for {}. Attempting to read header from LoROM location ({:X}) as fallback.",
254 source_name, lorom_header_start
255 );
256 mapping_type = "LoROM (Unverified)".to_string();
257 valid_header_offset = lorom_header_start; }
259
260 if valid_header_offset + 0x20 > file_size {
264 return Err(Box::new(RomAnalyzerError::new(&format!(
265 "ROM data is too small or header is invalid. File size: {} bytes. Checked header at offset: {}. Required minimum size for header region: {}.",
266 file_size,
267 valid_header_offset,
268 valid_header_offset + 0x20
269 ))));
270 }
271
272 let region_byte_offset = valid_header_offset + 0x19; let region_code = data[region_byte_offset];
275 let (region_name, region) = map_region(region_code);
276
277 let game_title = String::from_utf8_lossy(&data[valid_header_offset..valid_header_offset + 21])
280 .trim_matches(char::from(0)) .trim()
282 .to_string();
283
284 let region_mismatch = check_region_mismatch(source_name, region);
285
286 Ok(SnesAnalysis {
287 source_name: source_name.to_string(),
288 region,
289 region_string: region_name.to_string(),
290 region_mismatch,
291 region_code,
292 game_title,
293 mapping_type,
294 })
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use std::error::Error;
301
302 fn generate_snes_header(
305 rom_size: usize,
306 copier_header_offset: usize,
307 region_code: u8,
308 is_hirom: bool,
309 title: &str,
310 map_mode_byte: Option<u8>,
311 ) -> Vec<u8> {
312 let mut data = vec![0; rom_size];
313
314 let header_start = (if is_hirom { 0xFFC0 } else { 0x7FC0 }) + copier_header_offset;
316
317 if header_start + 0x20 > rom_size {
319 panic!(
320 "Provided ROM size {} is too small for SNES header at offset {} (needs at least {}).",
321 rom_size,
322 header_start,
323 header_start + 0x20
324 );
325 }
326
327 let mut title_bytes: Vec<u8> = title.as_bytes().to_vec();
329 title_bytes.truncate(21);
331 title_bytes.resize(21, b' '); data[header_start..header_start + 21].copy_from_slice(&title_bytes);
334
335 data[header_start + 0x19] = region_code;
337
338 if let Some(map_mode) = map_mode_byte {
340 data[header_start + MAP_MODE_OFFSET] = map_mode;
341 }
342
343 let complement: u16 = 0x5555;
346 let checksum: u16 = 0xFFFF - complement; data[header_start + 0x1C..header_start + 0x1E].copy_from_slice(&complement.to_le_bytes());
350 data[header_start + 0x1E..header_start + 0x20].copy_from_slice(&checksum.to_le_bytes());
352
353 data
354 }
355
356 #[test]
357 fn test_analyze_snes_data_lorom_japan() -> Result<(), Box<dyn Error>> {
358 let data = generate_snes_header(0x80000, 0, 0x00, false, "TEST GAME TITLE", None); let analysis = analyze_snes_data(&data, "test_lorom_jp.sfc")?;
360
361 assert_eq!(analysis.source_name, "test_lorom_jp.sfc");
362 assert_eq!(analysis.game_title, "TEST GAME TITLE");
363 assert_eq!(analysis.mapping_type, "LoROM (Map Mode Unverified)");
364 assert_eq!(analysis.region_code, 0x00);
365 assert_eq!(analysis.region, Region::JAPAN);
366 assert_eq!(analysis.region_string, "Japan (NTSC)");
367 Ok(())
368 }
369
370 #[test]
371 fn test_analyze_snes_data_hirom_usa() -> Result<(), Box<dyn Error>> {
372 let data = generate_snes_header(0x100000, 0, 0x01, true, "TEST GAME TITLE", None); let analysis = analyze_snes_data(&data, "test_hirom_us.sfc")?;
374
375 assert_eq!(analysis.source_name, "test_hirom_us.sfc");
376 assert_eq!(analysis.game_title, "TEST GAME TITLE");
377 assert_eq!(analysis.mapping_type, "HiROM (Map Mode Unverified)");
378 assert_eq!(analysis.region_code, 0x01);
379 assert_eq!(analysis.region, Region::USA);
380 assert_eq!(analysis.region_string, "USA / Canada (NTSC)");
381 Ok(())
382 }
383
384 #[test]
385 fn test_analyze_snes_data_lorom_europe_copier_header() -> Result<(), Box<dyn Error>> {
386 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")?;
389
390 assert_eq!(analysis.source_name, "test_lorom_eur_copier.sfc");
391 assert_eq!(analysis.game_title, "TEST GAME TITLE");
392 assert_eq!(analysis.mapping_type, "LoROM (Map Mode Unverified)"); assert_eq!(analysis.region_code, 0x02);
394 assert_eq!(analysis.region, Region::EUROPE | Region::ASIA);
395 assert_eq!(analysis.region_string, "Europe / Oceania / Asia (PAL)");
396 Ok(())
397 }
398
399 #[test]
400 fn test_analyze_snes_data_hirom_canada_copier_header() -> Result<(), Box<dyn Error>> {
401 let data = generate_snes_header(
403 0x100200,
404 512, 0x0F, true, "TEST GAME TITLE",
408 None,
409 );
410 let analysis = analyze_snes_data(&data, "test_hirom_can_copier.sfc")?;
411
412 assert_eq!(analysis.source_name, "test_hirom_can_copier.sfc");
413 assert_eq!(analysis.game_title, "TEST GAME TITLE");
414 assert_eq!(analysis.mapping_type, "HiROM (Map Mode Unverified)");
415 assert_eq!(analysis.region_code, 0x0F);
416 assert_eq!(analysis.region, Region::USA);
417 assert_eq!(analysis.region_string, "Canada (NTSC)");
418 Ok(())
419 }
420
421 #[test]
422 fn test_analyze_snes_data_unknown_region() -> Result<(), Box<dyn Error>> {
423 let data = generate_snes_header(0x80000, 0, 0xFF, false, "TEST GAME TITLE", None); let analysis = analyze_snes_data(&data, "test_lorom_unknown.sfc")?;
425
426 assert_eq!(analysis.source_name, "test_lorom_unknown.sfc");
427 assert_eq!(analysis.game_title, "TEST GAME TITLE");
428 assert_eq!(analysis.mapping_type, "LoROM (Map Mode Unverified)");
429 assert_eq!(analysis.region_code, 0xFF);
430 assert_eq!(analysis.region, Region::UNKNOWN);
431 assert_eq!(analysis.region_string, "Unknown");
432 Ok(())
433 }
434
435 #[test]
436 fn test_analyze_snes_data_invalid_checksum() -> Result<(), Box<dyn Error>> {
437 let mut data = generate_snes_header(
439 0x8000, 0,
441 0x01, false, "INVALID CHECKSUM", None,
445 );
446
447 let checksum_start = 0x7FC0 + 0x1C;
450
451 data[checksum_start..checksum_start + 4].copy_from_slice(&[0x00, 0x00, 0x00, 0x00]);
453
454 let analysis = analyze_snes_data(&data, "test_invalid_checksum.sfc")?;
455
456 assert_eq!(analysis.source_name, "test_invalid_checksum.sfc");
457 assert_eq!(analysis.game_title, "INVALID CHECKSUM");
458 assert_eq!(analysis.mapping_type, "LoROM (Unverified)"); assert_eq!(analysis.region_code, 0x01);
460 assert_eq!(analysis.region, Region::USA);
461 assert_eq!(analysis.region_string, "USA / Canada (NTSC)");
462 Ok(())
463 }
464
465 #[test]
466 fn test_analyze_snes_data_too_small() {
467 let data = vec![0; 0x1000]; let result = analyze_snes_data(&data, "too_small.sfc");
472 assert!(result.is_err());
473 assert!(
474 result
475 .unwrap_err()
476 .to_string()
477 .contains("too small or header is invalid")
478 );
479 }
480
481 #[test]
482 fn test_analyze_snes_data_hirom_checksum_map_mode_consistent() -> Result<(), Box<dyn Error>> {
483 let data =
484 generate_snes_header(0x100000, 0, 0x01, true, "TEST HIROM CONSISTENT", Some(0x21)); let analysis = analyze_snes_data(&data, "test_hirom_consistent.sfc")?;
486
487 assert_eq!(analysis.mapping_type, "HiROM");
488 assert_eq!(analysis.game_title, "TEST HIROM CONSISTENT");
489 Ok(())
490 }
491
492 #[test]
493 fn test_analyze_snes_data_lorom_checksum_map_mode_consistent() -> Result<(), Box<dyn Error>> {
494 let data =
495 generate_snes_header(0x80000, 0, 0x00, false, "TEST LOROM CONSISTENT", Some(0x20)); let analysis = analyze_snes_data(&data, "test_lorom_consistent.sfc")?;
497
498 assert_eq!(analysis.mapping_type, "LoROM");
499 assert_eq!(analysis.game_title, "TEST LOROM CONSISTENT");
500 Ok(())
501 }
502
503 #[test]
504 fn test_analyze_snes_data_hirom_checksum_map_mode_inconsistent() -> Result<(), Box<dyn Error>> {
505 let data = generate_snes_header(
506 0x100000,
507 0,
508 0x01,
509 true,
510 "TEST HIROM INCONSISTENT",
511 Some(0x20),
512 ); let analysis = analyze_snes_data(&data, "test_hirom_inconsistent.sfc")?;
514
515 assert_eq!(analysis.mapping_type, "HiROM (Map Mode Unverified)");
516 assert_eq!(analysis.game_title, "TEST HIROM INCONSISTE");
517 Ok(())
518 }
519
520 #[test]
521 fn test_analyze_snes_data_lorom_checksum_map_mode_inconsistent() -> Result<(), Box<dyn Error>> {
522 let data = generate_snes_header(
523 0x80000,
524 0,
525 0x00,
526 false,
527 "TEST LOROM INCONSISTENT",
528 Some(0x21),
529 ); let analysis = analyze_snes_data(&data, "test_lorom_inconsistent.sfc")?;
531
532 assert_eq!(analysis.mapping_type, "LoROM (Map Mode Unverified)");
533 assert_eq!(analysis.game_title, "TEST LOROM INCONSISTE");
534 Ok(())
535 }
536
537 #[test]
538 fn test_analyze_snes_data_no_valid_checksum_map_mode_consistent_hirom_only()
539 -> Result<(), Box<dyn Error>> {
540 let mut data = generate_snes_header(
541 0x100000,
542 0,
543 0x01,
544 true,
545 "TEST NO CHECKSUM HIROM MAP",
546 Some(0x21),
547 ); let lorom_checksum_start = 0x7FC0 + 0x1C;
550 data[lorom_checksum_start..lorom_checksum_start + 4]
551 .copy_from_slice(&[0x00, 0x00, 0x00, 0x00]);
552 let hirom_checksum_start = 0xFFC0 + 0x1C;
553 data[hirom_checksum_start..hirom_checksum_start + 4]
554 .copy_from_slice(&[0x00, 0x00, 0x00, 0x00]);
555
556 let analysis = analyze_snes_data(&data, "test_no_checksum_hirom_map.sfc")?;
557
558 assert_eq!(analysis.mapping_type, "LoROM (Unverified)"); Ok(())
560 }
561 #[test]
562 fn test_analyze_snes_data_no_valid_checksum_map_mode_consistent_lorom_only()
563 -> Result<(), Box<dyn Error>> {
564 let mut data = generate_snes_header(
565 0x80000,
566 0,
567 0x00,
568 false,
569 "TEST NO CHECKSUM LOROM MAP",
570 Some(0x20),
571 ); let lorom_checksum_start = 0x7FC0 + 0x1C;
574 data[lorom_checksum_start..lorom_checksum_start + 4]
575 .copy_from_slice(&[0x00, 0x00, 0x00, 0x00]);
576 let hirom_checksum_start = 0xFFC0 + 0x1C;
577 data[hirom_checksum_start..hirom_checksum_start + 4]
578 .copy_from_slice(&[0x00, 0x00, 0x00, 0x00]);
579
580 let analysis = analyze_snes_data(&data, "test_no_checksum_lorom_map.sfc")?;
581
582 assert_eq!(analysis.mapping_type, "LoROM (Unverified)"); Ok(())
584 }
585}