rom-analyzer 1.1.0

A CLI tool for analyzing console ROM file headers.
Documentation
//! Provides header analysis functionality for Nintendo Entertainment System (NES) ROMs.
//!
//! This module supports both iNES and NES 2.0 header formats to extract region
//! and other relevant information.
//!
//! NES header documentation referenced here:
//! <https://www.nesdev.org/wiki/INES>
//! <https://www.nesdev.org/wiki/NES_2.0>

use serde::Serialize;

use crate::error::RomAnalyzerError;
use crate::region::{Region, check_region_mismatch};

const INES_REGION_BYTE: usize = 9;
const INES_REGION_MASK: u8 = 0x01;

const NES2_REGION_BYTE: usize = 12;
const NES2_REGION_MASK: u8 = 0x03;
const NES2_FORMAT_BYTE: usize = 7;
const NES2_FORMAT_MASK: u8 = 0x0C;
const NES2_FORMAT_EXPECTED_VALUE: u8 = 0x08;

/// Struct to hold the analysis results for a NES ROM.
#[derive(Debug, PartialEq, Clone, Serialize)]
pub struct NesAnalysis {
    /// The name of the source file.
    pub source_name: String,
    /// The identified region(s) as a region::Region bitmask.
    pub region: Region,
    /// The identified region name (e.g., "NTSC (USA/Japan)").
    pub region_string: String,
    /// If the region in the ROM header doesn't match the region in the filename.
    pub region_mismatch: bool,
    /// The raw byte value used for region determination (from iNES flag 9 or NES2 flag 12).
    pub region_byte_value: u8,
    /// Whether the ROM header is in NES 2.0 format.
    pub is_nes2_format: bool,
}

impl NesAnalysis {
    /// Returns a printable String of the analysis results.
    pub fn print(&self) -> String {
        let nes_flag_display = if self.is_nes2_format {
            format!("\nNES2.0 Flag 12: 0x{:02X}", self.region_byte_value)
        } else {
            format!("\niNES Flag 9:  0x{:02X}", self.region_byte_value)
        };

        format!(
            "{}\n\
             System:       Nintendo Entertainment System (NES)\n\
             Region:       {}\
             {}",
            self.source_name, self.region, nes_flag_display
        )
    }
}

/// Determines the NES region name based on the region byte and header format.
///
/// This function interprets the region information from either an iNES or NES 2.0
/// header, mapping the raw byte value to a human-readable region string.
///
/// # Arguments
///
/// * `region_byte` - The byte containing the region code (from iNES byte 9 or NES 2.0 byte 12).
/// * `nes2_format` - A boolean indicating whether the ROM uses the NES 2.0 header format.
///
/// # Returns
///
/// A tuple containing:
/// - A `&'static str` representing the region as written in the ROM header (e.g., "Multi-region",
///   "PAL (Europe/Oceania)", "NTSC (USA/Japan)") or "Unknown" if the region code is not recognized.
/// - A [`Region`] bitmask representing the region(s) associated with the code.
///
/// # Examples
///
/// ```rust
/// use rom_analyzer::console::nes::map_region;
/// use rom_analyzer::region::Region;
///
/// // Test NES 2.0 format with NTSC region
/// let (region_str, region_mask) = map_region(0x00, true);
/// assert_eq!(region_str, "NTSC (USA/Japan)");
/// assert_eq!(region_mask, Region::USA | Region::JAPAN);
///
/// // Test iNES format with PAL region
/// let (region_str, region_mask) = map_region(0x01, false);
/// assert_eq!(region_str, "PAL (Europe/Oceania)");
/// assert_eq!(region_mask, Region::EUROPE);
/// ```
pub fn map_region(region_byte: u8, nes2_format: bool) -> (&'static str, Region) {
    if nes2_format {
        // NES 2.0 headers store region data in the CPU/PPU timing bit
        // in byte 12.
        match region_byte & NES2_REGION_MASK {
            0 => ("NTSC (USA/Japan)", Region::USA | Region::JAPAN),
            1 => ("PAL (Europe/Oceania)", Region::EUROPE),
            2 => ("Multi-region", Region::USA | Region::JAPAN | Region::EUROPE),
            3 => ("Dendy (Russia)", Region::RUSSIA),
            _ => ("Unknown", Region::UNKNOWN),
        }
    } else {
        // iNES headers store region data in byte 9.
        // It is only the lowest-order bit for NTSC vs PAL.
        // NTSC covers USA and Japan.
        match region_byte & INES_REGION_MASK {
            0 => ("NTSC (USA/Japan)", Region::USA | Region::JAPAN),
            1 => ("PAL (Europe/Oceania)", Region::EUROPE),
            _ => ("Unknown", Region::UNKNOWN),
        }
    }
}

/// Analyzes NES ROM data.
///
/// This function first validates the iNES header signature. It then determines
/// if the ROM uses the NES 2.0 format or the older iNES format. Based on the
/// detected format, it extracts the relevant region byte and maps it to a
/// human-readable region name. A region mismatch check is also performed
/// against the `source_name`.
///
/// # Arguments
///
/// * `data` - A byte slice (`&[u8]`) containing the raw ROM data.
/// * `source_name` - The name of the ROM file, used for region mismatch checks.
///
/// # Returns
///
/// A `Result` which is:
/// - `Ok`([`NesAnalysis`]) containing the detailed analysis results.
/// - `Err`([`RomAnalyzerError`]) if the ROM data is too small or has an invalid iNES signature.
pub fn analyze_nes_data(data: &[u8], source_name: &str) -> Result<NesAnalysis, RomAnalyzerError> {
    if data.len() < 16 {
        return Err(RomAnalyzerError::DataTooSmall {
            file_size: data.len(),
            required_size: 16,
            details: "iNES header".to_string(),
        });
    }

    // All headered NES ROMs should begin with 'NES<EOF>'
    let signature = &data[0..4];
    if signature != b"NES\x1a" {
        return Err(RomAnalyzerError::InvalidHeader(
            "Invalid iNES header signature. Not a valid NES ROM.".to_string(),
        ));
    }

    let mut region_byte_val = data[INES_REGION_BYTE];
    let is_nes2_format = (data[NES2_FORMAT_BYTE] & NES2_FORMAT_MASK) == NES2_FORMAT_EXPECTED_VALUE;

    if is_nes2_format {
        region_byte_val = data[NES2_REGION_BYTE];
    }

    let (region_name, region) = map_region(region_byte_val, is_nes2_format);
    let region_mismatch = check_region_mismatch(source_name, region);

    Ok(NesAnalysis {
        source_name: source_name.to_string(),
        region,
        region_string: region_name.to_string(),
        region_mismatch,
        region_byte_value: region_byte_val,
        is_nes2_format,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    // Helper enum to specify header type for generation.
    enum NesHeaderType {
        Ines,
        Nes2,
    }

    /// Generates a 16-byte NES ROM header for testing.
    /// configures the header to be either iNES or NES 2.0 format,
    /// and sets the specified region value.
    fn generate_nes_header(header_type: NesHeaderType, region_value: u8) -> Vec<u8> {
        let mut data = vec![0; 16];
        data[0..4].copy_from_slice(b"NES\x1a"); // Signature

        match header_type {
            NesHeaderType::Ines => {
                // iNES format: region is in byte 9. Only the LSB (INES_REGION_MASK) matters.
                // We set the byte and let map_region handle the masking.
                data[INES_REGION_BYTE] = region_value;
                // Ensure NES 2.0 flags are NOT set in byte 7.
                data[NES2_FORMAT_BYTE] &= !NES2_FORMAT_MASK;
            }
            NesHeaderType::Nes2 => {
                // NES 2.0 format: set NES 2.0 identification bits in byte 7.
                data[NES2_FORMAT_BYTE] |= NES2_FORMAT_EXPECTED_VALUE;
                // Region is in byte 12, masked by NES2_REGION_MASK.
                // We set the byte and let map_region handle the masking.
                data[NES2_REGION_BYTE] = region_value;
            }
        }
        data
    }

    #[test]
    fn test_analyze_ines_data_ntsc() -> Result<(), RomAnalyzerError> {
        // iNES format, NTSC region (LSB is 0)
        let data = generate_nes_header(NesHeaderType::Ines, 0x00);
        let analysis = analyze_nes_data(&data, "test_rom_ntsc.nes")?;

        assert_eq!(analysis.source_name, "test_rom_ntsc.nes");
        assert_eq!(analysis.region, Region::USA | Region::JAPAN);
        assert_eq!(analysis.region_string, "NTSC (USA/Japan)");
        assert!(!analysis.is_nes2_format);
        assert_eq!(analysis.region_byte_value, 0x00);
        assert_eq!(
            analysis.print(),
            "test_rom_ntsc.nes\n\
             System:       Nintendo Entertainment System (NES)\n\
             Region:       Japan/USA\n\
             iNES Flag 9:  0x00"
        );
        Ok(())
    }

    #[test]
    fn test_analyze_ines_data_pal() -> Result<(), RomAnalyzerError> {
        // iNES format, PAL region (LSB is 1)
        let data = generate_nes_header(NesHeaderType::Ines, 0x01);
        let analysis = analyze_nes_data(&data, "test_rom_pal.nes")?;

        assert_eq!(analysis.source_name, "test_rom_pal.nes");
        assert_eq!(analysis.region, Region::EUROPE);
        assert_eq!(analysis.region_string, "PAL (Europe/Oceania)");
        assert!(!analysis.is_nes2_format);
        assert_eq!(analysis.region_byte_value, 0x01);
        Ok(())
    }

    #[test]
    fn test_analyze_nes2_data_ntsc() -> Result<(), RomAnalyzerError> {
        // NES 2.0 format, NTSC region (value 0)
        let data = generate_nes_header(NesHeaderType::Nes2, 0x00);
        let analysis = analyze_nes_data(&data, "test_rom_nes2_ntsc.nes")?;

        assert_eq!(analysis.source_name, "test_rom_nes2_ntsc.nes");
        assert_eq!(analysis.region, Region::USA | Region::JAPAN);
        assert_eq!(analysis.region_string, "NTSC (USA/Japan)");
        assert!(analysis.is_nes2_format);
        assert_eq!(analysis.region_byte_value, 0x00);
        assert_eq!(
            analysis.print(),
            "test_rom_nes2_ntsc.nes\n\
             System:       Nintendo Entertainment System (NES)\n\
             Region:       Japan/USA\n\
             NES2.0 Flag 12: 0x00"
        );
        Ok(())
    }

    #[test]
    fn test_analyze_nes2_data_pal() -> Result<(), RomAnalyzerError> {
        // NES 2.0 format, PAL region (value 1)
        let data = generate_nes_header(NesHeaderType::Nes2, 0x01);
        let analysis = analyze_nes_data(&data, "test_rom_nes2_pal.nes")?;

        assert_eq!(analysis.source_name, "test_rom_nes2_pal.nes");
        assert_eq!(analysis.region, Region::EUROPE);
        assert_eq!(analysis.region_string, "PAL (Europe/Oceania)");
        assert!(analysis.is_nes2_format);
        assert_eq!(analysis.region_byte_value, 0x01);
        Ok(())
    }

    #[test]
    fn test_analyze_nes2_data_world() -> Result<(), RomAnalyzerError> {
        // NES 2.0 format, Multi-region (value 2)
        let data = generate_nes_header(NesHeaderType::Nes2, 0x02);
        let analysis = analyze_nes_data(&data, "test_rom_nes2_world.nes")?;

        assert_eq!(analysis.source_name, "test_rom_nes2_world.nes");
        assert_eq!(
            analysis.region,
            Region::USA | Region::JAPAN | Region::EUROPE
        );
        assert_eq!(analysis.region_string, "Multi-region");
        assert!(analysis.is_nes2_format);
        assert_eq!(analysis.region_byte_value, 0x02);
        assert_eq!(
            analysis.print(),
            "test_rom_nes2_world.nes\n\
             System:       Nintendo Entertainment System (NES)\n\
             Region:       Japan/USA/Europe\n\
             NES2.0 Flag 12: 0x02"
        );
        Ok(())
    }

    #[test]
    fn test_analyze_nes2_data_dendy() -> Result<(), RomAnalyzerError> {
        // NES 2.0 format, Dendy (Russia) (value 3)
        let data = generate_nes_header(NesHeaderType::Nes2, 0x03);
        let analysis = analyze_nes_data(&data, "test_rom_nes2_dendy.nes")?;

        assert_eq!(analysis.source_name, "test_rom_nes2_dendy.nes");
        assert_eq!(analysis.region, Region::RUSSIA);
        assert_eq!(analysis.region_string, "Dendy (Russia)");
        assert!(analysis.is_nes2_format);
        assert_eq!(analysis.region_byte_value, 0x03);
        Ok(())
    }

    #[test]
    fn test_analyze_nes_data_too_small() {
        // Test with data smaller than the header size
        let data = vec![0; 10];
        let result = analyze_nes_data(&data, "too_small.nes");
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("too small"));
    }

    #[test]
    fn test_analyze_nes_invalid_signature() {
        // Test with incorrect signature
        let mut data = vec![0; 16];
        data[0..4].copy_from_slice(b"XXXX"); // Invalid signature
        let result = analyze_nes_data(&data, "invalid_sig.nes");
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("Invalid iNES header signature")
        );
    }
}