rom_analyzer/console/
psx.rs

1//! Provides header analysis functionality for Sony PlayStation (PSX) ROMs, typically in CD image formats.
2//!
3//! This module focuses on identifying the region of PSX games by searching for known
4//! executable prefixes (e.g., "SLUS", "SLES", "SLPS") within the initial data tracks.
5
6use serde::Serialize;
7
8use crate::error::RomAnalyzerError;
9use crate::region::{Region, check_region_mismatch};
10
11/// Struct to hold the analysis results for a PSX ROM.
12#[derive(Debug, PartialEq, Clone, Serialize)]
13pub struct PsxAnalysis {
14    /// The name of the source file.
15    pub source_name: String,
16    /// The identified region(s) as a region::Region bitmask.
17    pub region: Region,
18    /// The identified region name (e.g., "North America (NTSC-U)").
19    pub region_string: String,
20    /// If the region in the ROM header doesn't match the region in the filename.
21    pub region_mismatch: bool,
22    /// The identified region code (e.g., "SLUS").
23    pub code: String,
24}
25
26impl PsxAnalysis {
27    /// Returns a printable String of the analysis results.
28    pub fn print(&self) -> String {
29        let executable_prefix_not_found = if self.code == "N/A" {
30            "\nNote: Executable prefix (SLUS/SLES/SLPS) not found in header area. Requires main data track (.bin or .iso)."
31        } else {
32            ""
33        };
34        format!(
35            "{}\n\
36             System:       Sony PlayStation (PSX)\n\
37             Region:       {}\n\
38             Code:         {}\
39             {}",
40            self.source_name, self.region, self.code, executable_prefix_not_found
41        )
42    }
43}
44
45/// Determines the PSX game region based on a given region code.
46///
47/// The region code typically comes from the ROM data. This function maps it to a
48/// human-readable region string and a Region bitmask.
49///
50/// # Arguments
51///
52/// * `region_code` - The region code string, usually found in the ROM data.
53///
54/// # Returns
55///
56/// A tuple containing:
57/// - A `&'static str` representing the region (e.g., "North America (NTSC-U)", "Europe (PAL)", etc)
58///   or "Unknown" if the region code is not recognized.
59/// - A [`Region`] bitmask representing the region(s) associated with the code.
60///
61/// # Examples
62///
63/// ```rust
64/// use rom_analyzer::console::psx::map_region;
65/// use rom_analyzer::region::Region;
66///
67/// let (region_str, region_mask) = map_region("SLUS");
68/// assert_eq!(region_str, "North America (NTSC-U)");
69/// assert_eq!(region_mask, Region::USA);
70///
71/// let (region_str, region_mask) = map_region("SLES");
72/// assert_eq!(region_str, "Europe (PAL)");
73/// assert_eq!(region_mask, Region::EUROPE);
74///
75/// let (region_str, region_mask) = map_region("SLPS");
76/// assert_eq!(region_str, "Japan (NTSC-J)");
77/// assert_eq!(region_mask, Region::JAPAN);
78///
79/// let (region_str, region_mask) = map_region("UNKNOWN");
80/// assert_eq!(region_str, "Unknown");
81/// assert_eq!(region_mask, Region::UNKNOWN);
82/// ```
83pub fn map_region(region_code: &str) -> (&'static str, Region) {
84    match region_code {
85        "SLUS" => ("North America (NTSC-U)", Region::USA),
86        "SLES" => ("Europe (PAL)", Region::EUROPE),
87        "SLPS" => ("Japan (NTSC-J)", Region::JAPAN),
88        _ => ("Unknown", Region::UNKNOWN),
89    }
90}
91
92/// Analyzes PlayStation (PSX) ROM data, typically from CD images.
93///
94/// This function scans a portion of the ROM data (up to `0x20000` bytes) for
95/// common PSX executable prefixes like "SLUS", "SLES", or "SLPS". These prefixes
96/// indicate the game's region. If a prefix is found, the corresponding region
97/// and code are extracted. A region mismatch check is also performed against the `source_name`.
98///
99/// # Arguments
100///
101/// * `data` - A byte slice (`&[u8]`) containing the raw ROM data (e.g., from a `.bin` or `.iso` file).
102/// * `source_name` - The name of the ROM file, used for region mismatch checks.
103///
104/// # Returns
105///
106/// A `Result` which is:
107/// - `Ok`([`PsxAnalysis`]) containing the detailed analysis results.
108/// - `Err`([`RomAnalyzerError`]) if the ROM data is too small for reliable analysis.
109pub fn analyze_psx_data(data: &[u8], source_name: &str) -> Result<PsxAnalysis, RomAnalyzerError> {
110    // Check the first 128KB (0x20000 bytes)
111    let check_size = std::cmp::min(data.len(), 0x20000);
112    if check_size < 0x2000 {
113        // Need enough data for Volume Descriptor/Boot file
114        return Err(RomAnalyzerError::DataTooSmall {
115            file_size: data.len(),
116            required_size: 0x2000,
117            details: "PSX boot file analysis".to_string(),
118        });
119    }
120
121    let data_sample = &data[..check_size];
122
123    let mut found_code = "N/A".to_string();
124    let mut region_name = "Unknown";
125    let mut region = Region::UNKNOWN;
126
127    // TODO: Consider moving this somewhere else to centralize the logic into map_region()
128    // For now we'll live with these hardcoded prefixes.
129    for prefix in ["SLUS", "SLES", "SLPS"] {
130        // Use windows to check for the prefix anywhere in the sample.
131        if data_sample
132            .windows(prefix.len())
133            .any(|window| window.eq_ignore_ascii_case(prefix.as_bytes()))
134        {
135            found_code = prefix.to_string();
136            let (region_str, region_mask) = map_region(prefix);
137            region_name = region_str;
138            region = region_mask;
139            break;
140        }
141    }
142
143    let region_mismatch = check_region_mismatch(source_name, region);
144
145    Ok(PsxAnalysis {
146        source_name: source_name.to_string(),
147        region,
148        region_string: region_name.to_string(),
149        region_mismatch,
150        code: found_code,
151    })
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn test_analyze_psx_data_slus() -> Result<(), RomAnalyzerError> {
160        // Ensure sufficient data for analysis, at least 0x2000 bytes.
161        let mut data = vec![0; 0x2000];
162        // Place the region code at an offset where it's expected.
163        data[0x100..0x104].copy_from_slice(b"SLUS"); // North America
164        let analysis = analyze_psx_data(&data, "test_rom_us.iso")?;
165
166        assert_eq!(analysis.source_name, "test_rom_us.iso");
167        assert_eq!(analysis.region, Region::USA);
168        assert_eq!(analysis.region_string, "North America (NTSC-U)");
169        assert_eq!(analysis.code, "SLUS");
170        assert_eq!(
171            analysis.print(),
172            "test_rom_us.iso\n\
173             System:       Sony PlayStation (PSX)\n\
174             Region:       USA\n\
175             Code:         SLUS"
176        );
177        Ok(())
178    }
179
180    #[test]
181    fn test_analyze_psx_data_sles() -> Result<(), RomAnalyzerError> {
182        let mut data = vec![0; 0x2000];
183        data[0x100..0x104].copy_from_slice(b"SLES"); // Europe
184        let analysis = analyze_psx_data(&data, "test_rom_eur.iso")?;
185
186        assert_eq!(analysis.source_name, "test_rom_eur.iso");
187        assert_eq!(analysis.region, Region::EUROPE);
188        assert_eq!(analysis.region_string, "Europe (PAL)");
189        assert_eq!(analysis.code, "SLES");
190        Ok(())
191    }
192
193    #[test]
194    fn test_analyze_psx_data_slps() -> Result<(), RomAnalyzerError> {
195        let mut data = vec![0; 0x2000];
196        data[0x100..0x104].copy_from_slice(b"SLPS"); // Japan
197        let analysis = analyze_psx_data(&data, "test_rom_jp.iso")?;
198
199        assert_eq!(analysis.source_name, "test_rom_jp.iso");
200        assert_eq!(analysis.region, Region::JAPAN);
201        assert_eq!(analysis.region_string, "Japan (NTSC-J)");
202        assert_eq!(analysis.code, "SLPS");
203        Ok(())
204    }
205
206    #[test]
207    fn test_analyze_psx_data_unknown() -> Result<(), RomAnalyzerError> {
208        let data = vec![0; 0x2000];
209        // No known prefix
210        let analysis = analyze_psx_data(&data, "test_rom.iso")?;
211
212        assert_eq!(analysis.source_name, "test_rom.iso");
213        assert_eq!(analysis.region, Region::UNKNOWN);
214        assert_eq!(analysis.region_string, "Unknown");
215        assert_eq!(analysis.code, "N/A");
216        assert_eq!(
217            analysis.print(),
218            "test_rom.iso\n\
219             System:       Sony PlayStation (PSX)\n\
220             Region:       Unknown\n\
221             Code:         N/A\n\
222             Note: Executable prefix (SLUS/SLES/SLPS) not found in header area. Requires main data track (.bin or .iso)."
223        );
224        Ok(())
225    }
226
227    #[test]
228    fn test_analyze_psx_data_too_small() {
229        // Test with data smaller than the minimum required size for analysis.
230        let data = vec![0; 100]; // Smaller than 0x2000
231        let result = analyze_psx_data(&data, "too_small.iso");
232        assert!(result.is_err());
233        assert!(result.unwrap_err().to_string().contains("too small"));
234    }
235
236    #[test]
237    fn test_analyze_psx_data_case_insensitivity() -> Result<(), RomAnalyzerError> {
238        // Test that the matching is case-insensitive.
239        let mut data = vec![0; 0x2000];
240        data[0x100..0x104].copy_from_slice(b"sLuS"); // Mixed case
241        let analysis = analyze_psx_data(&data, "test_rom_mixedcase.iso")?;
242
243        assert_eq!(analysis.source_name, "test_rom_mixedcase.iso");
244        assert_eq!(analysis.region, Region::USA);
245        assert_eq!(analysis.region_string, "North America (NTSC-U)");
246        assert_eq!(analysis.code, "SLUS");
247        Ok(())
248    }
249}