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