use crate::iso9660::PrimaryVolumeDescriptor;
use crate::sector_reader::{SectorReader, SECTOR_SIZE};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Console {
Ps1,
Ps2,
SegaSaturn,
SegaMegaCd,
SegaDreamcast,
AmigaCd32,
NeoGeoCd,
PcFx,
PcEngineCd,
GameCube,
Wii,
ThreeDo,
CdI,
}
impl Console {
pub fn display_name(self) -> &'static str {
match self {
Self::Ps1 => "Sony PlayStation",
Self::Ps2 => "Sony PlayStation 2",
Self::SegaSaturn => "Sega Saturn",
Self::SegaMegaCd => "Sega Mega-CD / Sega CD",
Self::SegaDreamcast => "Sega Dreamcast",
Self::AmigaCd32 => "Amiga CD32 / CDTV",
Self::NeoGeoCd => "SNK Neo Geo CD",
Self::PcFx => "NEC PC-FX",
Self::PcEngineCd => "NEC PC Engine CD",
Self::GameCube => "Nintendo GameCube",
Self::Wii => "Nintendo Wii",
Self::ThreeDo => "3DO",
Self::CdI => "Philips CD-i",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Region {
NtscU,
NtscJ,
Pal,
Asia,
Korea,
World,
Unknown,
}
impl Region {
pub fn display_name(self) -> &'static str {
match self {
Self::NtscU => "North America (NTSC-U)",
Self::NtscJ => "Japan (NTSC-J)",
Self::Pal => "Europe (PAL)",
Self::Asia => "Asia",
Self::Korea => "Korea",
Self::World => "World",
Self::Unknown => "Unknown",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GameDiscInfo {
pub console: Console,
pub serial: Option<String>,
pub title: Option<String>,
pub region: Option<Region>,
pub maker: Option<String>,
pub version: Option<String>,
}
impl GameDiscInfo {
fn just(console: Console) -> Self {
Self {
console,
serial: None,
title: None,
region: None,
maker: None,
version: None,
}
}
}
pub fn detect_game_disc(
reader: &mut dyn SectorReader,
pvd: Option<&PrimaryVolumeDescriptor>,
) -> Option<GameDiscInfo> {
if let Ok(sector0) = reader.read_sector(0) {
if let Some(info) = probe_sector0_header(§or0) {
return Some(info);
}
if sector0.len() >= 6 && sector0[0] == 0x01 && sector0[1..6] == [0x5A; 5] {
return Some(probe_3do(§or0));
}
}
if let Ok(sector1) = reader.read_sector(1) {
if sector1.len() >= 0x37 + 23 && §or1[0x20..0x20 + 23] == b"PC Engine CD-ROM SYSTEM" {
let title = ascii_field(§or1, 0x6A, 22);
return Some(GameDiscInfo {
title,
..GameDiscInfo::just(Console::PcEngineCd)
});
}
}
if crate::browse::cdi::detect_cdi(reader) {
return Some(GameDiscInfo {
title: crate::browse::cdi::read_volume_id(reader),
..GameDiscInfo::just(Console::CdI)
});
}
let parsed_pvd;
let pvd = match pvd {
Some(p) => Some(p),
None => {
parsed_pvd = PrimaryVolumeDescriptor::read_from(reader).ok();
parsed_pvd.as_ref()
}
};
if let Some(pvd) = pvd {
if pvd.system_id.trim_start().starts_with("CDTV") {
return Some(GameDiscInfo {
title: non_empty(pvd.volume_id.trim()),
..GameDiscInfo::just(Console::AmigaCd32)
});
}
if let Some(cnf) = read_root_file(reader, pvd, "SYSTEM.CNF") {
if let Some(info) = probe_playstation(&cnf, pvd) {
return Some(info);
}
}
if read_root_file(reader, pvd, "IPL.TXT").is_some() {
return Some(GameDiscInfo {
title: non_empty(pvd.volume_id.trim()),
..GameDiscInfo::just(Console::NeoGeoCd)
});
}
}
None
}
fn probe_sector0_header(s: &[u8]) -> Option<GameDiscInfo> {
if s.len() < 0x100 {
return None;
}
if s.starts_with(b"SEGA SEGASATURN") {
return Some(GameDiscInfo {
maker: ascii_field(s, 0x10, 16),
serial: ascii_field(s, 0x20, 10),
version: ascii_field(s, 0x2A, 6),
region: sega_area_region(&s[0x40..0x4A]),
title: ascii_field(s, 0x60, 112),
console: Console::SegaSaturn,
});
}
if s.starts_with(b"SEGA SEGAKATANA") {
return Some(GameDiscInfo {
maker: ascii_field(s, 0x10, 16),
region: sega_area_region(&s[0x30..0x38]),
serial: ascii_field(s, 0x40, 10),
version: ascii_field(s, 0x4A, 6),
title: ascii_field(s, 0x80, 128),
console: Console::SegaDreamcast,
});
}
if s.starts_with(b"SEGADISCSYSTEM")
|| s.starts_with(b"SEGABOOTDISC")
|| s.starts_with(b"SEGADATADISC")
{
let region = s.get(0x1F0..0x1F3).and_then(sega_area_region);
return Some(GameDiscInfo {
title: ascii_field(s, 0x150, 48).or_else(|| ascii_field(s, 0x120, 48)),
serial: ascii_field(s, 0x180, 14),
region,
..GameDiscInfo::just(Console::SegaMegaCd)
});
}
if s.starts_with(b"PC-FX:Hu_CD-ROM") {
return Some(GameDiscInfo {
maker: ascii_field(s, 0x10, 0xE0).map(|c| first_line(&c)),
..GameDiscInfo::just(Console::PcFx)
});
}
None
}
fn probe_3do(s: &[u8]) -> GameDiscInfo {
GameDiscInfo {
title: ascii_field(s, 0x28, 32),
..GameDiscInfo::just(Console::ThreeDo)
}
}
fn sega_area_region(area: &[u8]) -> Option<Region> {
let mut j = false;
let mut u = false;
let mut e = false;
for &b in area {
match b {
b'J' => j = true,
b'U' | b'T' | b'B' => u = true,
b'E' | b'A' | b'L' => e = true,
_ => {}
}
}
match (j, u, e) {
(true, false, false) => Some(Region::NtscJ),
(false, true, false) => Some(Region::NtscU),
(false, false, true) => Some(Region::Pal),
(false, false, false) => None,
_ => Some(Region::World),
}
}
fn probe_playstation(cnf: &[u8], pvd: &PrimaryVolumeDescriptor) -> Option<GameDiscInfo> {
let text = String::from_utf8_lossy(cnf);
let mut boot2 = None;
let mut boot = None;
for line in text.lines() {
let mut parts = line.splitn(2, '=');
let key = parts.next().unwrap_or("").trim();
let val = parts.next().unwrap_or("").trim();
if key.eq_ignore_ascii_case("BOOT2") {
boot2 = Some(val.to_string());
} else if key.eq_ignore_ascii_case("BOOT") {
boot = Some(val.to_string());
}
}
let (console, boot_val) = match (boot2, boot) {
(Some(v), _) => (Console::Ps2, v),
(None, Some(v)) => (Console::Ps1, v),
(None, None) => return None,
};
let serial = normalize_ps_serial(&boot_val);
let region = serial.as_deref().and_then(ps_region_from_serial);
Some(GameDiscInfo {
console,
title: non_empty(pvd.volume_id.trim()),
region,
serial,
maker: None,
version: None,
})
}
fn normalize_ps_serial(boot: &str) -> Option<String> {
let file = boot.rsplit(['\\', '/', ':']).next().unwrap_or(boot).trim();
let file = file.split(';').next().unwrap_or(file);
let normalized: String = file
.chars()
.filter(|&c| c != '.')
.map(|c| {
if c == '_' {
'-'
} else {
c.to_ascii_uppercase()
}
})
.collect();
if normalized.len() >= 8 && normalized.contains('-') {
Some(normalized)
} else {
None
}
}
fn ps_region_from_serial(serial: &str) -> Option<Region> {
let prefix: String = serial.chars().take(4).collect::<String>().to_uppercase();
match prefix.as_str() {
"SLUS" | "SCUS" | "LSP-" => Some(Region::NtscU),
"SLES" | "SCES" | "SLED" | "SCED" => Some(Region::Pal),
"SLPS" | "SLPM" | "SCPS" | "SIPS" => Some(Region::NtscJ),
"SLKA" | "SCKA" => Some(Region::Korea),
"SLAJ" | "SCAJ" => Some(Region::Asia),
_ => None,
}
}
fn read_root_file(
reader: &mut dyn SectorReader,
pvd: &PrimaryVolumeDescriptor,
name: &str,
) -> Option<Vec<u8>> {
let (lba, size) = find_root_entry(reader, pvd, name)?;
if size == 0 || size > 8 * 1024 * 1024 {
return None;
}
reader
.read_bytes(lba as u64 * SECTOR_SIZE, size as usize)
.ok()
}
fn find_root_entry(
reader: &mut dyn SectorReader,
pvd: &PrimaryVolumeDescriptor,
name: &str,
) -> Option<(u32, u32)> {
let flags_off = if pvd.high_sierra { 24 } else { 25 };
let data = reader
.read_bytes(
pvd.root_directory_lba as u64 * SECTOR_SIZE,
pvd.root_directory_size as usize,
)
.ok()?;
let mut pos = 0usize;
while pos < data.len() {
let len = data[pos] as usize;
if len == 0 {
let next = (pos / SECTOR_SIZE as usize + 1) * SECTOR_SIZE as usize;
if next <= pos || next >= data.len() {
break;
}
pos = next;
continue;
}
if len < 34 || pos + len > data.len() {
break;
}
let rec = &data[pos..pos + len];
let id_len = rec[32] as usize;
let is_dir = rec.get(flags_off).map(|&f| f & 0x02 != 0).unwrap_or(false);
if !is_dir && 33 + id_len <= rec.len() {
let id = &rec[33..33 + id_len];
let stem = id.split(|&b| b == b';').next().unwrap_or(id);
if stem.eq_ignore_ascii_case(name.as_bytes()) {
let lba = u32::from_le_bytes(rec[2..6].try_into().ok()?);
let dsize = u32::from_le_bytes(rec[10..14].try_into().ok()?);
return Some((lba, dsize));
}
}
pos += len;
}
None
}
fn ascii_field(buf: &[u8], off: usize, len: usize) -> Option<String> {
let slice = buf.get(off..off + len)?;
let end = slice.iter().position(|&b| b == 0).unwrap_or(slice.len());
let s: String = slice[..end]
.iter()
.map(|&b| {
if (0x20..0x7F).contains(&b) {
b as char
} else {
' '
}
})
.collect();
non_empty(s.trim())
}
fn first_line(s: &str) -> String {
s.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("")
.to_string()
}
fn non_empty(s: &str) -> Option<String> {
if s.is_empty() {
None
} else {
Some(s.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::{OpticaldiscsError, Result};
use std::io::{Cursor, Read, Seek, SeekFrom};
struct CursorReader(Cursor<Vec<u8>>);
impl SectorReader for CursorReader {
fn read_sector(&mut self, lba: u64) -> Result<Vec<u8>> {
self.0
.seek(SeekFrom::Start(lba * SECTOR_SIZE))
.map_err(OpticaldiscsError::Io)?;
let mut buf = vec![0u8; SECTOR_SIZE as usize];
self.0.read_exact(&mut buf).map_err(OpticaldiscsError::Io)?;
Ok(buf)
}
}
fn reader_with_sector0(data: &[u8]) -> CursorReader {
let mut img = vec![0u8; 4 * SECTOR_SIZE as usize];
img[..data.len()].copy_from_slice(data);
CursorReader(Cursor::new(img))
}
#[test]
fn detects_saturn() {
let mut s = vec![0u8; SECTOR_SIZE as usize];
s[..16].copy_from_slice(b"SEGA SEGASATURN ");
s[0x20..0x2A].copy_from_slice(b"T-1234G ");
s[0x40..0x4A].copy_from_slice(b"J ");
s[0x60..0x66].copy_from_slice(b"MYGAME");
let mut r = reader_with_sector0(&s);
let info = detect_game_disc(&mut r, None).unwrap();
assert_eq!(info.console, Console::SegaSaturn);
assert_eq!(info.serial.as_deref(), Some("T-1234G"));
assert_eq!(info.title.as_deref(), Some("MYGAME"));
assert_eq!(info.region, Some(Region::NtscJ));
}
#[test]
fn detects_dreamcast() {
let mut s = vec![0u8; SECTOR_SIZE as usize];
s[..16].copy_from_slice(b"SEGA SEGAKATANA ");
s[0x40..0x4A].copy_from_slice(b"HDR-0000 ");
s[0x80..0x86].copy_from_slice(b"SONICS");
let mut r = reader_with_sector0(&s);
let info = detect_game_disc(&mut r, None).unwrap();
assert_eq!(info.console, Console::SegaDreamcast);
assert_eq!(info.serial.as_deref(), Some("HDR-0000"));
assert_eq!(info.title.as_deref(), Some("SONICS"));
}
#[test]
fn detects_megacd() {
let mut s = vec![0u8; SECTOR_SIZE as usize];
s[..14].copy_from_slice(b"SEGADISCSYSTEM");
s[0x180..0x18E].copy_from_slice(b"GM MK-4501-00 ");
let mut r = reader_with_sector0(&s);
let info = detect_game_disc(&mut r, None).unwrap();
assert_eq!(info.console, Console::SegaMegaCd);
assert_eq!(info.serial.as_deref(), Some("GM MK-4501-00"));
}
#[test]
fn detects_pcfx() {
let mut s = vec![0u8; SECTOR_SIZE as usize];
s[..16].copy_from_slice(b"PC-FX:Hu_CD-ROM ");
let mut r = reader_with_sector0(&s);
let info = detect_game_disc(&mut r, None).unwrap();
assert_eq!(info.console, Console::PcFx);
}
#[test]
fn detects_3do() {
let mut s = vec![0u8; SECTOR_SIZE as usize];
s[0] = 0x01;
s[1..6].copy_from_slice(&[0x5A; 5]);
s[0x28..0x2E].copy_from_slice(b"3DOFS!");
let mut r = reader_with_sector0(&s);
let info = detect_game_disc(&mut r, None).unwrap();
assert_eq!(info.console, Console::ThreeDo);
assert_eq!(info.title.as_deref(), Some("3DOFS!"));
}
#[test]
fn detects_pcengine_sector1() {
let mut img = vec![0u8; 4 * SECTOR_SIZE as usize];
let s1 = SECTOR_SIZE as usize;
img[s1 + 0x20..s1 + 0x20 + 23].copy_from_slice(b"PC Engine CD-ROM SYSTEM");
img[s1 + 0x6A..s1 + 0x6A + 6].copy_from_slice(b"HUGAME");
let mut r = CursorReader(Cursor::new(img));
let info = detect_game_disc(&mut r, None).unwrap();
assert_eq!(info.console, Console::PcEngineCd);
assert_eq!(info.title.as_deref(), Some("HUGAME"));
}
#[test]
fn normalize_ps_serial_works() {
assert_eq!(
normalize_ps_serial("cdrom:\\SLUS_005.55;1").as_deref(),
Some("SLUS-00555")
);
assert_eq!(
normalize_ps_serial("cdrom0:\\SLUS_216.29;1").as_deref(),
Some("SLUS-21629")
);
}
#[test]
fn ps_region_table() {
assert_eq!(ps_region_from_serial("SLUS-00555"), Some(Region::NtscU));
assert_eq!(ps_region_from_serial("SCES-12345"), Some(Region::Pal));
assert_eq!(ps_region_from_serial("SLPS-01234"), Some(Region::NtscJ));
}
fn build_iso_with_file(name: &str, contents: &[u8]) -> (Vec<u8>, PrimaryVolumeDescriptor) {
let root_lba = 20u32;
let file_lba = 22u32;
let mut img = vec![0u8; 24 * SECTOR_SIZE as usize];
let id = format!("{name};1");
let mut rec = vec![0u8; 33 + id.len()];
rec[2..6].copy_from_slice(&file_lba.to_le_bytes());
rec[6..10].copy_from_slice(&file_lba.to_be_bytes());
rec[10..14].copy_from_slice(&(contents.len() as u32).to_le_bytes());
rec[14..18].copy_from_slice(&(contents.len() as u32).to_be_bytes());
rec[25] = 0; rec[32] = id.len() as u8;
rec[33..].copy_from_slice(id.as_bytes());
if rec.len() % 2 == 1 {
rec.push(0);
}
rec[0] = rec.len() as u8;
let root_off = root_lba as usize * SECTOR_SIZE as usize;
img[root_off..root_off + rec.len()].copy_from_slice(&rec);
let file_off = file_lba as usize * SECTOR_SIZE as usize;
img[file_off..file_off + contents.len()].copy_from_slice(contents);
let pvd = PrimaryVolumeDescriptor {
volume_id: "GAME".into(),
system_id: "PLAYSTATION".into(),
volume_set_id: String::new(),
publisher_id: String::new(),
application_id: String::new(),
volume_space_size: 24,
logical_block_size: 2048,
root_directory_lba: root_lba,
root_directory_size: SECTOR_SIZE as u32,
creation_date: None,
modification_date: None,
expiration_date: None,
effective_date: None,
high_sierra: false,
};
(img, pvd)
}
#[test]
fn detects_ps1_via_system_cnf() {
let (img, pvd) = build_iso_with_file("SYSTEM.CNF", b"BOOT = cdrom:\\SLUS_005.55;1\r\n");
let mut r = CursorReader(Cursor::new(img));
let info = detect_game_disc(&mut r, Some(&pvd)).unwrap();
assert_eq!(info.console, Console::Ps1);
assert_eq!(info.serial.as_deref(), Some("SLUS-00555"));
assert_eq!(info.region, Some(Region::NtscU));
}
#[test]
fn detects_ps2_via_boot2() {
let (img, pvd) = build_iso_with_file(
"SYSTEM.CNF",
b"BOOT2 = cdrom0:\\SLES_216.29;1\r\nVER = 1.00\r\n",
);
let mut r = CursorReader(Cursor::new(img));
let info = detect_game_disc(&mut r, Some(&pvd)).unwrap();
assert_eq!(info.console, Console::Ps2);
assert_eq!(info.serial.as_deref(), Some("SLES-21629"));
assert_eq!(info.region, Some(Region::Pal));
}
#[test]
fn detects_ps2_dvd_without_pvd() {
let (mut img, pvd) =
build_iso_with_file("SYSTEM.CNF", b"BOOT2 = cdrom0:\\SLPM_651.40;1\r\n");
let pvd_sector = crate::iso9660::build_test_pvd_sector(
"PS2GAME",
pvd.root_directory_lba,
pvd.root_directory_size,
);
let off = 16 * SECTOR_SIZE as usize;
img[off..off + pvd_sector.len()].copy_from_slice(&pvd_sector);
let mut r = CursorReader(Cursor::new(img));
let info = detect_game_disc(&mut r, None).unwrap();
assert_eq!(info.console, Console::Ps2);
assert_eq!(info.serial.as_deref(), Some("SLPM-65140"));
}
#[test]
fn detects_neogeo_via_ipl() {
let (img, pvd) = build_iso_with_file("IPL.TXT", b"PROG.PRG,0,0\r\n");
let mut r = CursorReader(Cursor::new(img));
let info = detect_game_disc(&mut r, Some(&pvd)).unwrap();
assert_eq!(info.console, Console::NeoGeoCd);
}
#[test]
fn no_match_returns_none() {
let mut r = reader_with_sector0(&[0u8; 16]);
assert!(detect_game_disc(&mut r, None).is_none());
}
}