1use crate::archive;
2use crate::archive::{ArchiveEntry, ArchiveError};
3use cdrom::reader::{CdRom, CdRomFileFormat};
4use genesis_core::api::GenesisHardware;
5use jgenesis_proc_macros::{CustomValueEnum, EnumAll, EnumDisplay, EnumFromStr};
6use smsgg_core::SmsGgHardware;
7use std::borrow::Cow;
8use std::collections::{HashMap, HashSet};
9use std::ffi::OsStr;
10use std::fs::File;
11use std::io::{BufReader, Read};
12use std::path::Path;
13use std::sync::LazyLock;
14use std::{cmp, fs, io};
15
16pub const SG_1000: &[&str] = &["sg"];
17pub const MASTER_SYSTEM: &[&str] = &["sms"];
18pub const GAME_GEAR: &[&str] = &["gg"];
19pub const GENESIS: &[&str] = &["gen", "md", "bin", "smd"];
20pub const SEGA_CD: &[&str] = &["cue", "chd"];
21pub const SEGA_32X: &[&str] = &["32x", "bin"];
22pub const NES: &[&str] = &["nes"];
23pub const SNES: &[&str] = &["sfc", "smc"];
24pub const GAME_BOY: &[&str] = &["gb"];
25pub const GAME_BOY_COLOR: &[&str] = &["gbc"];
26pub const GAME_BOY_ADVANCE: &[&str] = &["gba", "bin"];
27pub const PC_ENGINE: &[&str] = &["pce"];
28
29pub const SUPPORTED_ARCHIVES: &[&str] = &["zip", "7z"];
30
31const GENESIS_32X_HEADER_LEN: usize =
32    s32x_core::SECURITY_PROGRAM_CARTRIDGE_ADDR + s32x_core::SECURITY_PROGRAM_LEN;
33
34fn concat_extensions(iter: impl IntoIterator<Item = &'static [&'static str]>) -> Vec<&'static str> {
35    iter.into_iter()
36        .flat_map(|system| system.iter().copied())
37        .collect::<HashSet<_>>()
38        .into_iter()
39        .collect()
40}
41
42pub static SMSGG: LazyLock<Vec<&'static str>> =
43    LazyLock::new(|| concat_extensions([SG_1000, MASTER_SYSTEM, GAME_GEAR]));
44
45pub static GENESIS_32X: LazyLock<Vec<&'static str>> =
46    LazyLock::new(|| concat_extensions([GENESIS, SEGA_32X]));
47
48pub static SEGA_CD_32X: LazyLock<Vec<&'static str>> =
49    LazyLock::new(|| concat_extensions([GENESIS, SEGA_CD, SEGA_32X]));
50
51pub static GB_GBC: LazyLock<Vec<&'static str>> =
52    LazyLock::new(|| concat_extensions([GAME_BOY, GAME_BOY_COLOR]));
53
54pub static ALL_CARTRIDGE_BASED: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
55    concat_extensions([
56        SG_1000,
57        MASTER_SYSTEM,
58        GAME_GEAR,
59        GENESIS,
60        SEGA_32X,
61        NES,
62        SNES,
63        GAME_BOY,
64        GAME_BOY_COLOR,
65        GAME_BOY_ADVANCE,
66        PC_ENGINE,
67    ])
68});
69
70pub static ALL: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
71    ALL_CARTRIDGE_BASED.clone().into_iter().chain(SEGA_CD.iter().copied()).collect()
72});
73
74pub static ALL_PLUS_ARCHIVES: LazyLock<Vec<&'static str>> =
75    LazyLock::new(|| ALL.clone().into_iter().chain(SUPPORTED_ARCHIVES.iter().copied()).collect());
76
77#[must_use]
78pub fn from_path<P: AsRef<Path>>(path: P) -> Option<String> {
79    fn inner(path: &Path) -> Option<String> {
80        path.extension().map(OsStr::to_ascii_lowercase).and_then(|s| s.to_str().map(String::from))
81    }
82
83    inner(path.as_ref())
84}
85
86struct FoundEntryMetadata {
87    file_name: String,
88    extension: String,
89    size: u64,
90}
91
92struct ArchiveListCallback {
93    contains_cue: bool,
94    first_supported_file: Option<FoundEntryMetadata>,
95}
96
97impl ArchiveListCallback {
98    fn new() -> Self {
99        Self { contains_cue: false, first_supported_file: None }
100    }
101
102    fn as_fn_mut(&mut self) -> impl FnMut(ArchiveEntry<'_>) + use<'_> {
103        |entry| {
104            let Some(extension) = from_path(entry.file_name) else { return };
105
106            self.contains_cue |= extension.as_str() == "cue";
107
108            if self.first_supported_file.is_none() && ALL.contains(&extension.as_str()) {
109                self.first_supported_file = Some(FoundEntryMetadata {
110                    file_name: entry.file_name.into(),
111                    extension,
112                    size: entry.size,
113                });
114            }
115        }
116    }
117}
118
119static EXTENSION_LOOKUP: LazyLock<HashMap<&'static str, Console>> =
120    LazyLock::new(build_extension_lookup);
121
122fn build_extension_lookup() -> HashMap<&'static str, Console> {
123    [
124        (SG_1000, Console::Sg1000),
125        (MASTER_SYSTEM, Console::MasterSystem),
126        (GAME_GEAR, Console::GameGear),
127        // Exclude Genesis/32X because need to check the header for Sega CD support
128        (SEGA_CD, Console::SegaCd),
129        (NES, Console::Nes),
130        (SNES, Console::Snes),
131        (GAME_BOY, Console::GameBoy),
132        (GAME_BOY_COLOR, Console::GameBoyColor),
133        (GAME_BOY_ADVANCE, Console::GameBoyAdvance),
134        (PC_ENGINE, Console::PcEngine),
135    ]
136    .into_iter()
137    .flat_map(|(extensions, console)| extensions.iter().map(move |&extension| (extension, console)))
138    .filter(|&(extension, _)| {
139        // Exclude .bin files because it's ambiguous whether the ROM is Genesis or 32X without
140        // checking the file contents
141        extension != "bin"
142    })
143    .collect()
144}
145
146#[derive(Debug, Clone, Copy)]
147pub struct ConsoleWithSize {
148    pub console: Console,
149    pub file_size: u64,
150}
151
152#[derive(Debug, Clone, Copy)]
153pub struct SupportedExtensions {
154    // Should display the console name if label is None
155    pub label: Option<&'static str>,
156    extensions: &'static [&'static str],
157    include_archives: bool,
158}
159
160impl SupportedExtensions {
161    #[must_use]
162    pub fn to_list(self) -> Cow<'static, [&'static str]> {
163        if self.include_archives {
164            self.extensions.iter().copied().chain(["zip", "7z"]).collect()
165        } else {
166            self.extensions.into()
167        }
168    }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumDisplay, EnumFromStr, EnumAll, CustomValueEnum)]
172pub enum Console {
173    MasterSystem,
174    GameGear,
175    Sg1000,
176    Genesis,
177    SegaCd,
178    Sega32X,
179    SegaCd32X,
180    Nes,
181    Snes,
182    GameBoy,
183    #[value_enum(skip)]
184    GameBoyColor,
185    GameBoyAdvance,
186    PcEngine,
187}
188
189impl Console {
190    #[must_use]
191    pub fn from_file(file_path: &Path) -> Option<ConsoleWithSize> {
192        let extension = from_path(file_path)?;
193        if let Some(&console) = EXTENSION_LOOKUP.get(&extension.as_str()) {
194            let console = match console {
195                Console::SegaCd if is_disc_sega_cd_32x(file_path) => Console::SegaCd32X,
196                _ => console,
197            };
198
199            let file_size = fs::metadata(file_path).ok()?.len();
200            return Some(ConsoleWithSize { console, file_size });
201        }
202
203        if GENESIS_32X.contains(&extension.as_str()) {
204            let console = guess_genesis_console_raw_file(file_path).ok()?;
205            let file_size = fs::metadata(file_path).ok()?.len();
206            return Some(ConsoleWithSize { console, file_size });
207        }
208
209        match extension.as_str() {
210            "zip" => Self::from_zip(file_path),
211            "7z" => Self::from_7z(file_path),
212            _ => None,
213        }
214    }
215
216    fn from_zip(zip_path: &Path) -> Option<ConsoleWithSize> {
217        let mut callback = ArchiveListCallback::new();
218        archive::list_files_zip(zip_path, callback.as_fn_mut()).ok()?;
219
220        Self::from_list_callback(callback, |file_name, max_len| {
221            archive::read_file_zip(zip_path, file_name, max_len)
222        })
223    }
224
225    fn from_7z(sevenz_path: &Path) -> Option<ConsoleWithSize> {
226        let mut callback = ArchiveListCallback::new();
227        archive::list_files_7z(sevenz_path, callback.as_fn_mut()).ok()?;
228
229        Self::from_list_callback(callback, |file_name, max_len| {
230            archive::read_file_7z(sevenz_path, file_name, max_len)
231        })
232    }
233
234    fn from_list_callback(
235        callback: ArchiveListCallback,
236        read_file_fn: impl FnOnce(&str, usize) -> Result<Vec<u8>, ArchiveError>,
237    ) -> Option<ConsoleWithSize> {
238        if callback.contains_cue {
239            return None;
240        }
241
242        let first_supported_file = callback.first_supported_file?;
243        if let Some(&console) = EXTENSION_LOOKUP.get(&first_supported_file.extension.as_str()) {
244            return Some(ConsoleWithSize { console, file_size: first_supported_file.size });
245        }
246
247        if GENESIS_32X.contains(&first_supported_file.extension.as_str()) {
248            let header =
249                read_file_fn(&first_supported_file.file_name, GENESIS_32X_HEADER_LEN).ok()?;
250            let console = guess_genesis_console(&header);
251            return Some(ConsoleWithSize { console, file_size: first_supported_file.size });
252        }
253
254        None
255    }
256
257    #[inline]
258    #[must_use]
259    pub const fn display_str(self) -> &'static str {
260        match self {
261            Self::Sg1000 => "SG-1000",
262            Self::MasterSystem => "Master System",
263            Self::GameGear => "Game Gear",
264            Self::Genesis => "Genesis",
265            Self::SegaCd => "Sega CD",
266            Self::Sega32X => "32X",
267            Self::SegaCd32X => "Sega CD 32X",
268            Self::Nes => "NES",
269            Self::Snes => "SNES",
270            Self::GameBoy => "Game Boy",
271            Self::GameBoyColor => "Game Boy Color",
272            Self::GameBoyAdvance => "Game Boy Advance",
273            Self::PcEngine => "PC Engine",
274        }
275    }
276
277    #[inline]
278    #[must_use]
279    pub fn supported_extensions(self) -> Vec<SupportedExtensions> {
280        fn single(extensions: &'static [&'static str]) -> Vec<SupportedExtensions> {
281            vec![SupportedExtensions { label: None, extensions, include_archives: true }]
282        }
283
284        match self {
285            Self::Sg1000 | Self::MasterSystem | Self::GameGear => single(&SMSGG),
286            Self::Genesis => single(GENESIS),
287            Self::SegaCd => vec![
288                SupportedExtensions { label: None, extensions: SEGA_CD, include_archives: false },
289                SupportedExtensions {
290                    label: Some("Genesis"),
291                    extensions: GENESIS,
292                    include_archives: true,
293                },
294            ],
295            Self::Sega32X => single(SEGA_32X),
296            Self::SegaCd32X => vec![
297                SupportedExtensions {
298                    label: None,
299                    extensions: &SEGA_CD_32X,
300                    include_archives: true,
301                },
302                SupportedExtensions {
303                    label: Some("Sega CD"),
304                    extensions: SEGA_CD,
305                    include_archives: false,
306                },
307                SupportedExtensions {
308                    label: Some("Genesis/32X"),
309                    extensions: &GENESIS_32X,
310                    include_archives: true,
311                },
312            ],
313            Self::Nes => single(NES),
314            Self::Snes => single(SNES),
315            Self::GameBoy | Self::GameBoyColor => single(&GB_GBC),
316            Self::GameBoyAdvance => single(GAME_BOY_ADVANCE),
317            Self::PcEngine => single(PC_ENGINE),
318        }
319    }
320
321    #[inline]
322    #[must_use]
323    pub const fn standard_extension(self) -> &'static str {
324        match self {
325            Self::MasterSystem => "sms",
326            Self::GameGear => "gg",
327            Self::Sg1000 => "sg",
328            Self::Genesis => "md",
329            Self::SegaCd | Self::SegaCd32X => "scd", // Intentionally not CUE or CHD, too ambiguous
330            Self::Sega32X => "32x",
331            Self::Nes => "nes",
332            Self::Snes => "sfc",
333            Self::GameBoy => "gb",
334            Self::GameBoyColor => "gbc",
335            Self::GameBoyAdvance => "gba",
336            Self::PcEngine => "pce",
337        }
338    }
339
340    #[inline]
341    #[must_use]
342    pub fn to_smsgg_hardware(self) -> Option<SmsGgHardware> {
343        match self {
344            Self::MasterSystem => Some(SmsGgHardware::MasterSystem),
345            Self::GameGear => Some(SmsGgHardware::GameGear),
346            Self::Sg1000 => Some(SmsGgHardware::Sg1000),
347            _ => None,
348        }
349    }
350
351    #[inline]
352    #[must_use]
353    pub fn to_genesis_hardware(self) -> Option<GenesisHardware> {
354        match self {
355            Self::Genesis => Some(GenesisHardware::Standalone),
356            Self::SegaCd => Some(GenesisHardware::SegaCd),
357            Self::Sega32X => Some(GenesisHardware::Sega32X),
358            Self::SegaCd32X => Some(GenesisHardware::SegaCd32X),
359            _ => None,
360        }
361    }
362}
363
364// Assuming this is a path to a Genesis or 32X image, determine what hardware is supported/required
365fn guess_genesis_console_raw_file(path: &Path) -> io::Result<Console> {
366    let file = File::open(path)?;
367    let file_len = file.metadata()?.len();
368    let buffer_len = cmp::min(file_len as usize, GENESIS_32X_HEADER_LEN);
369
370    let mut reader = BufReader::new(file);
371    let mut buffer = vec![0; buffer_len];
372    reader.read_exact(&mut buffer)?;
373
374    Ok(guess_genesis_console(&buffer))
375}
376
377fn guess_genesis_console(header: &[u8]) -> Console {
378    let start = s32x_core::SECURITY_PROGRAM_CARTRIDGE_ADDR;
379    let end = start + s32x_core::SECURITY_PROGRAM_LEN;
380    let contains_s32x_security_program =
381        header.len() >= end && &header[start..end] == s32x_core::security_program();
382
383    // 'C' in the devices section indicates Sega CD support: https://plutiedev.com/rom-header#devices
384    // Exclude J-Cart games (have device string 'OJKRPTBVFCA')
385    let mut supports_sega_cd = header.len() >= 0x1A0
386        && header[0x190..0x1A0].contains(&b'C')
387        && &header[0x190..0x19B] != b"OJKRPTBVFCA";
388
389    // Special case Flux (audio CD visualizer), header doesn't indicate Sega CD support
390    supports_sega_cd |= header.len() >= 0x18B && &header[0x180..0x18B] == b"GM T-70416-";
391
392    if supports_sega_cd && contains_s32x_security_program {
393        Console::SegaCd32X
394    } else if contains_s32x_security_program {
395        Console::Sega32X
396    } else if supports_sega_cd {
397        Console::SegaCd
398    } else {
399        Console::Genesis
400    }
401}
402
403// Assuming this is a path to a Sega CD disc image, check whether the game supports/requires 32X
404fn is_disc_sega_cd_32x(path: &Path) -> bool {
405    let Some(disc_format) = CdRomFileFormat::from_file_path(path) else { return false };
406    let Ok(mut disc) = CdRom::open(path, disc_format) else { return false };
407
408    segacd_core::is_cd_32x_disc(&mut disc)
409}