rom_analyzer/
lib.rs

1//! The `rom_analyzer` crate provides functionality to analyze various ROM file formats from
2//! classic video game consoles. It aims to extract metadata such as region, game title, publisher,
3//! and other console-specific details from ROM headers and file names.
4//!
5//! This library supports a range of console ROMs, including but not limited to NES, SNES, N64,
6//! Sega Master System, Game Gear, Game Boy, Game Boy Advance, Sega Genesis, and Sega CD.  It can
7//! also handle ROMs packaged as ZIP or CHD (Compressed Hunks of Data) archives.
8//!
9//! The primary entry point for analysis is the [`analyze_rom_data`] function, which takes a file
10//! path and returns a [`RomAnalysisResult`] enum containing console-specific analysis data.
11
12pub mod archive;
13pub mod console;
14pub mod error;
15pub mod region;
16
17use std::fs::{self, File};
18use std::path::Path;
19
20use serde::Serialize;
21
22use crate::archive::chd::analyze_chd_file;
23use crate::archive::zip::process_zip_file;
24use crate::console::gamegear::{self, GameGearAnalysis};
25use crate::console::gb::{self, GbAnalysis};
26use crate::console::gba::{self, GbaAnalysis};
27use crate::console::genesis::{self, GenesisAnalysis};
28use crate::console::mastersystem::{self, MasterSystemAnalysis};
29use crate::console::n64::{self, N64Analysis};
30use crate::console::nes::{self, NesAnalysis};
31use crate::console::psx::{self, PsxAnalysis};
32use crate::console::segacd::{self, SegaCdAnalysis};
33use crate::console::snes::{self, SnesAnalysis};
34use crate::error::RomAnalyzerError;
35
36/// A list of file extensions that the ROM analyzer supports.
37/// These extensions are used to determine the type of ROM file being processed.
38pub const SUPPORTED_ROM_EXTENSIONS: &[&str] = &[
39    ".nes", // NES
40    ".smc", ".sfc", // SNES
41    ".n64", ".v64", ".z64", // N64
42    ".sms", // Sega Master System
43    ".gg",  // Sega Game Gear
44    ".md", ".gen", ".32x", // Sega Genesis / 32X
45    ".gb", ".gbc", // Game Boy / Game Boy Color
46    ".gba", // Game Boy Advance
47    ".scd", // Sega CD
48    ".iso", ".bin", ".img", ".psx", // CD Systems
49];
50
51pub const SEGA_MEGA_DRIVE_SIG: &[u8] = b"SEGA MEGA DRIVE";
52pub const SEGA_GENESIS_SIG: &[u8] = b"SEGA GENESIS";
53
54/// Represents the analysis result for a ROM file.
55#[derive(Debug, PartialEq, Clone, Serialize)]
56#[serde(tag = "console")]
57pub enum RomAnalysisResult {
58    GameGear(GameGearAnalysis),
59    GB(GbAnalysis),
60    GBA(GbaAnalysis),
61    Genesis(GenesisAnalysis),
62    MasterSystem(MasterSystemAnalysis),
63    N64(N64Analysis),
64    NES(NesAnalysis),
65    PSX(PsxAnalysis),
66    SegaCD(SegaCdAnalysis),
67    SNES(SnesAnalysis),
68}
69
70/// Represents the type of ROM file based on its extension.
71/// This enum is used internally to dispatch to the correct analysis logic.
72#[derive(Debug, PartialEq, Eq)]
73pub enum RomFileType {
74    Nes,
75    Snes,
76    N64,
77    MasterSystem,
78    GameGear,
79    GameBoy,
80    GameBoyAdvance,
81    Genesis,
82    SegaCD,
83    CDSystem,
84    Unknown,
85}
86
87/// Extracts the file extension from a given file path and converts it to lowercase.
88///
89/// # Arguments
90///
91/// * `file_path` - The path to the file.
92///
93/// # Returns
94///
95/// A `String` containing the lowercase file extension, or an empty string if no
96/// extension is found.
97fn get_file_extension_lowercase(file_path: &str) -> String {
98    Path::new(file_path)
99        .extension()
100        .and_then(std::ffi::OsStr::to_str)
101        .unwrap_or_default()
102        .to_lowercase()
103}
104
105/// Maps a file's **extension** to the corresponding [`RomFileType`] for supported consoles.
106///
107/// The file extension is extracted from the provided name, converted to lowercase
108/// and matched against a predefined list of extensions for different retro gaming systems.
109///
110/// # Arguments
111///
112/// * `name` - The full file name, which may or may not include a path (e.g., `"game/zelda.nes"`).
113///
114/// # Returns
115///
116/// A [`RomFileType`] variant corresponding to the file extension:
117///
118/// * [`RomFileType::Nes`] for `nes`
119/// * [`RomFileType::Snes`] for `smc` or `sfc`
120/// * [`RomFileType::N64`] for `n64`, `v64`, or `z64`
121/// * [`RomFileType::MasterSystem`] for `sms`
122/// * [`RomFileType::GameGear`] for `gg`
123/// * [`RomFileType::GameBoy`] for `gb` or `gbc`
124/// * [`RomFileType::GameBoyAdvance`] for `gba`
125/// * [`RomFileType::Genesis`] for `md`, `gen`, or `32x`
126/// * [`RomFileType::SegaCD`] for `scd`
127/// * [`RomFileType::CDSystem`] for `iso`, `bin`, `img`, `psx`, or `chd`
128/// * [`RomFileType::Unknown`] for any other extension.
129///
130/// # Examples
131///
132/// ```rust
133/// use rom_analyzer::{get_rom_file_type, RomFileType};
134///
135/// let rom_type_nes = get_rom_file_type("game.NES");
136/// assert_eq!(rom_type_nes, RomFileType::Nes);
137///
138/// let rom_type_snes = get_rom_file_type("chrono.sfc");
139/// assert_eq!(rom_type_snes, RomFileType::Snes);
140///
141/// let unknown = get_rom_file_type("document.txt");
142/// assert_eq!(unknown, RomFileType::Unknown);
143/// ```
144pub fn get_rom_file_type(name: &str) -> RomFileType {
145    let ext = get_file_extension_lowercase(name);
146
147    match ext.as_str() {
148        "nes" => RomFileType::Nes,
149        "smc" | "sfc" => RomFileType::Snes,
150        "n64" | "v64" | "z64" => RomFileType::N64,
151        "sms" => RomFileType::MasterSystem,
152        "gg" => RomFileType::GameGear,
153        "gb" | "gbc" => RomFileType::GameBoy,
154        "gba" => RomFileType::GameBoyAdvance,
155        "md" | "gen" | "32x" => RomFileType::Genesis,
156        "scd" => RomFileType::SegaCD,
157        "iso" | "bin" | "img" | "psx" | "chd" => RomFileType::CDSystem,
158        _ => RomFileType::Unknown,
159    }
160}
161
162/// Processes raw ROM data based on its determined file type.
163///
164/// This function takes the raw byte data of a ROM file and its path, determines
165/// the console type using [`get_rom_file_type`] and then dispatches the data to
166/// the appropriate console-specific analysis function.
167///
168/// # Arguments
169///
170/// * `data` - A `Vec<u8>` containing the raw bytes of the ROM file.
171/// * `rom_path` - The path to the ROM file, used to infer the file type.
172///
173/// # Returns
174///
175/// A `Result` containing either a [`RomAnalysisResult`] with the analysis data
176/// or a [`RomAnalyzerError`].
177fn process_rom_data(data: Vec<u8>, rom_path: &str) -> Result<RomAnalysisResult, RomAnalyzerError> {
178    match get_rom_file_type(rom_path) {
179        RomFileType::Nes => nes::analyze_nes_data(&data, rom_path).map(RomAnalysisResult::NES),
180        RomFileType::Snes => snes::analyze_snes_data(&data, rom_path).map(RomAnalysisResult::SNES),
181        RomFileType::N64 => n64::analyze_n64_data(&data, rom_path).map(RomAnalysisResult::N64),
182        RomFileType::MasterSystem => mastersystem::analyze_mastersystem_data(&data, rom_path)
183            .map(RomAnalysisResult::MasterSystem),
184        RomFileType::GameGear => {
185            gamegear::analyze_gamegear_data(&data, rom_path).map(RomAnalysisResult::GameGear)
186        }
187        RomFileType::GameBoy => gb::analyze_gb_data(&data, rom_path).map(RomAnalysisResult::GB),
188        RomFileType::GameBoyAdvance => {
189            gba::analyze_gba_data(&data, rom_path).map(RomAnalysisResult::GBA)
190        }
191        RomFileType::Genesis => {
192            genesis::analyze_genesis_data(&data, rom_path).map(RomAnalysisResult::Genesis)
193        }
194        RomFileType::SegaCD => {
195            segacd::analyze_segacd_data(&data, rom_path).map(RomAnalysisResult::SegaCD)
196        }
197        RomFileType::CDSystem => {
198            // Some cartridge formats (like Sega Genesis) use the .bin extension, which
199            // conflicts with CD image formats. This checks for cartridge headers inside
200            // files that might otherwise be treated as CD images.
201            const SEGA_HEADER_START: usize = 0x100;
202            const SEGA_GENESIS_HEADER_END: usize = 0x110;
203            const SEGA_CD_SIGNATURE_END: usize = 0x107;
204            const SEGA_CD_MIN_LEN: usize = 0x10C; // To read region code at 0x10B
205
206            if data.len() >= SEGA_GENESIS_HEADER_END
207                && (data[SEGA_HEADER_START..SEGA_GENESIS_HEADER_END]
208                    .starts_with(SEGA_MEGA_DRIVE_SIG)
209                    || data[SEGA_HEADER_START..SEGA_GENESIS_HEADER_END]
210                        .starts_with(SEGA_GENESIS_SIG))
211            {
212                genesis::analyze_genesis_data(&data, rom_path).map(RomAnalysisResult::Genesis)
213            } else if data.len() >= SEGA_CD_MIN_LEN
214                && data[SEGA_HEADER_START..SEGA_CD_SIGNATURE_END].eq_ignore_ascii_case(b"SEGA CD")
215            {
216                segacd::analyze_segacd_data(&data, rom_path).map(RomAnalysisResult::SegaCD)
217            } else {
218                psx::analyze_psx_data(&data, rom_path).map(RomAnalysisResult::PSX)
219            }
220        }
221        RomFileType::Unknown => Err(RomAnalyzerError::UnsupportedFormat(format!(
222            "Unrecognized ROM file extension for dispatch: {}",
223            rom_path
224        ))),
225    }
226}
227
228/// Analyze the header data of a ROM file.
229///
230/// This is the primary public function for analyzing ROM files. It handles different
231/// file types (including archives like ZIP and CHD) by first processing them to
232/// extract the ROM data, and then dispatches the data to `process_rom_data` for
233/// console-specific analysis.
234///
235/// # Arguments
236///
237/// * `file_path` - The path to the ROM file or archive.
238///
239/// # Returns
240///
241/// A `Result` containing either a [`RomAnalysisResult`] with the analysis data
242/// or a [`RomAnalyzerError`].
243///
244/// # Examples
245///
246/// ```rust
247/// use rom_analyzer::analyze_rom_data;
248///
249/// let result = analyze_rom_data("path/to/your/rom.nes");
250/// match result {
251///     Ok(analysis) => println!("Analysis successful!"),
252///     Err(e) => eprintln!("Error analyzing ROM: {}", e),
253/// }
254/// ```
255pub fn analyze_rom_data(file_path: &str) -> Result<RomAnalysisResult, RomAnalyzerError> {
256    match get_file_extension_lowercase(file_path).as_str() {
257        "zip" => {
258            let file = File::open(file_path)?;
259            let (data, rom_file_name) = process_zip_file(file, file_path)?;
260            process_rom_data(data, &rom_file_name)
261        }
262        "chd" => {
263            let decompressed_chd = analyze_chd_file(Path::new(file_path))?;
264            process_rom_data(decompressed_chd, file_path)
265        }
266        _ => {
267            let data = fs::read(file_path)?;
268            process_rom_data(data, file_path)
269        }
270    }
271}
272
273macro_rules! impl_rom_analysis_method {
274    ($fn_name:ident, $return_type:ty) => {
275        /// Calls the `$fn_name` method on the inner console-specific analysis struct.
276        /// This allows a common interface for accessing console-specific data.
277        pub fn $fn_name(&self) -> $return_type {
278            match self {
279                RomAnalysisResult::GameGear(a) => a.$fn_name(),
280                RomAnalysisResult::GB(a) => a.$fn_name(),
281                RomAnalysisResult::GBA(a) => a.$fn_name(),
282                RomAnalysisResult::Genesis(a) => a.$fn_name(),
283                RomAnalysisResult::MasterSystem(a) => a.$fn_name(),
284                RomAnalysisResult::N64(a) => a.$fn_name(),
285                RomAnalysisResult::NES(a) => a.$fn_name(),
286                RomAnalysisResult::PSX(a) => a.$fn_name(),
287                RomAnalysisResult::SegaCD(a) => a.$fn_name(),
288                RomAnalysisResult::SNES(a) => a.$fn_name(),
289            }
290        }
291    };
292}
293
294macro_rules! impl_rom_analysis_accessor {
295    ($fn_name:ident, $field:ident, &$return_type:ty) => {
296        /// Provides read-only access to the `$field` field of the inner console-specific analysis struct.
297        pub fn $fn_name(&self) -> &$return_type {
298            match self {
299                RomAnalysisResult::GameGear(a) => &a.$field,
300                RomAnalysisResult::GB(a) => &a.$field,
301                RomAnalysisResult::GBA(a) => &a.$field,
302                RomAnalysisResult::Genesis(a) => &a.$field,
303                RomAnalysisResult::MasterSystem(a) => &a.$field,
304                RomAnalysisResult::N64(a) => &a.$field,
305                RomAnalysisResult::NES(a) => &a.$field,
306                RomAnalysisResult::PSX(a) => &a.$field,
307                RomAnalysisResult::SegaCD(a) => &a.$field,
308                RomAnalysisResult::SNES(a) => &a.$field,
309            }
310        }
311    };
312    ($fn_name:ident, $field:ident, $return_type:ty) => {
313        /// Provides access to the `$field` field of the inner console-specific analysis struct.
314        pub fn $fn_name(&self) -> $return_type {
315            match self {
316                RomAnalysisResult::GameGear(a) => a.$field,
317                RomAnalysisResult::GB(a) => a.$field,
318                RomAnalysisResult::GBA(a) => a.$field,
319                RomAnalysisResult::Genesis(a) => a.$field,
320                RomAnalysisResult::MasterSystem(a) => a.$field,
321                RomAnalysisResult::N64(a) => a.$field,
322                RomAnalysisResult::NES(a) => a.$field,
323                RomAnalysisResult::PSX(a) => a.$field,
324                RomAnalysisResult::SegaCD(a) => a.$field,
325                RomAnalysisResult::SNES(a) => a.$field,
326            }
327        }
328    };
329}
330
331impl RomAnalysisResult {
332    impl_rom_analysis_method!(print, String);
333    impl_rom_analysis_accessor!(source_name, source_name, &str);
334    impl_rom_analysis_accessor!(region, region_string, &str);
335    impl_rom_analysis_accessor!(region_mismatch, region_mismatch, bool);
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use std::io::Write;
342    use tempfile::tempdir;
343    use zip::write::{FileOptions, ZipWriter};
344
345    const TEST_SEGA_MEGA_DRIVE_HEADER: &[u8] = b"SEGA MEGA DRIVE "; // Padded to 16 bytes
346    const TEST_SEGA_GENESIS_HEADER: &[u8] = b"SEGA GENESIS    ";
347
348    #[test]
349    fn test_get_rom_file_type() {
350        assert_eq!(get_rom_file_type("game.nes"), RomFileType::Nes);
351        assert_eq!(get_rom_file_type("game.smc"), RomFileType::Snes);
352        assert_eq!(get_rom_file_type("game.sfc"), RomFileType::Snes);
353        assert_eq!(get_rom_file_type("game.n64"), RomFileType::N64);
354        assert_eq!(get_rom_file_type("game.v64"), RomFileType::N64);
355        assert_eq!(get_rom_file_type("game.z64"), RomFileType::N64);
356        assert_eq!(get_rom_file_type("game.sms"), RomFileType::MasterSystem);
357        assert_eq!(get_rom_file_type("game.gg"), RomFileType::GameGear);
358        assert_eq!(get_rom_file_type("game.gb"), RomFileType::GameBoy);
359        assert_eq!(get_rom_file_type("game.gbc"), RomFileType::GameBoy);
360        assert_eq!(get_rom_file_type("game.gba"), RomFileType::GameBoyAdvance);
361        assert_eq!(get_rom_file_type("game.md"), RomFileType::Genesis);
362        assert_eq!(get_rom_file_type("game.gen"), RomFileType::Genesis);
363        assert_eq!(get_rom_file_type("game.32x"), RomFileType::Genesis);
364        assert_eq!(get_rom_file_type("game.scd"), RomFileType::SegaCD);
365        assert_eq!(get_rom_file_type("game.iso"), RomFileType::CDSystem);
366        assert_eq!(get_rom_file_type("game.bin"), RomFileType::CDSystem);
367        assert_eq!(get_rom_file_type("game.img"), RomFileType::CDSystem);
368        assert_eq!(get_rom_file_type("game.psx"), RomFileType::CDSystem);
369        assert_eq!(get_rom_file_type("game.chd"), RomFileType::CDSystem);
370        assert_eq!(get_rom_file_type("game.zip"), RomFileType::Unknown);
371        assert_eq!(get_rom_file_type("game.txt"), RomFileType::Unknown);
372    }
373
374    #[test]
375    fn test_process_rom_data_unrecognized_extension() {
376        let data = vec![];
377        let name = "game.xyz";
378        let result = process_rom_data(data, name);
379        let err = result.expect_err(
380            "process_rom_data should have returned an error for unrecognized extension",
381        );
382        assert!(err.to_string().contains("Unrecognized ROM file extension"));
383    }
384
385    #[test]
386    fn test_process_rom_data_cd_system_sega_genesis_header() {
387        let mut data = vec![0; 0x120];
388        data[0x100..0x110].copy_from_slice(TEST_SEGA_MEGA_DRIVE_HEADER);
389        let name = "game.bin";
390        // This will attempt to call genesis::analyze_genesis_data
391        // Since we don't have a full mock, we'll assert it doesn't return an unknown error
392        // A successful return indicates it dispatched to a recognized console analyzer.
393        let result = process_rom_data(data, name);
394        // Expect an error from the analyzer itself if the data isn't valid for a Sega Cartridge, not an 'Unknown' dispatch error.
395        assert!(result.is_err());
396        let err = result.expect_err("process_rom_data should have returned an error for mock data");
397        assert!(!err.to_string().contains("Unrecognized ROM file extension"));
398        assert!(!err.to_string().contains("PSX"));
399    }
400
401    #[test]
402    fn test_process_rom_data_cd_system_sega_genesis_header_genesis() {
403        let mut data = vec![0; 0x120];
404        data[0x100..0x110].copy_from_slice(TEST_SEGA_GENESIS_HEADER);
405        let name = "game.bin";
406        let result = process_rom_data(data, name);
407        assert!(result.is_err());
408        let err = result.expect_err("process_rom_data should have returned an error for mock data");
409        assert!(!err.to_string().contains("Unrecognized ROM file extension"));
410        assert!(!err.to_string().contains("PSX"));
411    }
412
413    #[test]
414    fn test_process_rom_data_cd_system_sega_cd_header() {
415        let mut data = vec![0; 0x120];
416        data[0x100..0x107].copy_from_slice(b"SEGA CD");
417        let name = "game.iso";
418        let result = process_rom_data(data, name);
419        let err = result.expect_err("process_rom_data should have returned an error for mock data");
420        assert!(!err.to_string().contains("Unrecognized ROM file extension"));
421    }
422
423    #[test]
424    fn test_process_rom_data_cd_system_psx() {
425        let data = vec![0; 0x100]; // Not enough for Sega headers, should fall through to PSX
426        let name = "game.bin";
427        let result = process_rom_data(data, name);
428        let err = result.expect_err("process_rom_data should have returned an error for mock data");
429        assert!(!err.to_string().contains("Unrecognized ROM file extension"));
430    }
431
432    #[test]
433    fn test_analyze_rom_data_zip() {
434        let dir = tempdir().unwrap();
435        let zip_path = dir.path().join("test.zip");
436        let zip_file = File::create(&zip_path).unwrap();
437        let mut zip = ZipWriter::new(zip_file);
438        zip.start_file("game.nes", FileOptions::default()).unwrap();
439        zip.write_all(b"NES ROM DATA").unwrap();
440        zip.finish().unwrap();
441        let zip_path_str = zip_path.to_str().unwrap();
442        let result = analyze_rom_data(zip_path_str);
443        assert!(result.is_err());
444        let err = result.unwrap_err();
445        assert!(!err.to_string().contains("Unrecognized ROM file extension"));
446    }
447
448    #[test]
449    fn test_analyze_rom_data_chd() {
450        let dir = tempdir().unwrap();
451        let chd_path = dir.path().join("test.chd");
452        std::fs::write(&chd_path, b"invalid chd data").unwrap();
453        let chd_path_str = chd_path.to_str().unwrap();
454        let result = analyze_rom_data(chd_path_str);
455        assert!(result.is_err());
456        let err = result.unwrap_err();
457        assert!(!err.to_string().contains("Unrecognized ROM file extension"));
458        assert!(!err.to_string().contains("PSX"));
459    }
460}