rom_analyzer/
region.rs

1//! Provides utilities for inferring and normalizing geographical regions
2//! from ROM filenames and header information.
3//!
4//! This module helps in identifying the target region (e.g., Japan, USA, Europe)
5//! of a ROM, which is crucial for accurate analysis and categorization. It includes
6//! functions for inferring regions from filenames and comparing inferred regions
7//! with regions reported by ROM headers.
8//!
9//! The `Region` bitflag struct is used to represent geographical regions and allows
10//! a ROM to belong to multiple regions (e.g., NES NTSC = USA + JAPAN). The `WORLD`
11//! constant is a special case that represents ROMs compatible with multiple regions.
12
13use std::fmt;
14
15use bitflags::bitflags;
16use serde::Serialize;
17
18bitflags! {
19    /// A bitflag struct representing geographical regions.
20    /// Allows a ROM to belong to multiple regions (e.g., NES NTSC = USA + JAPAN).
21    ///
22    /// The `WORLD` constant is a special case that represents ROMs compatible with
23    /// multiple regions (e.g. USA and Europe for ROMs with an 'Overseas' region).
24    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
25    pub struct Region: u8 {
26
27        const UNKNOWN = 0;
28        const JAPAN = 1 << 0;
29        const USA = 1 << 1;
30        const EUROPE = 1 << 2;
31        const RUSSIA = 1 << 3;
32        const ASIA = 1 << 4;
33        const CHINA = 1 << 5;
34        const KOREA = 1 << 6;
35
36        // Dynamic "WORLD" that matches all available regions and is safe.
37        const WORLD = Self::JAPAN.bits() | Self::USA.bits() | Self::EUROPE.bits() | Self::RUSSIA.bits();
38    }
39}
40
41impl fmt::Display for Region {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        if self.is_empty() {
44            return write!(f, "Unknown");
45        }
46
47        // Handle the composite constant WORLD for cleaner output
48        if self.bits() == Region::WORLD.bits() {
49            return write!(f, "World");
50        }
51
52        // Collect the string names using a match statement
53        let regions: Vec<&str> = self
54            .iter()
55            .map(|flag| match flag {
56                Region::JAPAN => "Japan",
57                Region::USA => "USA",
58                Region::EUROPE => "Europe",
59                Region::RUSSIA => "Russia",
60                Region::ASIA => "Asia",
61                Region::CHINA => "China",
62                Region::KOREA => "Korea",
63                _ => "",
64            })
65            .filter(|s| !s.is_empty())
66            .collect();
67
68        // Join multiple regions with forward slash (e.g. "Japan/USA")
69        write!(f, "{}", regions.join("/"))
70    }
71}
72
73/// Infers the geographical region of a ROM from its filename.
74///
75/// This function examines the provided filename for common region indicators (e.g., "JP", "USA",
76/// "EUR", "PAL", NTSC-J, NTSC-U, NTSC-E, (J), (U), (E), \[J\], \[U\], \[E\]) and returns a
77/// standardized region string if a match is found. The search is case-insensitive.
78///
79/// # Arguments
80///
81/// * `name` - The filename of the ROM as a string slice.
82///
83/// # Returns
84///
85/// Returns a `Region` bitmask. If no region is found, returns `Region::UNKNOWN`.
86///
87/// # Examples
88///
89/// ```rust
90/// use rom_analyzer::region::{infer_region_from_filename, Region};
91///
92/// assert_eq!(infer_region_from_filename("MyGame (J).zip"), Region::JAPAN);
93/// assert_eq!(infer_region_from_filename("AnotherGame (USA).nes"), Region::USA);
94/// assert_eq!(infer_region_from_filename("PAL_Game.sfc"), Region::EUROPE);
95/// assert_eq!(infer_region_from_filename("UnknownGame.bin"), Region::UNKNOWN);
96/// ```
97pub fn infer_region_from_filename(name: &str) -> Region {
98    let lower_name = name.to_lowercase();
99    let mut region = Region::UNKNOWN;
100
101    // Define region patterns with their corresponding flags
102    let region_patterns = [
103        (vec!["jap", "jp", "(j)", "[j]", "ntsc-j"], Region::JAPAN),
104        (vec!["usa", "(u)", "[u]", "ntsc-u", "ntsc-us"], Region::USA),
105        (vec!["eur", "(e)", "[e]", "pal", "ntsc-e"], Region::EUROPE),
106        (vec!["russia", "dendy"], Region::RUSSIA),
107        (vec!["(world)", "[world]", "(w)", "[w]"], Region::WORLD),
108    ];
109
110    // Check each pattern and set the corresponding region flag
111    for (patterns, flag) in region_patterns {
112        for pattern in patterns {
113            if lower_name.contains(pattern) {
114                region |= flag;
115                break;
116            }
117        }
118    }
119
120    region
121}
122
123/// Compare the inferred region (via filename) to the region reported by the ROM's header.
124///
125/// # Arguments
126///
127/// * `name` - The filename of the ROM as a string slice.
128///
129/// # Returns
130///
131/// Returns `true` if there is a mismatch, otherwise returns `false`.
132/// A mismatch occurs if:
133/// 1. Both filename and header have known regions.
134/// 2. They share NO common regions (intersection is empty).
135///
136/// If either region is unknown, returns `false` (no mismatch).
137///
138/// # Examples
139///
140/// ```rust
141/// use rom_analyzer::region::{check_region_mismatch, Region};
142///
143/// // No mismatch cases
144/// assert_eq!(check_region_mismatch("MyGame (J).zip", Region::JAPAN), false);
145/// assert_eq!(check_region_mismatch("AnotherGame (USA).nes", Region::USA), false);
146/// assert_eq!(check_region_mismatch("PAL_Game.sfc", Region::EUROPE), false);
147/// assert_eq!(check_region_mismatch("UnknownGame.bin", Region::UNKNOWN), false);
148/// // Mismatch cases
149/// assert_eq!(check_region_mismatch("MyGame (J).zip", Region::USA), true);
150/// assert_eq!(check_region_mismatch("AnotherGame (USA).nes", Region::EUROPE), true);
151/// assert_eq!(check_region_mismatch("PAL_Game.sfc", Region::JAPAN), true);
152/// ```
153pub fn check_region_mismatch(source_name: &str, header_region: Region) -> bool {
154    let inferred_region = infer_region_from_filename(source_name);
155
156    // If either region is unknown, do not return a mismatch.
157    if inferred_region.is_empty() || header_region.is_empty() {
158        return false;
159    }
160
161    !inferred_region.intersects(header_region)
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn test_infer_region_from_filename_japan() {
170        assert_eq!(infer_region_from_filename("game (J).zip"), Region::JAPAN);
171        assert_eq!(infer_region_from_filename("game [J].zip"), Region::JAPAN);
172        assert_eq!(
173            infer_region_from_filename("game (Japan).zip"),
174            Region::JAPAN
175        );
176        assert_eq!(
177            infer_region_from_filename("game (NTSC-J).zip"),
178            Region::JAPAN
179        );
180    }
181
182    #[test]
183    fn test_infer_region_from_filename_usa() {
184        assert_eq!(infer_region_from_filename("game (U).zip"), Region::USA);
185        assert_eq!(infer_region_from_filename("game [U].zip"), Region::USA);
186        assert_eq!(infer_region_from_filename("game (USA).zip"), Region::USA);
187        assert_eq!(infer_region_from_filename("game (NTSC-U).zip"), Region::USA);
188        assert_eq!(
189            infer_region_from_filename("game (NTSC-US).zip"),
190            Region::USA
191        );
192    }
193
194    #[test]
195    fn test_infer_region_from_filename_europe() {
196        assert_eq!(infer_region_from_filename("game (E).zip"), Region::EUROPE);
197        assert_eq!(infer_region_from_filename("game [E].zip"), Region::EUROPE);
198        assert_eq!(
199            infer_region_from_filename("game (Europe).zip"),
200            Region::EUROPE
201        );
202        assert_eq!(infer_region_from_filename("game (PAL).zip"), Region::EUROPE);
203        assert_eq!(
204            infer_region_from_filename("game (NTSC-E).zip"),
205            Region::EUROPE
206        );
207    }
208
209    #[test]
210    fn test_infer_region_from_filename_none() {
211        assert_eq!(
212            infer_region_from_filename("game (unmarked).zip"),
213            Region::UNKNOWN
214        );
215        assert_eq!(
216            infer_region_from_filename("another game.zip"),
217            Region::UNKNOWN
218        );
219    }
220
221    #[test]
222    fn test_check_region_mismatch_no_mismatch_japan() {
223        // Filename indicates Japan, header is also Japan
224        assert_eq!(check_region_mismatch("game (J).zip", Region::JAPAN), false);
225        assert_eq!(
226            check_region_mismatch("game (Japan).zip", Region::JAPAN),
227            false
228        );
229        assert_eq!(check_region_mismatch("game (J).zip", Region::JAPAN), false);
230    }
231
232    #[test]
233    fn test_check_region_mismatch_no_mismatch_usa() {
234        // Filename indicates USA, header is also USA
235        assert_eq!(check_region_mismatch("game (U).zip", Region::USA), false);
236        assert_eq!(check_region_mismatch("game (USA).zip", Region::USA), false);
237        assert_eq!(check_region_mismatch("game (U).zip", Region::USA), false);
238    }
239
240    #[test]
241    fn test_check_region_mismatch_no_mismatch_europe() {
242        // Filename indicates Europe, header is also Europe
243        assert_eq!(check_region_mismatch("game (E).zip", Region::EUROPE), false);
244        assert_eq!(
245            check_region_mismatch("game (Europe).zip", Region::EUROPE),
246            false
247        );
248        assert_eq!(check_region_mismatch("game (E).zip", Region::EUROPE), false);
249    }
250
251    #[test]
252    fn test_check_region_mismatch_mismatch_japan_usa() {
253        // Filename indicates Japan, header indicates USA
254        assert_eq!(check_region_mismatch("game (J).zip", Region::USA), true);
255        assert_eq!(check_region_mismatch("game (Japan).zip", Region::USA), true);
256    }
257
258    #[test]
259    fn test_check_region_mismatch_mismatch_usa_europe() {
260        // Filename indicates USA, header indicates Europe
261        assert_eq!(check_region_mismatch("game (U).zip", Region::EUROPE), true);
262        assert_eq!(
263            check_region_mismatch("game (USA).zip", Region::EUROPE),
264            true
265        );
266    }
267
268    #[test]
269    fn test_check_region_mismatch_mismatch_europe_japan() {
270        // Filename indicates Europe, header indicates Japan
271        assert_eq!(check_region_mismatch("game (E).zip", Region::JAPAN), true);
272        assert_eq!(
273            check_region_mismatch("game (Europe).zip", Region::JAPAN),
274            true
275        );
276    }
277
278    #[test]
279    fn test_check_region_mismatch_filename_has_region_header_unknown() {
280        // Filename indicates a region, but header is unknown/unnormalized
281        assert_eq!(
282            check_region_mismatch("game (J).zip", Region::UNKNOWN),
283            false
284        );
285        assert_eq!(
286            check_region_mismatch("game (U).zip", Region::UNKNOWN),
287            false
288        );
289        assert_eq!(
290            check_region_mismatch("game (E).zip", Region::UNKNOWN),
291            false
292        );
293    }
294
295    #[test]
296    fn test_check_region_mismatch_filename_unknown_header_has_region() {
297        // Filename is generic, header indicates a region
298        assert_eq!(check_region_mismatch("game.zip", Region::JAPAN), false);
299        assert_eq!(
300            check_region_mismatch("another game.zip", Region::USA),
301            false
302        );
303        assert_eq!(check_region_mismatch("game_title", Region::EUROPE), false);
304    }
305
306    #[test]
307    fn test_check_region_mismatch_both_unknown() {
308        // Neither filename nor header can be normalized to a region
309        assert_eq!(check_region_mismatch("game.zip", Region::UNKNOWN), false);
310        assert_eq!(
311            check_region_mismatch("another game.zip", Region::UNKNOWN),
312            false
313        );
314        assert_eq!(check_region_mismatch("game_title", Region::UNKNOWN), false);
315    }
316
317    #[test]
318    fn test_check_region_mismatch_case_insensitivity_filename() {
319        // Test case insensitivity for filename inference
320        assert_eq!(
321            check_region_mismatch("game (JapAn).zip", Region::JAPAN),
322            false
323        );
324        assert_eq!(check_region_mismatch("game (uSa).zip", Region::USA), false);
325        assert_eq!(
326            check_region_mismatch("game (EuRoPe).zip", Region::EUROPE),
327            false
328        );
329    }
330
331    #[test]
332    fn test_overlap_logic() {
333        // NES Example: Header says "NTSC", Filename says "(U)"
334        let filename_region = infer_region_from_filename("Contra (U).nes"); // USA
335        let header_region = Region::USA | Region::JAPAN;
336
337        // They should intersect (match), so mismatch is false
338        assert!(filename_region.intersects(header_region));
339        assert_eq!(check_region_mismatch("Contra (U).nes", Region::USA), false);
340    }
341
342    #[test]
343    fn test_strict_mismatch() {
344        // Filename says (E), Header says NTSC (USA|Japan)
345        let filename_region = infer_region_from_filename("Contra (E).nes"); // EUROPE
346        let header_region = Region::USA | Region::JAPAN;
347
348        // No intersection, so mismatch is true
349        assert!(!filename_region.intersects(header_region));
350        assert_eq!(check_region_mismatch("Contra (E).nes", Region::USA), true);
351    }
352
353    #[test]
354    fn test_world_rom() {
355        // Filename says (W), Header says USA
356        // (W) implies USA | JAPAN | EUROPE
357        assert_eq!(check_region_mismatch("Game (W).bin", Region::USA), false);
358    }
359
360    #[test]
361    fn test_multiple_region_filename_display() {
362        let filename = "Super Game (U) (J).nes";
363        let region = infer_region_from_filename(filename).to_string();
364        assert_eq!(region, "Japan/USA")
365    }
366}