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