rom_analyzer/console/
snes.rs

1//! Provides header analysis functionality for Super Nintendo Entertainment System (SNES) ROMs.
2//!
3//! This module can detect SNES ROM mapping types (LoROM, HiROM),
4//! validate checksums, and extract game title and region information.
5//!
6//! Super Nintendo header documentation referenced here:
7//! <https://snes.nesdev.org/wiki/ROM_header>
8
9use log::error;
10use serde::Serialize;
11
12use crate::error::RomAnalyzerError;
13use crate::region::{Region, check_region_mismatch};
14
15// Map Mode byte offset relative to the header start (0x7FC0 for LoROM, 0xFFC0 for HiROM)
16const MAP_MODE_OFFSET: usize = 0x15;
17
18// Expected Map Mode byte values for LoROM and HiROM
19const LOROM_MAP_MODES: &[u8] = &[0x20, 0x30, 0x25, 0x35];
20const HIROM_MAP_MODES: &[u8] = &[0x21, 0x31, 0x22, 0x32];
21
22/// Struct to hold the analysis results for a SNES ROM.
23#[derive(Debug, PartialEq, Clone, Serialize)]
24pub struct SnesAnalysis {
25    /// The name of the source file.
26    pub source_name: String,
27    /// The identified region(s) as a region::Region bitmask.
28    pub region: Region,
29    /// The identified region name (e.g., "Japan (NTSC)").
30    pub region_string: String,
31    /// If the region in the ROM header doesn't match the region in the filename.
32    pub region_mismatch: bool,
33    /// The raw region code byte.
34    pub region_code: u8,
35    /// The game title extracted from the ROM header.
36    pub game_title: String,
37    /// The detected mapping type (e.g., "LoROM", "HiROM").
38    pub mapping_type: String,
39}
40
41impl SnesAnalysis {
42    /// Returns a printable String of the analysis results.
43    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
56/// Determines the SNES game region name based on a given region byte.
57///
58/// The region byte typically comes from the ROM header. This function extracts the relevant bits
59/// from the byte and maps it to a human-readable region string and a Region bitmask.
60///
61/// # Arguments
62///
63/// * `region_byte` - The byte containing the region code, usually found in the ROM header.
64///
65/// # Returns
66///
67/// A tuple containing:
68/// - A `&'static str` representing the region as written in the ROM header (e.g., "Japan (NTSC)",
69///   "USA / Canada (NTSC)", etc.) or "Unknown" if the region code is not recognized.
70/// - A [`Region`] bitmask representing the region(s) associated with the code.
71///
72/// # Examples
73///
74/// ```rust
75/// use rom_analyzer::console::snes::map_region;
76/// use rom_analyzer::region::Region;
77///
78/// let (region_str, region_mask) = map_region(0x00);
79/// assert_eq!(region_str, "Japan (NTSC)");
80/// assert_eq!(region_mask, Region::JAPAN);
81///
82/// let (region_str, region_mask) = map_region(0x01);
83/// assert_eq!(region_str, "USA / Canada (NTSC)");
84/// assert_eq!(region_mask, Region::USA);
85///
86/// let (region_str, region_mask) = map_region(0x02);
87/// assert_eq!(region_str, "Europe / Oceania / Asia (PAL)");
88/// assert_eq!(region_mask, Region::EUROPE | Region::ASIA);
89/// ```
90pub 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
123/// Helper function to validate the SNES ROM checksum.
124///
125/// This function checks if the 16-bit checksum and its complement, located
126/// within the SNES header, sum up to `0xFFFF`. This is a common method
127/// for validating the integrity of SNES ROM headers.
128///
129/// # Arguments
130///
131/// * `rom_data` - A byte slice (`&[u8]`) containing the raw ROM data.
132/// * `header_offset` - The starting offset of the SNES header within `rom_data`.
133///
134/// # Returns
135///
136/// `true` if the checksum and its complement are valid (sum to 0xFFFF),
137/// `false` otherwise, or if the `header_offset` is out of bounds.
138pub fn validate_snes_checksum(rom_data: &[u8], header_offset: usize) -> bool {
139    // Ensure we have enough data for checksum and complement bytes.
140    if header_offset + 0x20 > rom_data.len() {
141        return false;
142    }
143
144    // Checksum is at 0x1E (relative to header start), complement at 0x1C.
145    // Both are 16-bit values, little-endian.
146    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, // Should not happen if header_offset + 0x20 is within bounds.
150        };
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, // Should not happen if header_offset + 0x20 is within bounds.
155        };
156
157    let complement = u16::from_le_bytes(complement_bytes);
158    let checksum = u16::from_le_bytes(checksum_bytes);
159
160    // The checksum algorithm: (checksum + complement) should equal 0xFFFF.
161    (checksum as u32 + complement as u32) == 0xFFFF
162}
163
164/// Analyzes SNES ROM data.
165///
166/// This function first attempts to detect a copier header. It then tries to determine
167/// the ROM's mapping type (LoROM or HiROM) by validating checksums and examining
168/// the Map Mode byte at expected header locations. If both checksum and Map Mode
169/// are consistent, that mapping is chosen. If only the checksum is valid, it uses
170/// that mapping with an "Map Mode Unverified" tag. If neither is fully consistent,
171/// it falls back to LoROM (Unverified). Once the header location is determined,
172/// it extracts the game title and region code, maps the region code to a human-readable
173/// name, and performs a region mismatch check against the `source_name`.
174///
175/// # Arguments
176///
177/// * `data` - A byte slice (`&[u8]`) containing the raw ROM data.
178/// * `source_name` - The name of the ROM file, used for logging and region mismatch checks.
179///
180/// # Returns
181///
182/// A `Result` which is:
183/// - `Ok`([`SnesAnalysis`]) containing the detailed analysis results.
184/// - `Err`([`RomAnalyzerError`]) if the ROM data is too small or the header is deemed invalid
185///   such that critical information cannot be read.
186pub 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    // Detect copier header (often 512 bytes, common for some older dumps/tools)
191    if file_size >= 512 && (file_size % 1024 == 512) {
192        // Heuristic: If file size ends in 512 and is divisible by 1024
193        header_offset = 512;
194        // Note: This copier header detection is a simple heuristic and might not be foolproof.
195        // More advanced detection could involve checking for specific patterns.
196    }
197
198    // Determine ROM mapping type (LoROM vs HiROM) by checking checksums and Map Mode byte.
199    // The relevant header information is usually found at 0x7FC0 for LoROM and 0xFFC0 for HiROM
200    // (relative to the start of the ROM, accounting for the header_offset).
201    let lorom_header_start = 0x7FC0 + header_offset; // Header block starts here
202    let hirom_header_start = 0xFFC0 + header_offset; // Header block starts here
203
204    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    // Get Map Mode bytes if headers are within bounds
211    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    // Decision logic: Prioritize HiROM if both checksum and map mode are consistent.
226    // Then check LoROM similarly. If only one checksum is valid, use that.
227    // If neither is fully consistent, fallback to LoROM (unverified) with a warning.
228    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        // If neither checksum is valid, log a warning and try LoROM as a fallback, as it's more common.
250        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; // Fallback to LoROM offset
256    }
257
258    // Ensure the determined header offset plus the header size needed for analysis is within the file bounds.
259    // We need at least up to the region code (offset 0x19 relative to header start) and game title (offset 0x0 to 0x14).
260    // Thus, we check if `valid_header_offset + 0x20` is within bounds, as this covers the checksum bytes.
261    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    // Extract region code and game title from the identified header.
270    let region_byte_offset = valid_header_offset + 0x19; // Offset for region code within the header
271    let region_code = data[region_byte_offset];
272    let (region_name, region) = map_region(region_code);
273
274    // Game title is located at the beginning of the header (offset 0x0 relative to valid_header_offset) for 21 bytes.
275    // It is null-terminated, so we trim null bytes and leading/trailing whitespace.
276    let game_title = String::from_utf8_lossy(&data[valid_header_offset..valid_header_offset + 21])
277        .trim_matches(char::from(0)) // Remove null bytes
278        .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    /// Helper to create a dummy SNES ROM with a valid checksum.
299    /// It allows specifying ROM size, copier header offset, region code, mapping type.
300    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        // Calculate the actual start of the SNES header based on mapping type and copier offset.
311        let header_start = (if is_hirom { 0xFFC0 } else { 0x7FC0 }) + copier_header_offset;
312
313        // Ensure the data is large enough
314        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        // 1. Set Title (21 bytes starting at header_start + 0x00)
324        let mut title_bytes: Vec<u8> = title.as_bytes().to_vec();
325        // Truncate if longer than 21 bytes, then pad with spaces if shorter.
326        title_bytes.truncate(21);
327        title_bytes.resize(21, b' '); // Pad with spaces, standard SNES header practice
328
329        data[header_start..header_start + 21].copy_from_slice(&title_bytes);
330
331        // 2. Set Region Code (at header_start + 0x19)
332        data[header_start + 0x19] = region_code;
333
334        // 3. Set Map Mode byte if provided (at header_start + MAP_MODE_OFFSET)
335        if let Some(map_mode) = map_mode_byte {
336            data[header_start + MAP_MODE_OFFSET] = map_mode;
337        }
338
339        // 4. Set a valid checksum and its complement.
340        // The checksum algorithm is (checksum + complement) == 0xFFFF. We use a simple pair.
341        let complement: u16 = 0x5555;
342        let checksum: u16 = 0xFFFF - complement; // 0xAAAA
343
344        // Set Checksum Complement (0x1C relative to header start)
345        data[header_start + 0x1C..header_start + 0x1E].copy_from_slice(&complement.to_le_bytes());
346        // Set Checksum (0x1E relative to header start)
347        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); // 512KB ROM, LoROM, Japan
355        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); // 1MB ROM, HiROM, USA
378        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        // Rom size ends with 512 bytes, e.g., 800KB + 512 bytes = 800512 bytes.
392        let data = generate_snes_header(0x80000 + 512, 512, 0x02, false, "TEST GAME TITLE", None); // LoROM, Europe, with 512-byte copier header
393        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)"); // Should detect copier header but still identify LoROM
398        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        // Data size: 1MB + 512 bytes for copier header
407        let data = generate_snes_header(
408            0x100200,
409            512,  // Copier Header offset
410            0x0F, // Region: Canada (0x0F)
411            true, // HiROM
412            "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); // LoROM, Unknown region
429        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); // LoROM, Indonesia
443        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); // LoROM, Common
457        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        // Minimal size for LoROM: header at 0x7FC0, needs up to 0x7FE0 for checksum.
474        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        // Test with data exactly the size needed for LoROM checksum validation.
483        // LoROM header starts at 0x7FC0, needs up to 0x7FE0 (0x7FC0 + 0x20).
484        let mut data = vec![0; 0x7FE0];
485        // Set valid checksum: complement 0x5555, checksum 0xAAAA (0xFFFF - 0x5555)
486        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        // Test with data that is large enough to detect LoROM header location
494        // but not large enough for the header content (needs valid_header_offset + 0x20)
495        // For LoROM, valid_header_offset = 0x7FC0, so we need at least 0x7FE0 bytes
496        let data = vec![0; 0x7FDF]; // One byte short of 0x7FE0
497        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); // valid_header_offset + 0x20
508                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)); // HiROM, USA, HiROM Map Mode
518        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)); // LoROM, Japan, LoROM Map Mode
529        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        ); // HiROM, USA, LoROM Map Mode
547        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        ); // LoROM, Japan, HiROM Map Mode
565        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        ); // HiROM, USA, HiROM Map Mode
583        // Invalidate both checksums
584        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)"); // Expect fallback
594        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        ); // LoROM, Japan, LoROM Map Mode
607        // Invalidate both checksums
608        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)"); // Expect fallback
618        Ok(())
619    }
620
621    #[test]
622    fn test_map_region_all_codes() {
623        // Test all known region codes to catch "delete match arm" mutations
624        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}