rom_analyzer/console/
gb.rs

1//! Provides header analysis functionality for Game Boy (GB) and Game Boy Color (GBC) ROMs.
2//!
3//! This module can parse GB/GBC ROM headers to extract game title, system type,
4//! and region information.
5//!
6//! Gameboy/Color header documentation referenced here:
7//! <https://gbdev.io/pandocs/The_Cartridge_Header.html>
8
9use serde::Serialize;
10
11use crate::error::RomAnalyzerError;
12use crate::region::{Region, check_region_mismatch};
13
14const GB_TITLE_START: usize = 0x134;
15const GB_TITLE_END: usize = 0x143;
16const GB_DESTINATION: usize = 0x14A;
17
18const GBC_SYSTEM_TYPE: usize = 0x143;
19const GBC_TITLE_END: usize = 0x13F;
20
21/// Struct to hold the analysis results for a Game Boy ROM.
22#[derive(Debug, PartialEq, Clone, Serialize)]
23pub struct GbAnalysis {
24    /// The name of the source file.
25    pub source_name: String,
26    /// The identified region(s) as a region::Region bitmask.
27    pub region: Region,
28    /// The identified region name (e.g., "Japan").
29    pub region_string: String,
30    /// If the region in the ROM header doesn't match the region in the filename.
31    pub region_mismatch: bool,
32    /// The identified system type (e.g., "Game Boy (GB)" or "Game Boy Color (GBC)").
33    pub system_type: String,
34    /// The game title extracted from the ROM header.
35    pub game_title: String,
36    /// The raw destination code byte.
37    pub destination_code: u8,
38}
39
40impl GbAnalysis {
41    /// Returns a printable String of the analysis results.
42    pub fn print(&self) -> String {
43        format!(
44            "{}\n\
45             System:       {}\n\
46             Game Title:   {}\n\
47             Region Code:  0x{:02X}\n\
48             Region:       {}",
49            self.source_name, self.system_type, self.game_title, self.destination_code, self.region
50        )
51    }
52}
53
54/// Determines the Game Boy game region based on a given region byte.
55///
56/// The region byte typically comes from the ROM header. This function extracts the relevant bits
57/// from the byte and maps it to a human-readable region string and a Region bitmask.
58///
59/// # Arguments
60///
61/// * `region_byte` - The byte containing the region code, usually found in the ROM header.
62///
63/// # Returns
64///
65/// A tuple containing:
66/// - A `&'static str` representing the region as written in the ROM header (e.g., "Japan",
67///   "Non-Japan (International)") or "Unknown" if the region code is not recognized.
68/// - A [`Region`] bitmask representing the region(s) associated with the code.
69///
70/// # Examples
71///
72/// ```rust
73/// use rom_analyzer::console::gb::map_region;
74/// use rom_analyzer::region::Region;
75///
76/// let (region_str, region_mask) = map_region(0x00);
77/// assert_eq!(region_str, "Japan");
78/// assert_eq!(region_mask, Region::JAPAN);
79///
80/// let (region_str, region_mask) = map_region(0x01);
81/// assert_eq!(region_str, "Non-Japan (International)");
82/// assert_eq!(region_mask, Region::USA | Region::EUROPE);
83///
84/// let (region_str, region_mask) = map_region(0x02);
85/// assert_eq!(region_str, "Unknown");
86/// assert_eq!(region_mask, Region::UNKNOWN);
87/// ```
88pub fn map_region(region_byte: u8) -> (&'static str, Region) {
89    match region_byte {
90        0x00 => ("Japan", Region::JAPAN),
91        0x01 => ("Non-Japan (International)", Region::USA | Region::EUROPE),
92        _ => ("Unknown", Region::UNKNOWN),
93    }
94}
95
96/// Analyzes Game Boy (GB) and Game Boy Color (GBC) ROM data.
97///
98/// This function reads the ROM header to determine the system type (GB or GBC),
99/// extract the game title and identify the destination code which indicates the region.
100/// It also performs a region mismatch check against the `source_name`.
101///
102/// # Arguments
103///
104/// * `data` - A byte slice (`&[u8]`) containing the raw ROM data.
105/// * `source_name` - The name of the ROM file, used for region mismatch checks.
106///
107/// # Returns
108///
109/// A `Result` which is:
110/// - `Ok`([`GbAnalysis`]) containing the detailed analysis results.
111/// - `Err`([`RomAnalyzerError`]) if the ROM data is too small to contain a valid header.
112pub fn analyze_gb_data(data: &[u8], source_name: &str) -> Result<GbAnalysis, RomAnalyzerError> {
113    // The Game Boy header is located at offset 0x100.
114    // The relevant information for region and system type are within the first 0x150 bytes.
115    const HEADER_SIZE: usize = 0x150;
116    if data.len() < HEADER_SIZE {
117        return Err(RomAnalyzerError::DataTooSmall {
118            file_size: data.len(),
119            required_size: HEADER_SIZE,
120            details: "Game Boy header".to_string(),
121        });
122    }
123
124    // System type is determined by a specific byte in the header.
125    // 0x80 or 0xC0 indicates GBC
126    let system_type = if data[GBC_SYSTEM_TYPE] == 0x80 || data[GBC_SYSTEM_TYPE] == 0xC0 {
127        "Game Boy Color (GBC)"
128    } else {
129        "Game Boy (GB)"
130    };
131
132    let title_end = if system_type == "Game Boy Color (GBC)" {
133        GBC_TITLE_END
134    } else {
135        GB_TITLE_END
136    };
137    let game_title = String::from_utf8_lossy(&data[GB_TITLE_START..title_end])
138        .trim_matches(char::from(0))
139        .to_string();
140
141    let destination_code = data[GB_DESTINATION];
142    let (region_name, region) = map_region(destination_code);
143
144    let region_mismatch = check_region_mismatch(source_name, region);
145
146    Ok(GbAnalysis {
147        source_name: source_name.to_string(),
148        region,
149        region_string: region_name.to_string(),
150        region_mismatch,
151        system_type: system_type.to_string(),
152        game_title,
153        destination_code,
154    })
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    /// Helper function to generate a minimal Game Boy header for testing.
162    fn generate_gb_header(destination_code: u8, system_byte: u8, title: &str) -> Vec<u8> {
163        let mut data = vec![0; 0x150]; // Ensure enough space for header
164
165        // Signature (usually present, but not strictly required for region/system analysis)
166        data[0x100..0x104].copy_from_slice(b"LOGO"); // Dummy signature
167
168        // Game Title (11 chars for GBC, 15 for GB)
169        let mut title_bytes = title.as_bytes().to_vec();
170        let mut title_length = 11;
171        // Check if GBC
172        if system_byte & 0x80 == 0x00 {
173            title_length = 15;
174        }
175        title_bytes.resize(title_length, 0);
176        data[GB_TITLE_START..(GB_TITLE_START + title_length)].copy_from_slice(&title_bytes);
177
178        data[GB_DESTINATION] = destination_code;
179
180        // System Type Byte
181        data[GBC_SYSTEM_TYPE] = system_byte;
182
183        data
184    }
185
186    #[test]
187    fn test_analyze_gb_data_japan() -> Result<(), RomAnalyzerError> {
188        let data = generate_gb_header(0x00, 0x00, "GAMETITLE"); // Japan, GB
189        let analysis = analyze_gb_data(&data, "test_rom_jp.gb")?;
190
191        assert_eq!(analysis.source_name, "test_rom_jp.gb");
192        assert_eq!(analysis.system_type, "Game Boy (GB)");
193        assert_eq!(analysis.game_title, "GAMETITLE");
194        assert_eq!(analysis.destination_code, 0x00);
195        assert_eq!(analysis.region, Region::JAPAN);
196        assert_eq!(analysis.region_string, "Japan");
197        assert_eq!(
198            analysis.print(),
199            "test_rom_jp.gb\n\
200             System:       Game Boy (GB)\n\
201             Game Title:   GAMETITLE\n\
202             Region Code:  0x00\n\
203             Region:       Japan"
204        );
205        Ok(())
206    }
207
208    #[test]
209    fn test_analyze_gb_data_non_japan() -> Result<(), RomAnalyzerError> {
210        let data = generate_gb_header(0x01, 0x00, "GAMETITLE"); // Non-Japan, GB
211        let analysis = analyze_gb_data(&data, "test_rom_us.gb")?;
212
213        assert_eq!(analysis.source_name, "test_rom_us.gb");
214        assert_eq!(analysis.system_type, "Game Boy (GB)");
215        assert_eq!(analysis.game_title, "GAMETITLE");
216        assert_eq!(analysis.destination_code, 0x01);
217        assert_eq!(analysis.region, Region::USA | Region::EUROPE);
218        assert_eq!(analysis.region_string, "Non-Japan (International)");
219        assert_eq!(
220            analysis.print(),
221            "test_rom_us.gb\n\
222             System:       Game Boy (GB)\n\
223             Game Title:   GAMETITLE\n\
224             Region Code:  0x01\n\
225             Region:       USA/Europe"
226        );
227        Ok(())
228    }
229
230    #[test]
231    fn test_analyze_gbc_data_japan() -> Result<(), RomAnalyzerError> {
232        let data = generate_gb_header(0x00, 0x80, "GBC TITLE"); // Japan, GBC
233        let analysis = analyze_gb_data(&data, "test_rom_jp.gbc")?;
234
235        assert_eq!(analysis.source_name, "test_rom_jp.gbc");
236        assert_eq!(analysis.system_type, "Game Boy Color (GBC)");
237        assert_eq!(analysis.game_title, "GBC TITLE");
238        assert_eq!(analysis.destination_code, 0x00);
239        assert_eq!(analysis.region, Region::JAPAN);
240        assert_eq!(analysis.region_string, "Japan");
241        Ok(())
242    }
243
244    #[test]
245    fn test_analyze_gbc_data_non_japan() -> Result<(), RomAnalyzerError> {
246        let data = generate_gb_header(0x01, 0xC0, "GBC TITLE"); // Non-Japan, GBC (using 0xC0 for system byte)
247        let analysis = analyze_gb_data(&data, "test_rom_eur.gbc")?;
248
249        assert_eq!(analysis.source_name, "test_rom_eur.gbc");
250        assert_eq!(analysis.system_type, "Game Boy Color (GBC)");
251        assert_eq!(analysis.game_title, "GBC TITLE");
252        assert_eq!(analysis.destination_code, 0x01);
253        assert_eq!(analysis.region, Region::USA | Region::EUROPE);
254        assert_eq!(analysis.region_string, "Non-Japan (International)");
255        Ok(())
256    }
257
258    // GB uses 15 bits for title name while GBC uses 11
259    // Test that we properly read longer title names
260    #[test]
261    fn test_analyze_gb_long_title() -> Result<(), RomAnalyzerError> {
262        let data = generate_gb_header(0x00, 0x00, "LOOOOOONG TITLE"); // Japan, GB
263        let analysis = analyze_gb_data(&data, "test_rom_jp.gbc")?;
264
265        assert_eq!(analysis.source_name, "test_rom_jp.gbc");
266        assert_eq!(analysis.system_type, "Game Boy (GB)");
267        assert_eq!(analysis.game_title, "LOOOOOONG TITLE");
268        assert_eq!(analysis.destination_code, 0x00);
269        assert_eq!(analysis.region, Region::JAPAN);
270        assert_eq!(analysis.region_string, "Japan");
271        Ok(())
272    }
273
274    #[test]
275    fn test_analyze_gbc_long_title() -> Result<(), RomAnalyzerError> {
276        let data = generate_gb_header(0x00, 0x80, "LOONG TITLE"); // Japan, GB
277        let analysis = analyze_gb_data(&data, "test_rom_jp.gbc")?;
278
279        assert_eq!(analysis.source_name, "test_rom_jp.gbc");
280        assert_eq!(analysis.system_type, "Game Boy Color (GBC)");
281        assert_eq!(analysis.game_title, "LOONG TITLE");
282        assert_eq!(analysis.destination_code, 0x00);
283        assert_eq!(analysis.region, Region::JAPAN);
284        assert_eq!(analysis.region_string, "Japan");
285        Ok(())
286    }
287
288    #[test]
289    fn test_analyze_gb_unknown_code() -> Result<(), RomAnalyzerError> {
290        let data = generate_gb_header(0x02, 0x00, "UNKNOWN REG"); // Unknown region code
291        let analysis = analyze_gb_data(&data, "test_rom_unknown.gb")?;
292
293        assert_eq!(analysis.source_name, "test_rom_unknown.gb");
294        assert_eq!(analysis.region, Region::UNKNOWN);
295        assert_eq!(analysis.region_string, "Unknown");
296        Ok(())
297    }
298
299    #[test]
300    fn test_analyze_gb_data_too_small() {
301        // Test with data smaller than the minimum required size for analysis.
302        let data = vec![0; 100]; // Smaller than 0x150
303        let result = analyze_gb_data(&data, "too_small.gb");
304        assert!(result.is_err());
305        assert!(result.unwrap_err().to_string().contains("too small"));
306    }
307}