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}