rom_analyzer/console/
nes.rs

1//! Provides header analysis functionality for Nintendo Entertainment System (NES) ROMs.
2//!
3//! This module supports both iNES and NES 2.0 header formats to extract region
4//! and other relevant information.
5//!
6//! NES header documentation referenced here:
7//! <https://www.nesdev.org/wiki/INES>
8//! <https://www.nesdev.org/wiki/NES_2.0>
9
10use std::error::Error;
11
12use serde::Serialize;
13
14use crate::error::RomAnalyzerError;
15use crate::region::{Region, check_region_mismatch};
16
17const INES_REGION_BYTE: usize = 9;
18const INES_REGION_MASK: u8 = 0x01;
19
20const NES2_REGION_BYTE: usize = 12;
21const NES2_REGION_MASK: u8 = 0x03;
22const NES2_FORMAT_BYTE: usize = 7;
23const NES2_FORMAT_MASK: u8 = 0x0C;
24const NES2_FORMAT_EXPECTED_VALUE: u8 = 0x08;
25
26/// Struct to hold the analysis results for a NES ROM.
27#[derive(Debug, PartialEq, Clone, Serialize)]
28pub struct NesAnalysis {
29    /// The name of the source file.
30    pub source_name: String,
31    /// The identified region(s) as a region::Region bitmask.
32    pub region: Region,
33    /// The identified region name (e.g., "NTSC (USA/Japan)").
34    pub region_string: String,
35    /// If the region in the ROM header doesn't match the region in the filename.
36    pub region_mismatch: bool,
37    /// The raw byte value used for region determination (from iNES flag 9 or NES2 flag 12).
38    pub region_byte_value: u8,
39    /// Whether the ROM header is in NES 2.0 format.
40    pub is_nes2_format: bool,
41}
42
43impl NesAnalysis {
44    /// Returns a printable String of the analysis results.
45    pub fn print(&self) -> String {
46        let nes_flag_display = if self.is_nes2_format {
47            format!("\nNES2.0 Flag 12: 0x{:02X}", self.region_byte_value)
48        } else {
49            format!("\niNES Flag 9:  0x{:02X}", self.region_byte_value)
50        };
51
52        format!(
53            "{}\n\
54             System:       Nintendo Entertainment System (NES)\n\
55             Region:       {}\
56             {}",
57            self.source_name, self.region, nes_flag_display
58        )
59    }
60}
61
62/// Determines the NES region name based on the region byte and header format.
63///
64/// This function interprets the region information from either an iNES or NES 2.0
65/// header, mapping the raw byte value to a human-readable region string.
66///
67/// # Arguments
68///
69/// * `region_byte` - The byte containing the region code (from iNES byte 9 or NES 2.0 byte 12).
70/// * `nes2_format` - A boolean indicating whether the ROM uses the NES 2.0 header format.
71///
72/// # Returns
73///
74/// A tuple containing:
75/// - A `&'static str` representing the region as written in the ROM header (e.g., "Multi-region",
76///  "PAL (Europe/Oceania)", "NTSC (USA/Japan)") or "Unknown" if the region code is not recognized.
77/// - A `Region` bitmask representing the region(s) associated with the code.
78///
79/// # Examples
80///
81/// ```rust
82/// use rom_analyzer::console::nes::map_region;
83/// use rom_analyzer::region::Region;
84///
85/// // Test NES 2.0 format with NTSC region
86/// let (region_str, region_mask) = map_region(0x00, true);
87/// assert_eq!(region_str, "NTSC (USA/Japan)");
88/// assert_eq!(region_mask, Region::USA | Region::JAPAN);
89///
90/// // Test iNES format with PAL region
91/// let (region_str, region_mask) = map_region(0x01, false);
92/// assert_eq!(region_str, "PAL (Europe/Oceania)");
93/// assert_eq!(region_mask, Region::EUROPE);
94/// ```
95pub fn map_region(region_byte: u8, nes2_format: bool) -> (&'static str, Region) {
96    if nes2_format {
97        // NES 2.0 headers store region data in the CPU/PPU timing bit
98        // in byte 12.
99        match region_byte & NES2_REGION_MASK {
100            0 => ("NTSC (USA/Japan)", Region::USA | Region::JAPAN),
101            1 => ("PAL (Europe/Oceania)", Region::EUROPE),
102            2 => ("Multi-region", Region::USA | Region::JAPAN | Region::EUROPE),
103            3 => ("Dendy (Russia)", Region::RUSSIA),
104            _ => ("Unknown", Region::UNKNOWN),
105        }
106    } else {
107        // iNES headers store region data in byte 9.
108        // It is only the lowest-order bit for NTSC vs PAL.
109        // NTSC covers USA and Japan.
110        match region_byte & INES_REGION_MASK {
111            0 => ("NTSC (USA/Japan)", Region::USA | Region::JAPAN),
112            1 => ("PAL (Europe/Oceania)", Region::EUROPE),
113            _ => ("Unknown", Region::UNKNOWN),
114        }
115    }
116}
117
118/// Analyzes NES ROM data.
119///
120/// This function first validates the iNES header signature. It then determines
121/// if the ROM uses the NES 2.0 format or the older iNES format. Based on the
122/// detected format, it extracts the relevant region byte and maps it to a
123/// human-readable region name. A region mismatch check is also performed
124/// against the `source_name`.
125///
126/// # Arguments
127///
128/// * `data` - A byte slice (`&[u8]`) containing the raw ROM data.
129/// * `source_name` - The name of the ROM file, used for region mismatch checks.
130///
131/// # Returns
132///
133/// A `Result` which is:
134/// - `Ok(NesAnalysis)` containing the detailed analysis results.
135/// - `Err(Box<dyn Error>)` if the ROM data is too small or has an invalid iNES signature.
136pub fn analyze_nes_data(data: &[u8], source_name: &str) -> Result<NesAnalysis, Box<dyn Error>> {
137    if data.len() < 16 {
138        return Err(Box::new(RomAnalyzerError::new(&format!(
139            "ROM data is too small to contain an iNES header (size: {} bytes).",
140            data.len()
141        ))));
142    }
143
144    // All headered NES ROMs should begin with 'NES<EOF>'
145    let signature = &data[0..4];
146    if signature != b"NES\x1a" {
147        return Err(Box::new(RomAnalyzerError::new(
148            "Invalid iNES header signature. Not a valid NES ROM.",
149        )));
150    }
151
152    let mut region_byte_val = data[INES_REGION_BYTE];
153    let is_nes2_format = (data[NES2_FORMAT_BYTE] & NES2_FORMAT_MASK) == NES2_FORMAT_EXPECTED_VALUE;
154
155    if is_nes2_format {
156        region_byte_val = data[NES2_REGION_BYTE];
157    }
158
159    let (region_name, region) = map_region(region_byte_val, is_nes2_format);
160    let region_mismatch = check_region_mismatch(source_name, region);
161
162    Ok(NesAnalysis {
163        source_name: source_name.to_string(),
164        region,
165        region_string: region_name.to_string(),
166        region_mismatch,
167        region_byte_value: region_byte_val,
168        is_nes2_format,
169    })
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use std::error::Error;
176
177    // Helper enum to specify header type for generation.
178    enum NesHeaderType {
179        Ines,
180        Nes2,
181    }
182
183    /// Generates a 16-byte NES ROM header for testing.
184    /// configures the header to be either iNES or NES 2.0 format,
185    /// and sets the specified region value.
186    fn generate_nes_header(header_type: NesHeaderType, region_value: u8) -> Vec<u8> {
187        let mut data = vec![0; 16];
188        data[0..4].copy_from_slice(b"NES\x1a"); // Signature
189
190        match header_type {
191            NesHeaderType::Ines => {
192                // iNES format: region is in byte 9. Only the LSB (INES_REGION_MASK) matters.
193                // We set the byte and let map_region handle the masking.
194                data[INES_REGION_BYTE] = region_value;
195                // Ensure NES 2.0 flags are NOT set in byte 7.
196                data[NES2_FORMAT_BYTE] &= !NES2_FORMAT_MASK;
197            }
198            NesHeaderType::Nes2 => {
199                // NES 2.0 format: set NES 2.0 identification bits in byte 7.
200                data[NES2_FORMAT_BYTE] |= NES2_FORMAT_EXPECTED_VALUE;
201                // Region is in byte 12, masked by NES2_REGION_MASK.
202                // We set the byte and let map_region handle the masking.
203                data[NES2_REGION_BYTE] = region_value;
204            }
205        }
206        data
207    }
208
209    #[test]
210    fn test_analyze_ines_data_ntsc() -> Result<(), Box<dyn Error>> {
211        // iNES format, NTSC region (LSB is 0)
212        let data = generate_nes_header(NesHeaderType::Ines, 0x00);
213        let analysis = analyze_nes_data(&data, "test_rom_ntsc.nes")?;
214
215        assert_eq!(analysis.source_name, "test_rom_ntsc.nes");
216        assert_eq!(analysis.region, Region::USA | Region::JAPAN);
217        assert_eq!(analysis.region_string, "NTSC (USA/Japan)");
218        assert!(!analysis.is_nes2_format);
219        assert_eq!(analysis.region_byte_value, 0x00);
220        Ok(())
221    }
222
223    #[test]
224    fn test_analyze_ines_data_pal() -> Result<(), Box<dyn Error>> {
225        // iNES format, PAL region (LSB is 1)
226        let data = generate_nes_header(NesHeaderType::Ines, 0x01);
227        let analysis = analyze_nes_data(&data, "test_rom_pal.nes")?;
228
229        assert_eq!(analysis.source_name, "test_rom_pal.nes");
230        assert_eq!(analysis.region, Region::EUROPE);
231        assert_eq!(analysis.region_string, "PAL (Europe/Oceania)");
232        assert!(!analysis.is_nes2_format);
233        assert_eq!(analysis.region_byte_value, 0x01);
234        Ok(())
235    }
236
237    #[test]
238    fn test_analyze_nes2_data_ntsc() -> Result<(), Box<dyn Error>> {
239        // NES 2.0 format, NTSC region (value 0)
240        let data = generate_nes_header(NesHeaderType::Nes2, 0x00);
241        let analysis = analyze_nes_data(&data, "test_rom_nes2_ntsc.nes")?;
242
243        assert_eq!(analysis.source_name, "test_rom_nes2_ntsc.nes");
244        assert_eq!(analysis.region, Region::USA | Region::JAPAN);
245        assert_eq!(analysis.region_string, "NTSC (USA/Japan)");
246        assert!(analysis.is_nes2_format);
247        assert_eq!(analysis.region_byte_value, 0x00);
248        Ok(())
249    }
250
251    #[test]
252    fn test_analyze_nes2_data_pal() -> Result<(), Box<dyn Error>> {
253        // NES 2.0 format, PAL region (value 1)
254        let data = generate_nes_header(NesHeaderType::Nes2, 0x01);
255        let analysis = analyze_nes_data(&data, "test_rom_nes2_pal.nes")?;
256
257        assert_eq!(analysis.source_name, "test_rom_nes2_pal.nes");
258        assert_eq!(analysis.region, Region::EUROPE);
259        assert_eq!(analysis.region_string, "PAL (Europe/Oceania)");
260        assert!(analysis.is_nes2_format);
261        assert_eq!(analysis.region_byte_value, 0x01);
262        Ok(())
263    }
264
265    #[test]
266    fn test_analyze_nes2_data_world() -> Result<(), Box<dyn Error>> {
267        // NES 2.0 format, Multi-region (value 2)
268        let data = generate_nes_header(NesHeaderType::Nes2, 0x02);
269        let analysis = analyze_nes_data(&data, "test_rom_nes2_world.nes")?;
270
271        assert_eq!(analysis.source_name, "test_rom_nes2_world.nes");
272        assert_eq!(
273            analysis.region,
274            Region::USA | Region::JAPAN | Region::EUROPE
275        );
276        assert_eq!(analysis.region_string, "Multi-region");
277        assert!(analysis.is_nes2_format);
278        assert_eq!(analysis.region_byte_value, 0x02);
279        Ok(())
280    }
281
282    #[test]
283    fn test_analyze_nes2_data_dendy() -> Result<(), Box<dyn Error>> {
284        // NES 2.0 format, Dendy (Russia) (value 3)
285        let data = generate_nes_header(NesHeaderType::Nes2, 0x03);
286        let analysis = analyze_nes_data(&data, "test_rom_nes2_dendy.nes")?;
287
288        assert_eq!(analysis.source_name, "test_rom_nes2_dendy.nes");
289        assert_eq!(analysis.region, Region::RUSSIA);
290        assert_eq!(analysis.region_string, "Dendy (Russia)");
291        assert!(analysis.is_nes2_format);
292        assert_eq!(analysis.region_byte_value, 0x03);
293        Ok(())
294    }
295
296    #[test]
297    fn test_analyze_nes_data_too_small() {
298        // Test with data smaller than the header size
299        let data = vec![0; 10];
300        let result = analyze_nes_data(&data, "too_small.nes");
301        assert!(result.is_err());
302        assert!(result.unwrap_err().to_string().contains("too small"));
303    }
304
305    #[test]
306    fn test_analyze_nes_invalid_signature() {
307        // Test with incorrect signature
308        let mut data = vec![0; 16];
309        data[0..4].copy_from_slice(b"XXXX"); // Invalid signature
310        let result = analyze_nes_data(&data, "invalid_sig.nes");
311        assert!(result.is_err());
312        assert!(
313            result
314                .unwrap_err()
315                .to_string()
316                .contains("Invalid iNES header signature")
317        );
318    }
319}