1use log::error;
10use serde::Serialize;
11
12use crate::error::RomAnalyzerError;
13use crate::region::{Region, check_region_mismatch};
14
15const MAP_MODE_OFFSET: usize = 0x15;
17
18const LOROM_MAP_MODES: &[u8] = &[0x20, 0x30, 0x25, 0x35];
20const HIROM_MAP_MODES: &[u8] = &[0x21, 0x31, 0x22, 0x32];
21
22#[derive(Debug, PartialEq, Clone, Serialize)]
24pub struct SnesAnalysis {
25 pub source_name: String,
27 pub region: Region,
29 pub region_string: String,
31 pub region_mismatch: bool,
33 pub region_code: u8,
35 pub game_title: String,
37 pub mapping_type: String,
39}
40
41impl SnesAnalysis {
42 pub fn print(&self) -> String {
44 format!(
45 "{}\n\
46 System: Super Nintendo (SNES)\n\
47 Game Title: {}\n\
48 Mapping: {}\n\
49 Region Code: 0x{:02X}\n\
50 Region: {}",
51 self.source_name, self.game_title, self.mapping_type, self.region_code, self.region
52 )
53 }
54}
55
56pub fn map_region(code: u8) -> (&'static str, Region) {
91 match code {
92 0x00 => ("Japan (NTSC)", Region::JAPAN),
93 0x01 => ("USA / Canada (NTSC)", Region::USA),
94 0x02 => (
95 "Europe / Oceania / Asia (PAL)",
96 Region::EUROPE | Region::ASIA,
97 ),
98 0x03 => ("Sweden / Scandinavia (PAL)", Region::EUROPE),
99 0x04 => ("Finland (PAL)", Region::EUROPE),
100 0x05 => ("Denmark (PAL)", Region::EUROPE),
101 0x06 => ("France (PAL)", Region::EUROPE),
102 0x07 => ("Netherlands (PAL)", Region::EUROPE),
103 0x08 => ("Spain (PAL)", Region::EUROPE),
104 0x09 => ("Germany (PAL)", Region::EUROPE),
105 0x0A => ("Italy (PAL)", Region::EUROPE),
106 0x0B => ("China (PAL)", Region::CHINA),
107 0x0C => ("Indonesia (PAL)", Region::EUROPE | Region::ASIA),
108 0x0D => ("South Korea (NTSC)", Region::KOREA),
109 0x0E => (
110 "Common / International",
111 Region::USA | Region::EUROPE | Region::JAPAN | Region::ASIA,
112 ),
113 0x0F => ("Canada (NTSC)", Region::USA),
114 0x10 => ("Brazil (NTSC)", Region::USA),
115 0x11 => ("Australia (PAL)", Region::EUROPE),
116 0x12 => ("Other (Variation 1)", Region::UNKNOWN),
117 0x13 => ("Other (Variation 2)", Region::UNKNOWN),
118 0x14 => ("Other (Variation 3)", Region::UNKNOWN),
119 _ => ("Unknown", Region::UNKNOWN),
120 }
121}
122
123pub fn validate_snes_checksum(rom_data: &[u8], header_offset: usize) -> bool {
139 if header_offset + 0x20 > rom_data.len() {
141 return false;
142 }
143
144 let complement_bytes: [u8; 2] =
147 match rom_data[header_offset + 0x1C..header_offset + 0x1E].try_into() {
148 Ok(b) => b,
149 Err(_) => return false, };
151 let checksum_bytes: [u8; 2] =
152 match rom_data[header_offset + 0x1E..header_offset + 0x20].try_into() {
153 Ok(b) => b,
154 Err(_) => return false, };
156
157 let complement = u16::from_le_bytes(complement_bytes);
158 let checksum = u16::from_le_bytes(checksum_bytes);
159
160 (checksum as u32 + complement as u32) == 0xFFFF
162}
163
164pub fn analyze_snes_data(data: &[u8], source_name: &str) -> Result<SnesAnalysis, RomAnalyzerError> {
187 let file_size = data.len();
188 let mut header_offset = 0;
189
190 if file_size >= 512 && (file_size % 1024 == 512) {
192 header_offset = 512;
194 }
197
198 let lorom_header_start = 0x7FC0 + header_offset; let hirom_header_start = 0xFFC0 + header_offset; let mapping_type: String;
205 let valid_header_offset: usize;
206
207 let lorom_checksum_valid = validate_snes_checksum(data, lorom_header_start);
208 let hirom_checksum_valid = validate_snes_checksum(data, hirom_header_start);
209
210 let lorom_map_mode_byte = if lorom_header_start + MAP_MODE_OFFSET < file_size {
212 Some(data[lorom_header_start + MAP_MODE_OFFSET])
213 } else {
214 None
215 };
216 let hirom_map_mode_byte = if hirom_header_start + MAP_MODE_OFFSET < file_size {
217 Some(data[hirom_header_start + MAP_MODE_OFFSET])
218 } else {
219 None
220 };
221
222 let is_lorom_map_mode = lorom_map_mode_byte.is_some_and(|b| LOROM_MAP_MODES.contains(&b));
223 let is_hirom_map_mode = hirom_map_mode_byte.is_some_and(|b| HIROM_MAP_MODES.contains(&b));
224
225 if hirom_checksum_valid && is_hirom_map_mode {
229 mapping_type = "HiROM".to_string();
230 valid_header_offset = hirom_header_start;
231 } else if lorom_checksum_valid && is_lorom_map_mode {
232 mapping_type = "LoROM".to_string();
233 valid_header_offset = lorom_header_start;
234 } else if hirom_checksum_valid {
235 mapping_type = "HiROM (Map Mode Unverified)".to_string();
236 valid_header_offset = hirom_header_start;
237 error!(
238 "[!] HiROM checksum valid for {}, but Map Mode byte (0x{:02X?}) is not a typical HiROM value. Falling back to HiROM.",
239 source_name, hirom_map_mode_byte
240 );
241 } else if lorom_checksum_valid {
242 mapping_type = "LoROM (Map Mode Unverified)".to_string();
243 valid_header_offset = lorom_header_start;
244 error!(
245 "[!] LoROM checksum valid for {}, but Map Mode byte (0x{:02X?}) is not a typical LoROM value. Falling back to LoROM.",
246 source_name, lorom_map_mode_byte
247 );
248 } else {
249 error!(
251 "[!] Checksum validation failed for {}. Attempting to read header from LoROM location ({:X}) as fallback.",
252 source_name, lorom_header_start
253 );
254 mapping_type = "LoROM (Unverified)".to_string();
255 valid_header_offset = lorom_header_start; }
257
258 if valid_header_offset + 0x20 > file_size {
262 return Err(RomAnalyzerError::DataTooSmall {
263 file_size,
264 required_size: valid_header_offset + 0x20,
265 details: format!("Checked header at offset: {}.", valid_header_offset),
266 });
267 }
268
269 let region_byte_offset = valid_header_offset + 0x19; let region_code = data[region_byte_offset];
272 let (region_name, region) = map_region(region_code);
273
274 let game_title = String::from_utf8_lossy(&data[valid_header_offset..valid_header_offset + 21])
277 .trim_matches(char::from(0)) .trim()
279 .to_string();
280
281 let region_mismatch = check_region_mismatch(source_name, region);
282
283 Ok(SnesAnalysis {
284 source_name: source_name.to_string(),
285 region,
286 region_string: region_name.to_string(),
287 region_mismatch,
288 region_code,
289 game_title,
290 mapping_type,
291 })
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 fn generate_snes_header(
301 rom_size: usize,
302 copier_header_offset: usize,
303 region_code: u8,
304 is_hirom: bool,
305 title: &str,
306 map_mode_byte: Option<u8>,
307 ) -> Vec<u8> {
308 let mut data = vec![0; rom_size];
309
310 let header_start = (if is_hirom { 0xFFC0 } else { 0x7FC0 }) + copier_header_offset;
312
313 if header_start + 0x20 > rom_size {
315 panic!(
316 "Provided ROM size {} is too small for SNES header at offset {} (needs at least {}).",
317 rom_size,
318 header_start,
319 header_start + 0x20
320 );
321 }
322
323 let mut title_bytes: Vec<u8> = title.as_bytes().to_vec();
325 title_bytes.truncate(21);
327 title_bytes.resize(21, b' '); data[header_start..header_start + 21].copy_from_slice(&title_bytes);
330
331 data[header_start + 0x19] = region_code;
333
334 if let Some(map_mode) = map_mode_byte {
336 data[header_start + MAP_MODE_OFFSET] = map_mode;
337 }
338
339 let complement: u16 = 0x5555;
342 let checksum: u16 = 0xFFFF - complement; data[header_start + 0x1C..header_start + 0x1E].copy_from_slice(&complement.to_le_bytes());
346 data[header_start + 0x1E..header_start + 0x20].copy_from_slice(&checksum.to_le_bytes());
348
349 data
350 }
351
352 #[test]
353 fn test_analyze_snes_data_lorom_japan() -> Result<(), RomAnalyzerError> {
354 let data = generate_snes_header(0x80000, 0, 0x00, false, "TEST GAME TITLE", None); let analysis = analyze_snes_data(&data, "test_lorom_jp.sfc")?;
356
357 assert_eq!(analysis.source_name, "test_lorom_jp.sfc");
358 assert_eq!(analysis.game_title, "TEST GAME TITLE");
359 assert_eq!(analysis.mapping_type, "LoROM (Map Mode Unverified)");
360 assert_eq!(analysis.region_code, 0x00);
361 assert_eq!(analysis.region, Region::JAPAN);
362 assert_eq!(analysis.region_string, "Japan (NTSC)");
363 assert_eq!(
364 analysis.print(),
365 "test_lorom_jp.sfc\n\
366 System: Super Nintendo (SNES)\n\
367 Game Title: TEST GAME TITLE\n\
368 Mapping: LoROM (Map Mode Unverified)\n\
369 Region Code: 0x00\n\
370 Region: Japan"
371 );
372 Ok(())
373 }
374
375 #[test]
376 fn test_analyze_snes_data_hirom_usa() -> Result<(), RomAnalyzerError> {
377 let data = generate_snes_header(0x100000, 0, 0x01, true, "TEST GAME TITLE", None); let analysis = analyze_snes_data(&data, "test_hirom_us.sfc")?;
379
380 assert_eq!(analysis.source_name, "test_hirom_us.sfc");
381 assert_eq!(analysis.game_title, "TEST GAME TITLE");
382 assert_eq!(analysis.mapping_type, "HiROM (Map Mode Unverified)");
383 assert_eq!(analysis.region_code, 0x01);
384 assert_eq!(analysis.region, Region::USA);
385 assert_eq!(analysis.region_string, "USA / Canada (NTSC)");
386 Ok(())
387 }
388
389 #[test]
390 fn test_analyze_snes_data_lorom_europe_copier_header() -> Result<(), RomAnalyzerError> {
391 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")?;
394
395 assert_eq!(analysis.source_name, "test_lorom_eur_copier.sfc");
396 assert_eq!(analysis.game_title, "TEST GAME TITLE");
397 assert_eq!(analysis.mapping_type, "LoROM (Map Mode Unverified)"); assert_eq!(analysis.region_code, 0x02);
399 assert_eq!(analysis.region, Region::EUROPE | Region::ASIA);
400 assert_eq!(analysis.region_string, "Europe / Oceania / Asia (PAL)");
401 Ok(())
402 }
403
404 #[test]
405 fn test_analyze_snes_data_hirom_canada_copier_header() -> Result<(), RomAnalyzerError> {
406 let data = generate_snes_header(
408 0x100200,
409 512, 0x0F, true, "TEST GAME TITLE",
413 None,
414 );
415 let analysis = analyze_snes_data(&data, "test_hirom_can_copier.sfc")?;
416
417 assert_eq!(analysis.source_name, "test_hirom_can_copier.sfc");
418 assert_eq!(analysis.game_title, "TEST GAME TITLE");
419 assert_eq!(analysis.mapping_type, "HiROM (Map Mode Unverified)");
420 assert_eq!(analysis.region_code, 0x0F);
421 assert_eq!(analysis.region, Region::USA);
422 assert_eq!(analysis.region_string, "Canada (NTSC)");
423 Ok(())
424 }
425
426 #[test]
427 fn test_analyze_snes_data_unknown_region() -> Result<(), RomAnalyzerError> {
428 let data = generate_snes_header(0x80000, 0, 0xFF, false, "TEST GAME TITLE", None); let analysis = analyze_snes_data(&data, "test_lorom_unknown.sfc")?;
430
431 assert_eq!(analysis.source_name, "test_lorom_unknown.sfc");
432 assert_eq!(analysis.game_title, "TEST GAME TITLE");
433 assert_eq!(analysis.mapping_type, "LoROM (Map Mode Unverified)");
434 assert_eq!(analysis.region_code, 0xFF);
435 assert_eq!(analysis.region, Region::UNKNOWN);
436 assert_eq!(analysis.region_string, "Unknown");
437 Ok(())
438 }
439
440 #[test]
441 fn test_analyze_snes_data_lorom_indonesia() -> Result<(), RomAnalyzerError> {
442 let data = generate_snes_header(0x80000, 0, 0x0C, false, "TEST GAME TITLE", None); let analysis = analyze_snes_data(&data, "test_lorom_indonesia.sfc")?;
444
445 assert_eq!(analysis.source_name, "test_lorom_indonesia.sfc");
446 assert_eq!(analysis.game_title, "TEST GAME TITLE");
447 assert_eq!(analysis.mapping_type, "LoROM (Map Mode Unverified)");
448 assert_eq!(analysis.region_code, 0x0C);
449 assert_eq!(analysis.region, Region::EUROPE | Region::ASIA);
450 assert_eq!(analysis.region_string, "Indonesia (PAL)");
451 Ok(())
452 }
453
454 #[test]
455 fn test_analyze_snes_data_lorom_common() -> Result<(), RomAnalyzerError> {
456 let data = generate_snes_header(0x80000, 0, 0x0E, false, "TEST GAME TITLE", None); let analysis = analyze_snes_data(&data, "test_lorom_common.sfc")?;
458
459 assert_eq!(analysis.source_name, "test_lorom_common.sfc");
460 assert_eq!(analysis.game_title, "TEST GAME TITLE");
461 assert_eq!(analysis.mapping_type, "LoROM (Map Mode Unverified)");
462 assert_eq!(analysis.region_code, 0x0E);
463 assert_eq!(
464 analysis.region,
465 Region::USA | Region::EUROPE | Region::JAPAN | Region::ASIA
466 );
467 assert_eq!(analysis.region_string, "Common / International");
468 Ok(())
469 }
470
471 #[test]
472 fn test_analyze_snes_data_minimal_lorom_size() -> Result<(), RomAnalyzerError> {
473 let data = generate_snes_header(0x7FE0, 0, 0x00, false, "MINIMAL", None);
475 let analysis = analyze_snes_data(&data, "minimal_lorom.sfc")?;
476 assert_eq!(analysis.mapping_type, "LoROM (Map Mode Unverified)");
477 Ok(())
478 }
479
480 #[test]
481 fn test_validate_snes_checksum_minimal_size() {
482 let mut data = vec![0; 0x7FE0];
485 data[0x7FC0 + 0x1C..0x7FC0 + 0x1E].copy_from_slice(&0x5555u16.to_le_bytes());
487 data[0x7FC0 + 0x1E..0x7FC0 + 0x20].copy_from_slice(&0xAAAAu16.to_le_bytes());
488 assert!(validate_snes_checksum(&data, 0x7FC0));
489 }
490
491 #[test]
492 fn test_analyze_snes_data_too_small_for_header() {
493 let data = vec![0; 0x7FDF]; let result = analyze_snes_data(&data, "too_small_for_header.sfc");
498 assert!(result.is_err());
499 let err = result.unwrap_err();
500 match err {
501 RomAnalyzerError::DataTooSmall {
502 file_size,
503 required_size,
504 details,
505 } => {
506 assert_eq!(file_size, 0x7FDF);
507 assert_eq!(required_size, 0x7FC0 + 0x20); assert!(details.contains("Checked header at offset"));
509 }
510 _ => panic!("Expected DataTooSmall error"),
511 }
512 }
513
514 #[test]
515 fn test_analyze_snes_data_hirom_checksum_map_mode_consistent() -> Result<(), RomAnalyzerError> {
516 let data =
517 generate_snes_header(0x100000, 0, 0x01, true, "TEST HIROM CONSISTENT", Some(0x21)); let analysis = analyze_snes_data(&data, "test_hirom_consistent.sfc")?;
519
520 assert_eq!(analysis.mapping_type, "HiROM");
521 assert_eq!(analysis.game_title, "TEST HIROM CONSISTENT");
522 Ok(())
523 }
524
525 #[test]
526 fn test_analyze_snes_data_lorom_checksum_map_mode_consistent() -> Result<(), RomAnalyzerError> {
527 let data =
528 generate_snes_header(0x80000, 0, 0x00, false, "TEST LOROM CONSISTENT", Some(0x20)); let analysis = analyze_snes_data(&data, "test_lorom_consistent.sfc")?;
530
531 assert_eq!(analysis.mapping_type, "LoROM");
532 assert_eq!(analysis.game_title, "TEST LOROM CONSISTENT");
533 Ok(())
534 }
535
536 #[test]
537 fn test_analyze_snes_data_hirom_checksum_map_mode_inconsistent() -> Result<(), RomAnalyzerError>
538 {
539 let data = generate_snes_header(
540 0x100000,
541 0,
542 0x01,
543 true,
544 "TEST HIROM INCONSISTENT",
545 Some(0x20),
546 ); let analysis = analyze_snes_data(&data, "test_hirom_inconsistent.sfc")?;
548
549 assert_eq!(analysis.mapping_type, "HiROM (Map Mode Unverified)");
550 assert_eq!(analysis.game_title, "TEST HIROM INCONSISTE");
551 Ok(())
552 }
553
554 #[test]
555 fn test_analyze_snes_data_lorom_checksum_map_mode_inconsistent() -> Result<(), RomAnalyzerError>
556 {
557 let data = generate_snes_header(
558 0x80000,
559 0,
560 0x00,
561 false,
562 "TEST LOROM INCONSISTENT",
563 Some(0x21),
564 ); let analysis = analyze_snes_data(&data, "test_lorom_inconsistent.sfc")?;
566
567 assert_eq!(analysis.mapping_type, "LoROM (Map Mode Unverified)");
568 assert_eq!(analysis.game_title, "TEST LOROM INCONSISTE");
569 Ok(())
570 }
571
572 #[test]
573 fn test_analyze_snes_data_no_valid_checksum_map_mode_consistent_hirom_only()
574 -> Result<(), RomAnalyzerError> {
575 let mut data = generate_snes_header(
576 0x100000,
577 0,
578 0x01,
579 true,
580 "TEST NO CHECKSUM HIROM MAP",
581 Some(0x21),
582 ); let lorom_checksum_start = 0x7FC0 + 0x1C;
585 data[lorom_checksum_start..lorom_checksum_start + 4]
586 .copy_from_slice(&[0x00, 0x00, 0x00, 0x00]);
587 let hirom_checksum_start = 0xFFC0 + 0x1C;
588 data[hirom_checksum_start..hirom_checksum_start + 4]
589 .copy_from_slice(&[0x00, 0x00, 0x00, 0x00]);
590
591 let analysis = analyze_snes_data(&data, "test_no_checksum_hirom_map.sfc")?;
592
593 assert_eq!(analysis.mapping_type, "LoROM (Unverified)"); Ok(())
595 }
596 #[test]
597 fn test_analyze_snes_data_no_valid_checksum_map_mode_consistent_lorom_only()
598 -> Result<(), RomAnalyzerError> {
599 let mut data = generate_snes_header(
600 0x80000,
601 0,
602 0x00,
603 false,
604 "TEST NO CHECKSUM LOROM MAP",
605 Some(0x20),
606 ); let lorom_checksum_start = 0x7FC0 + 0x1C;
609 data[lorom_checksum_start..lorom_checksum_start + 4]
610 .copy_from_slice(&[0x00, 0x00, 0x00, 0x00]);
611 let hirom_checksum_start = 0xFFC0 + 0x1C;
612 data[hirom_checksum_start..hirom_checksum_start + 4]
613 .copy_from_slice(&[0x00, 0x00, 0x00, 0x00]);
614
615 let analysis = analyze_snes_data(&data, "test_no_checksum_lorom_map.sfc")?;
616
617 assert_eq!(analysis.mapping_type, "LoROM (Unverified)"); Ok(())
619 }
620
621 #[test]
622 fn test_map_region_all_codes() {
623 let test_cases = vec![
625 (0x00, "Japan (NTSC)", Region::JAPAN),
626 (0x01, "USA / Canada (NTSC)", Region::USA),
627 (
628 0x02,
629 "Europe / Oceania / Asia (PAL)",
630 Region::EUROPE | Region::ASIA,
631 ),
632 (0x03, "Sweden / Scandinavia (PAL)", Region::EUROPE),
633 (0x04, "Finland (PAL)", Region::EUROPE),
634 (0x05, "Denmark (PAL)", Region::EUROPE),
635 (0x06, "France (PAL)", Region::EUROPE),
636 (0x07, "Netherlands (PAL)", Region::EUROPE),
637 (0x08, "Spain (PAL)", Region::EUROPE),
638 (0x09, "Germany (PAL)", Region::EUROPE),
639 (0x0A, "Italy (PAL)", Region::EUROPE),
640 (0x0B, "China (PAL)", Region::CHINA),
641 (0x0C, "Indonesia (PAL)", Region::EUROPE | Region::ASIA),
642 (0x0D, "South Korea (NTSC)", Region::KOREA),
643 (
644 0x0E,
645 "Common / International",
646 Region::USA | Region::EUROPE | Region::JAPAN | Region::ASIA,
647 ),
648 (0x0F, "Canada (NTSC)", Region::USA),
649 (0x10, "Brazil (NTSC)", Region::USA),
650 (0x11, "Australia (PAL)", Region::EUROPE),
651 (0x12, "Other (Variation 1)", Region::UNKNOWN),
652 (0x13, "Other (Variation 2)", Region::UNKNOWN),
653 (0x14, "Other (Variation 3)", Region::UNKNOWN),
654 (0xFF, "Unknown", Region::UNKNOWN),
655 ];
656 for (code, expected_name, expected_region) in test_cases {
657 let (name, region) = map_region(code);
658 assert_eq!(name, expected_name, "Failed for code 0x{:02X}", code);
659 assert_eq!(region, expected_region, "Failed for code 0x{:02X}", code);
660 }
661 }
662}