rom_analyzer/console/
gamegear.rs

1//! Provides header analysis functionality for Sega Game Gear ROMs.
2//!
3//! This module can parse Game Gear ROM headers to extract region information
4//! and compare it with region inferences made from the filename.
5//!
6//! Game Gear header documentation referenced here:
7//! <https://www.smspower.org/Development/ROMHeader>
8
9use log::debug;
10use serde::Serialize;
11
12use crate::RomAnalyzerError;
13use crate::region::{Region, check_region_mismatch, infer_region_from_filename};
14
15const POSSIBLE_HEADER_STARTS: &[usize] = &[0x7ff0, 0x3ff0, 0x1ff0];
16const REGION_CODE_OFFSET: usize = 0xf;
17const SEGA_HEADER_SIGNATURE: &[u8] = b"TMR SEGA";
18
19/// Struct to hold the analysis results for a Game Gear ROM.
20#[derive(Debug, PartialEq, Clone, Serialize)]
21pub struct GameGearAnalysis {
22    /// The name of the source file.
23    pub source_name: String,
24    /// The identified region(s) as a region::Region bitmask.
25    pub region: Region,
26    /// The identified region name (e.g., "GameGear Japan").
27    pub region_string: String,
28    /// If the region in the ROM header doesn't match the region in the filename.
29    pub region_mismatch: bool,
30    /// If the region is found in the header, or inferred from the filename.
31    pub region_found: bool,
32}
33
34impl GameGearAnalysis {
35    /// Returns a printable String of the analysis results.
36    pub fn print(&self) -> String {
37        let region_not_in_rom_header = if !self.region_found {
38            "\nNote:         Region information not in ROM header, inferred from filename."
39        } else {
40            ""
41        };
42        format!(
43            "{}\n\
44             System:       Sega Game Gear\n\
45             Region:       {}\
46             {}",
47            self.source_name, self.region, region_not_in_rom_header
48        )
49    }
50}
51
52/// Determines the Game Gear game region name based on a given region byte.
53///
54/// The region byte typically comes from the ROM header. This function extracts the relevant bits
55/// from the byte and maps it to a human-readable region string and a Region bitmask.
56///
57/// # Arguments
58///
59/// * `region_byte` - The byte containing the region code, usually found in the ROM header.
60///
61/// # Returns
62///
63/// A tuple containing:
64/// - A `&'static str` representing the region as written in the ROM header (e.g., "SMS Japan",
65///   "GameGear International") or "Unknown" if the region code is not recognized.
66/// - A [`Region`] bitmask representing the region(s) associated with the code.
67///
68/// # Examples
69///
70/// ```rust
71/// use rom_analyzer::console::gamegear::map_region;
72/// use rom_analyzer::region::Region;
73///
74/// let (region_str, region_mask) = map_region(0x30);
75/// assert_eq!(region_str, "SMS Japan");
76/// assert_eq!(region_mask, Region::JAPAN);
77///
78/// let (region_str, region_mask) = map_region(0x60);
79/// assert_eq!(region_str, "GameGear Export");
80/// assert_eq!(region_mask, Region::USA | Region::EUROPE);
81///
82/// let (region_str, region_mask) = map_region(0x20);
83/// assert_eq!(region_str, "Unknown");
84/// assert_eq!(region_mask, Region::UNKNOWN);
85/// ```
86pub fn map_region(region_byte: u8) -> (&'static str, Region) {
87    let region_code_value: u8 = region_byte >> 4;
88    match region_code_value {
89        0x3 => ("SMS Japan", Region::JAPAN),
90        0x4 => ("SMS Export", Region::USA | Region::EUROPE),
91        0x5 => ("GameGear Japan", Region::JAPAN),
92        0x6 => ("GameGear Export", Region::USA | Region::EUROPE),
93        0x7 => ("GameGear International", Region::USA | Region::EUROPE),
94        _ => ("Unknown", Region::UNKNOWN),
95    }
96}
97
98/// Analyzes a Game Gear ROM and returns a struct containing the analysis results.
99///
100/// This function attempts to locate the 'TMR SEGA' header signature within the ROM data at
101/// predefined offsets. If found, it extracts the region byte and determines the ROM's region.  If
102/// the region cannot be determined from the header or if no header is found, it attempts to infer
103/// the region from the `source_name`.
104///
105/// If a region is found in the header it also checks for mismatches between the inferred and
106/// header-derived regions.
107///
108/// # Arguments
109///
110/// * `data` - A byte slice (`&[u8]`) containing the raw ROM data.
111/// * `source_name` - The name of the ROM file, used for region inference if needed.
112///
113/// # Returns
114///
115/// A `Result` which is:
116/// - `Ok`([`GameGearAnalysis`]) containing the detailed analysis results.
117/// - `Err`([`RomAnalyzerError`]) if any critical error occurs during analysis.
118pub fn analyze_gamegear_data(
119    data: &[u8],
120    source_name: &str,
121) -> Result<GameGearAnalysis, RomAnalyzerError> {
122    // All headered Sega 8-bit ROMs should begin with 'TMR SEGA'
123    // This can exist at one of three locations; 0x1ff0, 0x3ff0 or 0x7ff0
124    let header_start_opt = POSSIBLE_HEADER_STARTS.iter().copied().find(|&offset| {
125        data.get(offset..offset + SEGA_HEADER_SIGNATURE.len()) == Some(SEGA_HEADER_SIGNATURE)
126    });
127
128    let mut region = Region::UNKNOWN;
129    let mut region_name = "Unknown".to_string();
130    let mut region_found = false;
131
132    if let Some(header_start) = header_start_opt {
133        debug!("Found signature at 0x{:x}", header_start);
134        if let Some(&region_byte) = data.get(header_start + REGION_CODE_OFFSET) {
135            let (name, region_val) = map_region(region_byte);
136            region_name = name.to_string();
137            region = region_val;
138            if region != Region::UNKNOWN {
139                region_found = true;
140            }
141        } else {
142            debug!(
143                "ROM too small to read region code from header at 0x{:x}",
144                header_start
145            );
146        }
147    }
148
149    if !region_found {
150        region = infer_region_from_filename(source_name);
151        region_name = region.to_string();
152    }
153
154    let region_mismatch = check_region_mismatch(source_name, region);
155
156    Ok(GameGearAnalysis {
157        source_name: source_name.to_string(),
158        region,
159        region_string: region_name.to_string(),
160        region_mismatch,
161        region_found,
162    })
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    // Helper function to create dummy ROM data with a Game Gear header
170    fn create_rom_data_with_header(header_start: usize, region_code: u8) -> Vec<u8> {
171        let mut data = vec![0; 0x8000]; // Sufficiently large dummy data
172        if data.len() > header_start + REGION_CODE_OFFSET {
173            // Write SEGA header signature
174            data[header_start..header_start + SEGA_HEADER_SIGNATURE.len()]
175                .copy_from_slice(SEGA_HEADER_SIGNATURE);
176            // Write region code
177            data[header_start + REGION_CODE_OFFSET] = region_code;
178        }
179        data
180    }
181
182    #[test]
183    fn test_analyze_gamegear_data_header_signature_present_region_byte_missing()
184    -> Result<(), RomAnalyzerError> {
185        let header_start = 0x7ff0;
186        let signature_len = SEGA_HEADER_SIGNATURE.len();
187        // Create a ROM that has the signature but is too short for the region byte
188        let mut data = vec![0; header_start + signature_len];
189        data[header_start..].copy_from_slice(SEGA_HEADER_SIGNATURE);
190
191        let analysis = analyze_gamegear_data(&data, "my_game_usa.gg")?;
192        assert_eq!(analysis.source_name, "my_game_usa.gg");
193        assert_eq!(analysis.region, Region::USA);
194        assert_eq!(analysis.region_string, "USA");
195        assert!(!analysis.region_found); // Region should be inferred, not found in header
196        Ok(())
197    }
198
199    #[test]
200    fn test_analyze_gamegear_data_header_japan_0x7ff0() -> Result<(), RomAnalyzerError> {
201        // 0x50 >> 4 = 0x5 (GameGear Japan)
202        let data = create_rom_data_with_header(0x7ff0, 0x50);
203        let analysis = analyze_gamegear_data(&data, "test_rom.gg")?;
204        assert_eq!(analysis.source_name, "test_rom.gg");
205        assert_eq!(analysis.region, Region::JAPAN);
206        assert_eq!(analysis.region_string, "GameGear Japan");
207        assert!(analysis.region_found);
208        assert_eq!(
209            analysis.print(),
210            "test_rom.gg\n\
211             System:       Sega Game Gear\n\
212             Region:       Japan"
213        );
214        Ok(())
215    }
216
217    #[test]
218    fn test_analyze_gamegear_data_header_export_0x3ff0() -> Result<(), RomAnalyzerError> {
219        // 0x60 >> 4 = 0x6 (GameGear Export)
220        let data = create_rom_data_with_header(0x3ff0, 0x60);
221        let analysis = analyze_gamegear_data(&data, "test_rom.gg")?;
222        assert_eq!(analysis.source_name, "test_rom.gg");
223        assert_eq!(analysis.region, Region::USA | Region::EUROPE);
224        assert_eq!(analysis.region_string, "GameGear Export");
225        assert!(analysis.region_found);
226        assert_eq!(
227            analysis.print(),
228            "test_rom.gg\n\
229             System:       Sega Game Gear\n\
230             Region:       USA/Europe"
231        );
232        Ok(())
233    }
234
235    #[test]
236    fn test_analyze_gamegear_data_header_international_0x1ff0() -> Result<(), RomAnalyzerError> {
237        // 0x70 >> 4 = 0x7 (GameGear International)
238        let data = create_rom_data_with_header(0x1ff0, 0x70);
239        let analysis = analyze_gamegear_data(&data, "test_rom.gg")?;
240        assert_eq!(analysis.source_name, "test_rom.gg");
241        assert_eq!(analysis.region, Region::USA | Region::EUROPE);
242        assert_eq!(analysis.region_string, "GameGear International");
243        assert!(analysis.region_found);
244        Ok(())
245    }
246
247    #[test]
248    fn test_analyze_gamegear_data_no_header_infer_from_filename() -> Result<(), RomAnalyzerError> {
249        let data = vec![0; 0x8000]; // No header
250        let analysis = analyze_gamegear_data(&data, "my_game_usa.gg")?;
251        assert_eq!(analysis.source_name, "my_game_usa.gg");
252        assert_eq!(analysis.region, Region::USA);
253        assert_eq!(analysis.region_string, "USA");
254        assert!(!analysis.region_found);
255        assert_eq!(
256            analysis.print(),
257            "my_game_usa.gg\n\
258             System:       Sega Game Gear\n\
259             Region:       USA\n\
260             Note:         Region information not in ROM header, inferred from filename."
261        );
262        Ok(())
263    }
264
265    #[test]
266    fn test_analyze_gamegear_data_header_unknown_region_infer_from_filename()
267    -> Result<(), RomAnalyzerError> {
268        // Header exists, but region code (0xF0 >> 4 = 0xF) is unknown, so it should infer from filename.
269        let data = create_rom_data_with_header(0x7ff0, 0xF0);
270        let analysis = analyze_gamegear_data(&data, "my_game_japan.gg")?;
271        assert_eq!(analysis.source_name, "my_game_japan.gg");
272        assert_eq!(analysis.region, Region::JAPAN);
273        assert_eq!(analysis.region_string, "Japan");
274        assert!(!analysis.region_found); // Still false because the header didn't provide a known region
275        Ok(())
276    }
277
278    #[test]
279    fn test_analyze_gamegear_data_get_region_name() {
280        assert_eq!(map_region(0x30), ("SMS Japan", Region::JAPAN));
281        assert_eq!(
282            map_region(0x40),
283            ("SMS Export", Region::USA | Region::EUROPE)
284        );
285        assert_eq!(map_region(0x50), ("GameGear Japan", Region::JAPAN));
286        assert_eq!(
287            map_region(0x60),
288            ("GameGear Export", Region::USA | Region::EUROPE)
289        );
290        assert_eq!(
291            map_region(0x70),
292            ("GameGear International", Region::USA | Region::EUROPE)
293        );
294        assert_eq!(map_region(0x00), ("Unknown", Region::UNKNOWN));
295        assert_eq!(map_region(0xF0), ("Unknown", Region::UNKNOWN));
296    }
297
298    #[test]
299    fn test_analyze_gamegear_data_usa() -> Result<(), RomAnalyzerError> {
300        let data = vec![0; 0x100]; // Dummy data
301        let analysis = analyze_gamegear_data(&data, "test_rom_usa.gg")?;
302        assert_eq!(analysis.source_name, "test_rom_usa.gg");
303        assert_eq!(analysis.region, Region::USA);
304        assert_eq!(analysis.region_string, "USA");
305        Ok(())
306    }
307
308    #[test]
309    fn test_analyze_gamegear_data_japan() -> Result<(), RomAnalyzerError> {
310        let data = vec![0; 0x100]; // Dummy data
311        let analysis = analyze_gamegear_data(&data, "test_rom_jp.gg")?;
312        assert_eq!(analysis.source_name, "test_rom_jp.gg");
313        assert_eq!(analysis.region, Region::JAPAN);
314        assert_eq!(analysis.region_string, "Japan");
315        Ok(())
316    }
317
318    #[test]
319    fn test_analyze_gamegear_data_europe() -> Result<(), RomAnalyzerError> {
320        let data = vec![0; 0x100]; // Dummy data
321        let analysis = analyze_gamegear_data(&data, "test_rom_eur.gg")?;
322        assert_eq!(analysis.source_name, "test_rom_eur.gg");
323        assert_eq!(analysis.region, Region::EUROPE);
324        assert_eq!(analysis.region_string, "Europe");
325        Ok(())
326    }
327
328    #[test]
329    fn test_analyze_gamegear_data_unknown() -> Result<(), RomAnalyzerError> {
330        let data = vec![0; 0x100]; // Dummy data
331        let analysis = analyze_gamegear_data(&data, "test_rom.gg")?;
332        assert_eq!(analysis.source_name, "test_rom.gg");
333        assert_eq!(analysis.region, Region::UNKNOWN);
334        assert_eq!(analysis.region_string, "Unknown");
335        Ok(())
336    }
337}