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 [`Region::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 [`Region::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 = u8::MAX;
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
73const REGION_PATTERNS: &[(&[&str], Region)] = &[
74    (&["JAP", "JP", "(J)", "[J]", "NTSC-J"], Region::JAPAN),
75    (&["USA", "(U)", "[U]", "NTSC-U", "NTSC-US"], Region::USA),
76    (&["EUR", "(E)", "[E]", "PAL", "NTSC-E"], Region::EUROPE),
77    (&["RUSSIA", "DENDY"], Region::RUSSIA),
78    (&["(WORLD)", "[WORLD]", "(W)", "[W]"], Region::WORLD),
79];
80
81/// Infers the geographical region of a ROM from its filename.
82///
83/// This function examines the provided filename for common region indicators (e.g., "JP", "USA",
84/// "EUR", "PAL", NTSC-J, NTSC-U, NTSC-E, (J), (U), (E), \[J\], \[U\], \[E\]) and returns a
85/// standardized region string if a match is found. The search is case-insensitive.
86///
87/// # Arguments
88///
89/// * `name` - The filename of the ROM as a string slice.
90///
91/// # Returns
92///
93/// Returns a [`Region`] bitmask. If no region is found, returns [`Region::UNKNOWN`].
94///
95/// # Examples
96///
97/// ```rust
98/// use rom_analyzer::region::{infer_region_from_filename, Region};
99///
100/// assert_eq!(infer_region_from_filename("MyGame (J).zip"), Region::JAPAN);
101/// assert_eq!(infer_region_from_filename("AnotherGame (USA).nes"), Region::USA);
102/// assert_eq!(infer_region_from_filename("PAL_Game.sfc"), Region::EUROPE);
103/// assert_eq!(infer_region_from_filename("UnknownGame.bin"), Region::UNKNOWN);
104/// ```
105pub fn infer_region_from_filename(name: &str) -> Region {
106    // Case-insensitively scan the filename for known region tokens and OR together
107    // any matching region flags to produce a combined Region bitmask.
108    let upper_name = name.to_uppercase();
109    REGION_PATTERNS
110        .iter()
111        .fold(Region::UNKNOWN, |acc, (patterns, flag)| {
112            if patterns.iter().any(|pattern| upper_name.contains(*pattern)) {
113                acc | *flag
114            } else {
115                acc
116            }
117        })
118}
119
120/// Compare the inferred region (via filename) to the region reported by the ROM's header.
121///
122/// # Arguments
123///
124/// * `name` - The filename of the ROM as a string slice.
125///
126/// # Returns
127///
128/// Returns `true` if there is a mismatch, otherwise returns `false`.
129/// A mismatch occurs if:
130/// 1. Both filename and header have known regions.
131/// 2. They share NO common regions (intersection is empty).
132///
133/// If either region is unknown, returns `false` (no mismatch).
134///
135/// # Examples
136///
137/// ```rust
138/// use rom_analyzer::region::{check_region_mismatch, Region};
139///
140/// // No mismatch cases
141/// assert!(!check_region_mismatch("MyGame (J).zip", Region::JAPAN));
142/// assert!(!check_region_mismatch("AnotherGame (USA).nes", Region::USA));
143/// assert!(!check_region_mismatch("PAL_Game.sfc", Region::EUROPE));
144/// assert!(!check_region_mismatch("UnknownGame.bin", Region::UNKNOWN));
145/// // Mismatch cases
146/// assert!(check_region_mismatch("MyGame (J).zip", Region::USA));
147/// assert!(check_region_mismatch("AnotherGame (USA).nes", Region::EUROPE));
148/// assert!(check_region_mismatch("PAL_Game.sfc", Region::JAPAN));
149/// ```
150pub fn check_region_mismatch(source_name: &str, header_region: Region) -> bool {
151    let inferred_region = infer_region_from_filename(source_name);
152
153    // If either region is unknown, do not return a mismatch.
154    if inferred_region.is_empty() || header_region.is_empty() {
155        return false;
156    }
157
158    !inferred_region.intersects(header_region)
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn test_infer_region_from_filename_japan() {
167        assert_eq!(infer_region_from_filename("game (J).zip"), Region::JAPAN);
168        assert_eq!(infer_region_from_filename("game [J].zip"), Region::JAPAN);
169        assert_eq!(
170            infer_region_from_filename("game (Japan).zip"),
171            Region::JAPAN
172        );
173        assert_eq!(
174            infer_region_from_filename("game (NTSC-J).zip"),
175            Region::JAPAN
176        );
177    }
178
179    #[test]
180    fn test_infer_region_from_filename_usa() {
181        assert_eq!(infer_region_from_filename("game (U).zip"), Region::USA);
182        assert_eq!(infer_region_from_filename("game [U].zip"), Region::USA);
183        assert_eq!(infer_region_from_filename("game (USA).zip"), Region::USA);
184        assert_eq!(infer_region_from_filename("game (NTSC-U).zip"), Region::USA);
185        assert_eq!(
186            infer_region_from_filename("game (NTSC-US).zip"),
187            Region::USA
188        );
189    }
190
191    #[test]
192    fn test_infer_region_from_filename_europe() {
193        assert_eq!(infer_region_from_filename("game (E).zip"), Region::EUROPE);
194        assert_eq!(infer_region_from_filename("game [E].zip"), Region::EUROPE);
195        assert_eq!(
196            infer_region_from_filename("game (Europe).zip"),
197            Region::EUROPE
198        );
199        assert_eq!(infer_region_from_filename("game (PAL).zip"), Region::EUROPE);
200        assert_eq!(
201            infer_region_from_filename("game (NTSC-E).zip"),
202            Region::EUROPE
203        );
204    }
205
206    #[test]
207    fn test_infer_region_from_filename_russia() {
208        assert_eq!(
209            infer_region_from_filename("game (Russia).zip"),
210            Region::RUSSIA
211        );
212        assert_eq!(infer_region_from_filename("game DENDY.zip"), Region::RUSSIA);
213    }
214
215    #[test]
216    fn test_infer_region_from_filename_world() {
217        assert_eq!(infer_region_from_filename("game (W).zip"), Region::WORLD);
218        assert_eq!(
219            infer_region_from_filename("game (World).zip"),
220            Region::WORLD
221        );
222    }
223
224    #[test]
225    fn test_infer_region_from_filename_none() {
226        assert_eq!(
227            infer_region_from_filename("game (unmarked).zip"),
228            Region::UNKNOWN
229        );
230        assert_eq!(
231            infer_region_from_filename("another game.zip"),
232            Region::UNKNOWN
233        );
234    }
235
236    #[test]
237    fn test_check_region_mismatch_no_mismatch_japan() {
238        // Filename indicates Japan, header is also Japan
239        assert!(!check_region_mismatch("game (J).zip", Region::JAPAN));
240        assert!(!check_region_mismatch("game (Japan).zip", Region::JAPAN));
241    }
242
243    #[test]
244    fn test_check_region_mismatch_no_mismatch_usa() {
245        // Filename indicates USA, header is also USA
246        assert!(!check_region_mismatch("game (U).zip", Region::USA));
247        assert!(!check_region_mismatch("game (USA).zip", Region::USA));
248    }
249
250    #[test]
251    fn test_check_region_mismatch_no_mismatch_europe() {
252        // Filename indicates Europe, header is also Europe
253        assert!(!check_region_mismatch("game (E).zip", Region::EUROPE));
254        assert!(!check_region_mismatch("game (Europe).zip", Region::EUROPE));
255    }
256
257    #[test]
258    fn test_check_region_mismatch_mismatch_japan_usa() {
259        // Filename indicates Japan, header indicates USA
260        assert!(check_region_mismatch("game (J).zip", Region::USA));
261        assert!(check_region_mismatch("game (Japan).zip", Region::USA));
262    }
263
264    #[test]
265    fn test_check_region_mismatch_mismatch_usa_europe() {
266        // Filename indicates USA, header indicates Europe
267        assert!(check_region_mismatch("game (U).zip", Region::EUROPE));
268        assert!(check_region_mismatch("game (USA).zip", Region::EUROPE));
269    }
270
271    #[test]
272    fn test_check_region_mismatch_mismatch_europe_japan() {
273        // Filename indicates Europe, header indicates Japan
274        assert!(check_region_mismatch("game (E).zip", Region::JAPAN));
275        assert!(check_region_mismatch("game (Europe).zip", Region::JAPAN));
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!(!check_region_mismatch("game (J).zip", Region::UNKNOWN));
282        assert!(!check_region_mismatch("game (U).zip", Region::UNKNOWN));
283        assert!(!check_region_mismatch("game (E).zip", Region::UNKNOWN));
284    }
285
286    #[test]
287    fn test_check_region_mismatch_filename_unknown_header_has_region() {
288        // Filename is generic, header indicates a region
289        assert!(!check_region_mismatch("game.zip", Region::JAPAN));
290        assert!(!check_region_mismatch("another game.zip", Region::USA));
291        assert!(!check_region_mismatch("game_title", Region::EUROPE));
292    }
293
294    #[test]
295    fn test_check_region_mismatch_both_unknown() {
296        // Neither filename nor header can be normalized to a region
297        assert!(!check_region_mismatch("game.zip", Region::UNKNOWN));
298        assert!(!check_region_mismatch("another game.zip", Region::UNKNOWN));
299        assert!(!check_region_mismatch("game_title", Region::UNKNOWN));
300    }
301
302    #[test]
303    fn test_check_region_mismatch_case_insensitivity_filename() {
304        // Test case insensitivity for filename inference
305        assert!(!check_region_mismatch("game (JapAn).zip", Region::JAPAN));
306        assert!(!check_region_mismatch("game (uSa).zip", Region::USA));
307        assert!(!check_region_mismatch("game (EuRoPe).zip", Region::EUROPE));
308    }
309
310    #[test]
311    fn test_overlap_logic() {
312        // NES Example: Header says "NTSC", Filename says "(U)"
313        let filename_region = infer_region_from_filename("Contra (U).nes"); // USA
314        let header_region = Region::USA | Region::JAPAN;
315
316        // They should intersect (match), so mismatch is false
317        assert!(filename_region.intersects(header_region));
318        assert!(!check_region_mismatch("Contra (U).nes", Region::USA));
319    }
320
321    #[test]
322    fn test_strict_mismatch() {
323        // Filename says (E), Header says NTSC (USA|Japan)
324        let filename_region = infer_region_from_filename("Contra (E).nes"); // EUROPE
325        let header_region = Region::USA | Region::JAPAN;
326
327        // No intersection, so mismatch is true
328        assert!(!filename_region.intersects(header_region));
329        assert!(check_region_mismatch("Contra (E).nes", Region::USA));
330    }
331
332    #[test]
333    fn test_world_rom() {
334        // Filename says (W), Header says USA
335        // (W) implies USA | JAPAN | EUROPE
336        assert!(!check_region_mismatch("Game (W).bin", Region::USA));
337    }
338
339    #[test]
340    fn test_multiple_region_filename_display() {
341        let filename = "Super Game (U) (J).nes";
342        let region = infer_region_from_filename(filename).to_string();
343        assert_eq!(region, "Japan/USA")
344    }
345
346    #[test]
347    fn test_region_display_all() {
348        assert_eq!(Region::JAPAN.to_string(), "Japan");
349        assert_eq!(Region::USA.to_string(), "USA");
350        assert_eq!(Region::EUROPE.to_string(), "Europe");
351        assert_eq!(Region::RUSSIA.to_string(), "Russia");
352        assert_eq!(Region::ASIA.to_string(), "Asia");
353        assert_eq!(Region::CHINA.to_string(), "China");
354        assert_eq!(Region::KOREA.to_string(), "Korea");
355        assert_eq!(Region::UNKNOWN.to_string(), "Unknown");
356        assert_eq!(Region::WORLD.to_string(), "World");
357        assert_eq!((Region::JAPAN | Region::USA).to_string(), "Japan/USA");
358    }
359}