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 std::error::Error;
10
11use log::error;
12use serde::Serialize;
13
14use crate::error::RomAnalyzerError;
15use crate::region::{Region, check_region_mismatch};
16
17const SYSTEM_TYPE_START: usize = 0x100;
18const SYSTEM_TYPE_END: usize = 0x110;
19const DOMESTIC_TITLE_START: usize = 0x120;
20const DOMESTIC_TITLE_END: usize = 0x150;
21const INTL_TITLE_START: usize = 0x150;
22const INTL_TITLE_END: usize = 0x180;
23const REGION_CODE_BYTE: usize = 0x1F0;
24
25/// Struct to hold the analysis results for a Sega cartridge (Genesis/Mega Drive) ROM.
26#[derive(Debug, PartialEq, Clone, Serialize)]
27pub struct GenesisAnalysis {
28    /// The name of the source file.
29    pub source_name: String,
30    /// The identified region(s) as a region::Region bitmask.
31    pub region: Region,
32    /// The identified region name (e.g., "USA (NTSC-U)").
33    pub region_string: String,
34    /// If the region in the ROM header doesn't match the region in the filename.
35    pub region_mismatch: bool,
36    /// The raw region code byte.
37    pub region_code_byte: u8,
38    /// The detected console name (e.g., "SEGA MEGA DRIVE", "SEGA GENESIS").
39    pub console_name: String,
40    /// The domestic game title extracted from the ROM header.
41    pub game_title_domestic: String,
42    /// The international game title extracted from the ROM header.
43    pub game_title_international: String,
44}
45
46impl GenesisAnalysis {
47    /// Returns a printable String of the analysis results.
48    pub fn print(&self) -> String {
49        format!(
50            "{}\n\
51             System:       {}\n\
52             Game Title (Domestic): {}\n\
53             Game Title (Int.):   {}\n\
54             Region Code:  0x{:02X} ('{}')\n\
55             Region:       {}",
56            self.source_name,
57            self.console_name,
58            self.game_title_domestic,
59            self.game_title_international,
60            self.region_code_byte,
61            self.region_code_byte as char,
62            self.region
63        )
64    }
65}
66
67/// Determines the Sega Genesis/Mega Drive game region name based on a given region byte.
68///
69/// The region byte typically comes from the ROM header. This function extracts the relevant bits
70/// from the byte and maps it to a human-readable region string and a Region bitmask.
71///
72/// # Arguments
73///
74/// * `region_byte` - The byte containing the region code, usually found in the ROM header.
75///
76/// # Returns
77///
78/// A tuple containing:
79/// - A `&'static str` representing the region as written in the ROM header (e.g., "USA (NTSC-U)",
80///   "Europe (PAL)") or "Unknown" if the region code is not recognized.
81/// - A `Region` bitmask representing the region(s) associated with the code.
82///
83/// # Examples
84///
85/// ```rust
86/// use rom_analyzer::console::genesis::map_region;
87/// use rom_analyzer::region::Region;
88///
89/// let (region_str, region_mask) = map_region(b'U');
90/// assert_eq!(region_str, "USA (NTSC-U)");
91/// assert_eq!(region_mask, Region::USA);
92///
93/// let (region_str, region_mask) = map_region(b'J');
94/// assert_eq!(region_str, "Japan (NTSC-J)");
95/// assert_eq!(region_mask, Region::JAPAN);
96///
97/// let (region_str, region_mask) = map_region(b'X');
98/// assert_eq!(region_str, "Unknown");
99/// assert_eq!(region_mask, Region::UNKNOWN);
100///
101/// let (region_str, region_mask) = map_region(0x34);
102/// assert_eq!(region_str, "USA/Europe (NTSC/PAL)");
103/// assert!(region_mask.contains(Region::USA));
104/// assert!(region_mask.contains(Region::EUROPE));
105/// ```
106pub fn map_region(region_byte: u8) -> (&'static str, Region) {
107    match region_byte {
108        b'J' => ("Japan (NTSC-J)", Region::JAPAN),
109        b'U' => ("USA (NTSC-U)", Region::USA),
110        b'E' => ("Europe (PAL)", Region::EUROPE),
111        b'A' => ("Asia (NTSC)", Region::ASIA),
112        b'B' => ("Brazil (PAL-M)", Region::EUROPE),
113        b'C' => ("China (NTSC)", Region::CHINA),
114        b'F' => ("France (PAL)", Region::EUROPE),
115        b'K' => ("Korea (NTSC)", Region::KOREA),
116        b'L' => ("UK (PAL)", Region::EUROPE),
117        b'S' => ("Scandinavia (PAL)", Region::EUROPE),
118        b'T' => ("Taiwan (NTSC)", Region::ASIA),
119        0x34 => ("USA/Europe (NTSC/PAL)", Region::USA | Region::EUROPE),
120        _ => ("Unknown", Region::UNKNOWN),
121    }
122}
123
124/// Analyzes Sega Genesis/Mega Drive ROM data.
125///
126/// This function reads the ROM header to extract the console name (e.g., "SEGA MEGA DRIVE", "SEGA
127/// GENESIS"), domestic and international game titles, and the region code byte. It then maps the
128/// region code to a human-readable region name and performs a region mismatch check against the
129/// `source_name`.  A warning is logged if an unexpected Sega header signature is found.
130///
131/// # Arguments
132///
133/// * `data` - A byte slice (`&[u8]`) containing the raw ROM data.
134/// * `source_name` - The name of the ROM file, used for logging and region mismatch checks.
135///
136/// # Returns
137///
138/// A `Result` which is:
139/// - `Ok(GenesisAnalysis)` containing the detailed analysis results.
140/// - `Err(Box<dyn Error>)` if the ROM data is too small to contain a valid Sega header.
141pub fn analyze_genesis_data(
142    data: &[u8],
143    source_name: &str,
144) -> Result<GenesisAnalysis, Box<dyn Error>> {
145    // Sega Genesis/Mega Drive header is at offset 0x100. It's 256 bytes long.
146    // The region byte is at offset 0x1F0 (relative to ROM start).
147    const HEADER_SIZE: usize = 0x200; // Minimum size to contain the header and region byte.
148    if data.len() < HEADER_SIZE {
149        return Err(Box::new(RomAnalyzerError::new(&format!(
150            "ROM data is too small to contain a Sega header (size: {} bytes, requires at least {} bytes).",
151            data.len(),
152            HEADER_SIZE
153        ))));
154    }
155
156    // Verify Sega header signature "SEGA MEGA DRIVE " or "SEGA GENESIS"
157    // This is not strictly necessary for region analysis but good for validation.
158    let console_name_bytes = &data[SYSTEM_TYPE_START..SYSTEM_TYPE_END];
159    let console_name = String::from_utf8_lossy(console_name_bytes)
160        .trim_matches(char::from(0))
161        .trim()
162        .to_string();
163
164    // If the signature doesn't match, it might still be a valid ROM but with a different header convention.
165    // We'll proceed with analysis but log a warning if the console name is unexpected.
166    let is_valid_signature = console_name == "SEGA MEGA DRIVE" || console_name == "SEGA GENESIS";
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    use std::error::Error;
209
210    /// Helper function to generate a minimal Sega cartridge header for testing.
211    fn generate_genesis_header(
212        console_sig: &[u8],
213        region_byte: u8,
214        domestic_title: &str,
215        international_title: &str,
216    ) -> Vec<u8> {
217        let mut data = vec![0; 0x200]; // Ensure enough space for header and region byte.
218
219        // Console Name/Signature (16 bytes at 0x100)
220        data[SYSTEM_TYPE_START..SYSTEM_TYPE_END].copy_from_slice(console_sig);
221
222        // Game Title - Domestic (32 bytes, null-terminated)
223        let mut domestic_title_bytes = domestic_title.as_bytes().to_vec();
224        domestic_title_bytes.resize(48, 0);
225        data[DOMESTIC_TITLE_START..DOMESTIC_TITLE_END].copy_from_slice(&domestic_title_bytes);
226
227        // Game Title - International (32 bytes, null-terminated)
228        let mut international_title_bytes = international_title.as_bytes().to_vec();
229        international_title_bytes.resize(48, 0);
230        data[INTL_TITLE_START..INTL_TITLE_END].copy_from_slice(&international_title_bytes);
231
232        // Region Code byte at 0x1F0
233        data[REGION_CODE_BYTE] = region_byte;
234
235        data
236    }
237
238    #[test]
239    fn test_analyze_genesis_data_usa() -> Result<(), Box<dyn Error>> {
240        let data =
241            generate_genesis_header(b"SEGA MEGA DRIVE ", b'U', "DOMESTIC US", "INTERNATIONAL US");
242        let analysis = analyze_genesis_data(&data, "test_rom_us.md")?;
243
244        assert_eq!(analysis.source_name, "test_rom_us.md");
245        assert_eq!(analysis.console_name, "SEGA MEGA DRIVE");
246        assert_eq!(analysis.game_title_domestic, "DOMESTIC US");
247        assert_eq!(analysis.game_title_international, "INTERNATIONAL US");
248        assert_eq!(analysis.region_code_byte, b'U');
249        assert_eq!(analysis.region, Region::USA);
250        assert_eq!(analysis.region_string, "USA (NTSC-U)");
251        Ok(())
252    }
253
254    #[test]
255    fn test_analyze_genesis_data_japan() -> Result<(), Box<dyn Error>> {
256        let data =
257            generate_genesis_header(b"SEGA MEGA DRIVE ", b'J', "DOMESTIC JP", "INTERNATIONAL JP");
258        let analysis = analyze_genesis_data(&data, "test_rom_jp.md")?;
259
260        assert_eq!(analysis.source_name, "test_rom_jp.md");
261        assert_eq!(analysis.console_name, "SEGA MEGA DRIVE");
262        assert_eq!(analysis.game_title_domestic, "DOMESTIC JP");
263        assert_eq!(analysis.game_title_international, "INTERNATIONAL JP");
264        assert_eq!(analysis.region_code_byte, b'J');
265        assert_eq!(analysis.region, Region::JAPAN);
266        assert_eq!(analysis.region_string, "Japan (NTSC-J)");
267        Ok(())
268    }
269
270    #[test]
271    fn test_analyze_genesis_data_europe() -> Result<(), Box<dyn Error>> {
272        let data = generate_genesis_header(
273            b"SEGA MEGA DRIVE ",
274            b'E',
275            "DOMESTIC EUR",
276            "INTERNATIONAL EUR",
277        );
278        let analysis = analyze_genesis_data(&data, "test_rom_eur.md")?;
279
280        assert_eq!(analysis.source_name, "test_rom_eur.md");
281        assert_eq!(analysis.console_name, "SEGA MEGA DRIVE");
282        assert_eq!(analysis.game_title_domestic, "DOMESTIC EUR");
283        assert_eq!(analysis.game_title_international, "INTERNATIONAL EUR");
284        assert_eq!(analysis.region_code_byte, b'E');
285        assert_eq!(analysis.region, Region::EUROPE);
286        assert_eq!(analysis.region_string, "Europe (PAL)");
287        Ok(())
288    }
289
290    #[test]
291    fn test_analyze_genesis_data_genesis_signature() -> Result<(), Box<dyn Error>> {
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_unknown_region() -> Result<(), Box<dyn Error>> {
305        let data = generate_genesis_header(
306            b"SEGA MEGA DRIVE ",
307            b'Z',
308            "DOMESTIC UNK",
309            "INTERNATIONAL UNK",
310        );
311        let analysis = analyze_genesis_data(&data, "test_rom_unknown.md")?;
312
313        assert_eq!(analysis.source_name, "test_rom_unknown.md");
314        assert_eq!(analysis.region, Region::UNKNOWN);
315        assert_eq!(analysis.region_string, "Unknown");
316        assert_eq!(analysis.region_code_byte, b'Z');
317        Ok(())
318    }
319
320    #[test]
321    fn test_analyze_genesis_data_too_small() {
322        // Test with data smaller than the minimum required size for analysis.
323        let data = vec![0; 100]; // Smaller than 0x200
324        let result = analyze_genesis_data(&data, "too_small.md");
325        assert!(result.is_err());
326        assert!(result.unwrap_err().to_string().contains("too small"));
327    }
328}