rom_analyzer/console/
genesis.rs

1//! Provides header analysis functionality for Sega Genesis (also known as Mega Drive) ROMs.
2//!
3//! This module can parse Genesis ROM headers to extract system type, game titles
4//! (domestic and international), and region information.
5//!
6//! Genesis header documentation referenced here:
7//! <https://plutiedev.com/rom-header#system>
8
9use log::error;
10use serde::Serialize;
11
12use crate::error::RomAnalyzerError;
13use crate::region::{Region, check_region_mismatch};
14use crate::{SEGA_GENESIS_SIG, SEGA_MEGA_DRIVE_SIG};
15
16const SYSTEM_TYPE_START: usize = 0x100;
17const SYSTEM_TYPE_END: usize = 0x110;
18const DOMESTIC_TITLE_START: usize = 0x120;
19const DOMESTIC_TITLE_END: usize = 0x150;
20const INTL_TITLE_START: usize = 0x150;
21const INTL_TITLE_END: usize = 0x180;
22const REGION_CODE_BYTE: usize = 0x1F0;
23
24/// Struct to hold the analysis results for a Sega cartridge (Genesis/Mega Drive) ROM.
25#[derive(Debug, PartialEq, Clone, Serialize)]
26pub struct GenesisAnalysis {
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., "USA (NTSC-U)").
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 region code byte.
36    pub region_code_byte: u8,
37    /// The detected console name (e.g., "SEGA MEGA DRIVE", "SEGA GENESIS").
38    pub console_name: String,
39    /// The domestic game title extracted from the ROM header.
40    pub game_title_domestic: String,
41    /// The international game title extracted from the ROM header.
42    pub game_title_international: String,
43}
44
45impl GenesisAnalysis {
46    /// Returns a printable String of the analysis results.
47    pub fn print(&self) -> String {
48        format!(
49            "{}\n\
50             System:       {}\n\
51             Game Title (Domestic): {}\n\
52             Game Title (Int.):   {}\n\
53             Region Code:  0x{:02X} ('{}')\n\
54             Region:       {}",
55            self.source_name,
56            self.console_name,
57            self.game_title_domestic,
58            self.game_title_international,
59            self.region_code_byte,
60            self.region_code_byte as char,
61            self.region
62        )
63    }
64}
65
66/// Determines the Sega Genesis/Mega Drive game region name based on a given region byte.
67///
68/// The region byte typically comes from the ROM header. This function extracts the relevant bits
69/// from the byte and maps it to a human-readable region string and a Region bitmask.
70///
71/// # Arguments
72///
73/// * `region_byte` - The byte containing the region code, usually found in the ROM header.
74///
75/// # Returns
76///
77/// A tuple containing:
78/// - A `&'static str` representing the region as written in the ROM header (e.g., "USA (NTSC-U)",
79///   "Europe (PAL)") or "Unknown" if the region code is not recognized.
80/// - A [`Region`] bitmask representing the region(s) associated with the code.
81///
82/// # Examples
83///
84/// ```rust
85/// use rom_analyzer::console::genesis::map_region;
86/// use rom_analyzer::region::Region;
87///
88/// let (region_str, region_mask) = map_region(b'U');
89/// assert_eq!(region_str, "USA (NTSC-U)");
90/// assert_eq!(region_mask, Region::USA);
91///
92/// let (region_str, region_mask) = map_region(b'J');
93/// assert_eq!(region_str, "Japan (NTSC-J)");
94/// assert_eq!(region_mask, Region::JAPAN);
95///
96/// let (region_str, region_mask) = map_region(b'X');
97/// assert_eq!(region_str, "Unknown");
98/// assert_eq!(region_mask, Region::UNKNOWN);
99///
100/// let (region_str, region_mask) = map_region(0x34);
101/// assert_eq!(region_str, "USA/Europe (NTSC/PAL)");
102/// assert!(region_mask.contains(Region::USA));
103/// assert!(region_mask.contains(Region::EUROPE));
104/// ```
105pub fn map_region(region_byte: u8) -> (&'static str, Region) {
106    match region_byte {
107        b'J' => ("Japan (NTSC-J)", Region::JAPAN),
108        b'U' => ("USA (NTSC-U)", Region::USA),
109        b'E' => ("Europe (PAL)", Region::EUROPE),
110        b'A' => ("Asia (NTSC)", Region::ASIA),
111        b'B' => ("Brazil (PAL-M)", Region::EUROPE),
112        b'C' => ("China (NTSC)", Region::CHINA),
113        b'F' => ("France (PAL)", Region::EUROPE),
114        b'K' => ("Korea (NTSC)", Region::KOREA),
115        b'L' => ("UK (PAL)", Region::EUROPE),
116        b'S' => ("Scandinavia (PAL)", Region::EUROPE),
117        b'T' => ("Taiwan (NTSC)", Region::ASIA),
118        0x34 => ("USA/Europe (NTSC/PAL)", Region::USA | Region::EUROPE),
119        _ => ("Unknown", Region::UNKNOWN),
120    }
121}
122
123/// Analyzes Sega Genesis/Mega Drive ROM data.
124///
125/// This function reads the ROM header to extract the console name (e.g., "SEGA MEGA DRIVE", "SEGA
126/// GENESIS"), domestic and international game titles, and the region code byte. It then maps the
127/// region code to a human-readable region name and performs a region mismatch check against the
128/// `source_name`.  A warning is logged if an unexpected Sega header signature is found.
129///
130/// # Arguments
131///
132/// * `data` - A byte slice (`&[u8]`) containing the raw ROM data.
133/// * `source_name` - The name of the ROM file, used for logging and region mismatch checks.
134///
135/// # Returns
136///
137/// A `Result` which is:
138/// - `Ok`([`GenesisAnalysis`]) containing the detailed analysis results.
139/// - `Err`([`RomAnalyzerError`]) if the ROM data is too small to contain a valid Sega header.
140pub fn analyze_genesis_data(
141    data: &[u8],
142    source_name: &str,
143) -> Result<GenesisAnalysis, RomAnalyzerError> {
144    // Sega Genesis/Mega Drive header is at offset 0x100. It's 256 bytes long.
145    // The region byte is at offset 0x1F0 (relative to ROM start).
146    const HEADER_SIZE: usize = 0x200; // Minimum size to contain the header and region byte.
147    if data.len() < HEADER_SIZE {
148        return Err(RomAnalyzerError::DataTooSmall {
149            file_size: data.len(),
150            required_size: HEADER_SIZE,
151            details: "Sega header".to_string(),
152        });
153    }
154
155    // Verify Sega header signature "SEGA MEGA DRIVE " or "SEGA GENESIS"
156    // This is not strictly necessary for region analysis but good for validation.
157    let console_name_bytes = &data[SYSTEM_TYPE_START..SYSTEM_TYPE_END];
158    let console_name = String::from_utf8_lossy(console_name_bytes)
159        .trim_matches(char::from(0))
160        .trim()
161        .to_string();
162
163    // If the signature doesn't match, it might still be a valid ROM but with a different header convention.
164    // We'll proceed with analysis but log a warning if the console name is unexpected.
165    let is_valid_signature = console_name_bytes.starts_with(SEGA_MEGA_DRIVE_SIG)
166        || console_name_bytes.starts_with(SEGA_GENESIS_SIG);
167    if !is_valid_signature {
168        error!(
169            "[!] Warning: Unexpected Sega header signature for {} at 0x{:x}. Found: '{}'",
170            source_name, SYSTEM_TYPE_START, console_name
171        );
172    }
173
174    // Game Title - Domestic (48 bytes, null-terminated)
175    let game_title_domestic =
176        String::from_utf8_lossy(&data[DOMESTIC_TITLE_START..DOMESTIC_TITLE_END])
177            .trim_matches(char::from(0))
178            .trim()
179            .to_string();
180    // Game Title - International (48 bytes, null-terminated)
181    let game_title_international = String::from_utf8_lossy(&data[INTL_TITLE_START..INTL_TITLE_END])
182        .trim_matches(char::from(0))
183        .trim()
184        .to_string();
185
186    // Region Code byte is at offset 0x1F0 (which is 0xF0 relative to header_start)
187    let region_code_byte = data[REGION_CODE_BYTE];
188
189    let (region_name, region) = map_region(region_code_byte);
190
191    let region_mismatch = check_region_mismatch(source_name, region);
192
193    Ok(GenesisAnalysis {
194        source_name: source_name.to_string(),
195        region,
196        region_string: region_name.to_string(),
197        region_mismatch,
198        region_code_byte,
199        console_name,
200        game_title_domestic,
201        game_title_international,
202    })
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    /// Helper function to generate a minimal Sega cartridge header for testing.
210    fn generate_genesis_header(
211        console_sig: &[u8],
212        region_byte: u8,
213        domestic_title: &str,
214        international_title: &str,
215    ) -> Vec<u8> {
216        let mut data = vec![0; 0x200]; // Ensure enough space for header and region byte.
217
218        // Console Name/Signature (16 bytes at 0x100)
219        data[SYSTEM_TYPE_START..SYSTEM_TYPE_END].copy_from_slice(console_sig);
220
221        // Game Title - Domestic (32 bytes, null-terminated)
222        let mut domestic_title_bytes = domestic_title.as_bytes().to_vec();
223        domestic_title_bytes.resize(48, 0);
224        data[DOMESTIC_TITLE_START..DOMESTIC_TITLE_END].copy_from_slice(&domestic_title_bytes);
225
226        // Game Title - International (32 bytes, null-terminated)
227        let mut international_title_bytes = international_title.as_bytes().to_vec();
228        international_title_bytes.resize(48, 0);
229        data[INTL_TITLE_START..INTL_TITLE_END].copy_from_slice(&international_title_bytes);
230
231        // Region Code byte at 0x1F0
232        data[REGION_CODE_BYTE] = region_byte;
233
234        data
235    }
236
237    #[test]
238    fn test_analyze_genesis_data_usa() -> Result<(), RomAnalyzerError> {
239        let data =
240            generate_genesis_header(b"SEGA MEGA DRIVE ", b'U', "DOMESTIC US", "INTERNATIONAL US");
241        let analysis = analyze_genesis_data(&data, "test_rom_us.md")?;
242
243        assert_eq!(analysis.source_name, "test_rom_us.md");
244        assert_eq!(analysis.console_name, "SEGA MEGA DRIVE");
245        assert_eq!(analysis.game_title_domestic, "DOMESTIC US");
246        assert_eq!(analysis.game_title_international, "INTERNATIONAL US");
247        assert_eq!(analysis.region_code_byte, b'U');
248        assert_eq!(analysis.region, Region::USA);
249        assert_eq!(analysis.region_string, "USA (NTSC-U)");
250        assert_eq!(
251            analysis.print(),
252            "test_rom_us.md\n\
253             System:       SEGA MEGA DRIVE\n\
254             Game Title (Domestic): DOMESTIC US\n\
255             Game Title (Int.):   INTERNATIONAL US\n\
256             Region Code:  0x55 ('U')\n\
257             Region:       USA"
258        );
259        Ok(())
260    }
261
262    #[test]
263    fn test_analyze_genesis_data_japan() -> Result<(), RomAnalyzerError> {
264        let data =
265            generate_genesis_header(b"SEGA MEGA DRIVE ", b'J', "DOMESTIC JP", "INTERNATIONAL JP");
266        let analysis = analyze_genesis_data(&data, "test_rom_jp.md")?;
267
268        assert_eq!(analysis.source_name, "test_rom_jp.md");
269        assert_eq!(analysis.console_name, "SEGA MEGA DRIVE");
270        assert_eq!(analysis.game_title_domestic, "DOMESTIC JP");
271        assert_eq!(analysis.game_title_international, "INTERNATIONAL JP");
272        assert_eq!(analysis.region_code_byte, b'J');
273        assert_eq!(analysis.region, Region::JAPAN);
274        assert_eq!(analysis.region_string, "Japan (NTSC-J)");
275        Ok(())
276    }
277
278    #[test]
279    fn test_analyze_genesis_data_brazil() -> Result<(), RomAnalyzerError> {
280        let data = generate_genesis_header(b"SEGA MEGA DRIVE ", b'B', "DOMESTIC BRA", "INT BRA");
281        let analysis = analyze_genesis_data(&data, "test_rom_bra.md")?;
282
283        assert_eq!(analysis.source_name, "test_rom_bra.md");
284        assert_eq!(analysis.region, Region::EUROPE); // Brazil is PAL
285        assert_eq!(analysis.region_string, "Brazil (PAL-M)");
286        assert_eq!(analysis.region_code_byte, b'B');
287        Ok(())
288    }
289
290    #[test]
291    fn test_analyze_genesis_data_genesis_signature() -> Result<(), RomAnalyzerError> {
292        let data = generate_genesis_header(b"SEGA GENESIS    ", b'U', "GENESIS DOM", "GENESIS INT");
293        let analysis = analyze_genesis_data(&data, "test_rom_genesis.gen")?;
294
295        assert_eq!(analysis.source_name, "test_rom_genesis.gen");
296        assert_eq!(analysis.console_name, "SEGA GENESIS");
297        assert_eq!(analysis.region_code_byte, b'U');
298        assert_eq!(analysis.region, Region::USA);
299        assert_eq!(analysis.region_string, "USA (NTSC-U)");
300        Ok(())
301    }
302
303    #[test]
304    fn test_analyze_genesis_data_asia() -> Result<(), RomAnalyzerError> {
305        let data = generate_genesis_header(b"SEGA MEGA DRIVE ", b'A', "DOMESTIC ASIA", "INT ASIA");
306        let analysis = analyze_genesis_data(&data, "test_rom_asia.md")?;
307
308        assert_eq!(analysis.source_name, "test_rom_asia.md");
309        assert_eq!(analysis.region, Region::ASIA);
310        assert_eq!(analysis.region_string, "Asia (NTSC)");
311        assert_eq!(analysis.region_code_byte, b'A');
312        Ok(())
313    }
314
315    #[test]
316    fn test_analyze_genesis_data_china() -> Result<(), RomAnalyzerError> {
317        let data =
318            generate_genesis_header(b"SEGA MEGA DRIVE ", b'C', "DOMESTIC CHINA", "INT CHINA");
319        let analysis = analyze_genesis_data(&data, "test_rom_chn.md")?;
320
321        assert_eq!(analysis.source_name, "test_rom_chn.md");
322        assert_eq!(analysis.region, Region::CHINA);
323        assert_eq!(analysis.region_string, "China (NTSC)");
324        assert_eq!(analysis.region_code_byte, b'C');
325        Ok(())
326    }
327
328    #[test]
329    fn test_analyze_genesis_data_too_small() {
330        // Test with data smaller than the minimum required size for analysis.
331        let data = vec![0; 100]; // Smaller than 0x200
332        let result = analyze_genesis_data(&data, "too_small.md");
333        assert!(result.is_err());
334        assert!(result.unwrap_err().to_string().contains("too small"));
335    }
336
337    #[test]
338    fn test_map_region_all_codes() {
339        // Test all known region codes to catch "delete match arm" mutations
340        let test_cases = vec![
341            (b'J', "Japan (NTSC-J)", Region::JAPAN),
342            (b'U', "USA (NTSC-U)", Region::USA),
343            (b'E', "Europe (PAL)", Region::EUROPE),
344            (b'A', "Asia (NTSC)", Region::ASIA),
345            (b'B', "Brazil (PAL-M)", Region::EUROPE),
346            (b'C', "China (NTSC)", Region::CHINA),
347            (b'F', "France (PAL)", Region::EUROPE),
348            (b'K', "Korea (NTSC)", Region::KOREA),
349            (b'L', "UK (PAL)", Region::EUROPE),
350            (b'S', "Scandinavia (PAL)", Region::EUROPE),
351            (b'T', "Taiwan (NTSC)", Region::ASIA),
352            (0x34, "USA/Europe (NTSC/PAL)", Region::USA | Region::EUROPE),
353            (b'Z', "Unknown", Region::UNKNOWN), // Unknown byte
354        ];
355        for (code, expected_name, expected_region) in test_cases {
356            let (name, region) = map_region(code);
357            assert_eq!(name, expected_name, "Failed for code 0x{:02X}", code);
358            assert_eq!(region, expected_region, "Failed for code 0x{:02X}", code);
359        }
360    }
361}