Files
psxrecomp/runtime/src/disc_identity.cpp
T
Matthew Stanley 68667cd58b disc+bios: either file the user picks resolves, mounts, and verifies
Two path-resolution defects where the runtime silently accepted only one of
two equally-valid spellings of the same input.

1. Disc: picking the .cue threw away the disc's TOC.

normalize_disc_path_for_launch() swapped any .cue for its same-named .bin
unconditionally, and every disc entry point (game.toml, --disc, the cached
disc.cfg, the launcher's Change Disc, the first-run dialog) went through it.
That discarded the cue's table of contents -- every CD-DA track and pregap
with it -- even though iso_reader.cpp has full multi-FILE cue support that
the swap defeated before it was ever reached. In the other direction,
picking a redump audio-track bin mounted that track as the whole disc, which
cannot boot.

resolve_disc_path() (runtime/include/disc_path.h) now canonicalizes any pick:

  .cue that resolves            -> mount the cue (TOC intact)
  .cue with a missing bin       -> the payload it still names, else a
                                   stem-matched sibling image
  .bin/.iso/.img owned by a cue -> mount that cue
  bare image, no cue            -> mount it

identify_disc() resolves through the same code, so the header/serial/CRC32
verdict is read from the data track no matter which member was picked.

While here: the mount path and the verify path had two divergent private cue
parsers (quoted-only + case-sensitive vs quoted-or-bare + case-insensitive),
so they could disagree about the same file. Both now use one parser
(runtime/include/cue_sheet.h), which additionally handles CRLF, backslash
paths, and case-insensitive payload lookup for case-sensitive filesystems.

2. BIOS: the multi-BIOS rename made configs and dump folders incompatible.

SCPH*.toml name "US-PSX-SCPH1001.BIN" while every pre-existing dump folder,
bios.cfg and tools/regen_bios.sh default name "SCPH1001.BIN". Either
combination fataled with "cannot open BIOS file" despite the right ROM being
present. resolve_bios_rom() (recompiler/include/bios_rom_alias.h) matches on
the model token -- the last '-'-separated component of the stem -- so both
conventions resolve in both directions without a hardcoded alias table.
Applied at the config loader, the positional psxrecomp-bios CLI, and each
rung of the runtime's BIOS search.

The positional CLI also derived out_stem from the raw filename, so a
region-qualified ROM would have emitted US-PSX-SCPH1001_full.c -- a name
runtime.cmake cannot link. It now derives the model token.

Also fixes SCPH5552.toml, whose rom field omitted the bios/ directory that
its two siblings have.

Tests: test_disc_path_resolve (36 checks, five dump layouts) and
test_bios_rom_alias (15 checks, both directions + negative cases), both
registered in runtime/CMakeLists.txt. Registered the pre-existing
test_iso_reader_cdda, which was wired into no build.

Verified on real dumps: Ape Escape .cue and .bin both give
SCUS-94423 / NTSC-U / crc=C6F455BC; Tomba 2's multi-track dump gives
crc=1FB8C151 and a 2-track TOC from the .cue, the data bin, and the CD-DA
bin alike.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 22:17:52 -07:00

207 lines
8.1 KiB
C++

// disc_identity.cpp — see disc_identity.h.
//
// The header detection mirrors the proven raw-sector logic previously inlined
// in main.cpp (handles both 2352-byte raw and 2048-byte cooked sector layouts,
// which is what real .bin/.cue and .iso dumps use respectively).
#include "disc_identity.h"
#include "crc32.h"
#include "disc_path.h"
#include <algorithm>
#include <cctype>
#include <cstring>
#include <fstream>
#include <sstream>
#include <vector>
namespace fs = std::filesystem;
namespace PSXRecompV4 {
namespace {
std::string uppercase_ascii(std::string s) {
for (char& c : s) c = (char)std::toupper((unsigned char)c);
return s;
}
bool read_at(std::ifstream& f, uint64_t offset, uint8_t* out, size_t len) {
f.clear();
f.seekg((std::streamoff)offset, std::ios::beg);
if (!f.good()) return false;
f.read(reinterpret_cast<char*>(out), (std::streamsize)len);
return f.gcount() == (std::streamsize)len;
}
bool is_iso_pvd(const uint8_t* sector) {
return sector[0] == 0x01 && std::memcmp(&sector[1], "CD001", 5) == 0 && sector[6] == 0x01;
}
// "SCUS-94236" -> region. PSX serial prefixes encode the territory.
std::string region_from_serial(const std::string& serial) {
if (serial.size() < 4) return std::string();
const std::string p = uppercase_ascii(serial.substr(0, 4));
if (p == "SCUS" || p == "SLUS" || p == "LSP9") return "NTSC-U";
if (p == "SCES" || p == "SLES" || p == "SCED" || p == "SLED") return "PAL";
if (p == "SCPS" || p == "SLPS" || p == "SLPM" || p == "SCPM" || p == "ESPM") return "NTSC-J";
return std::string();
}
// Normalize a scanned EXE-style id "SCUS_942.36" to the game-id form
// "SCUS-94236". Pass-through for things already in game-id form. Returns ""
// if the token doesn't look like a serial.
std::string normalize_serial(const std::string& raw) {
std::string s;
for (char c : raw) {
if (std::isalnum((unsigned char)c)) s += (char)std::toupper((unsigned char)c);
// drop separators ('_', '.', '-')
}
// Expect 4 letters + 5 digits.
if (s.size() < 9) return std::string();
for (int i = 0; i < 4; i++) if (!std::isalpha((unsigned char)s[i])) return std::string();
for (int i = 4; i < 9; i++) if (!std::isdigit((unsigned char)s[i])) return std::string();
return s.substr(0, 4) + "-" + s.substr(4, 5);
}
// Scan the SYSTEM.CNF text region for the BOOT serial. PSX SYSTEM.CNF holds a
// line like `BOOT = cdrom:\SCUS_942.36;1`. We look for "cdrom:" and read the
// following filename token, then normalize it.
std::string scan_boot_serial(const std::vector<uint8_t>& buf) {
static const char kKey[] = "cdrom:";
const size_t klen = sizeof(kKey) - 1;
if (buf.size() < klen) return std::string();
for (size_t i = 0; i + klen < buf.size(); i++) {
bool hit = true;
for (size_t k = 0; k < klen; k++) {
char a = (char)std::tolower((unsigned char)buf[i + k]);
if (a != kKey[k]) { hit = false; break; }
}
if (!hit) continue;
// Read the token after "cdrom:", skipping any leading path separators.
size_t j = i + klen;
while (j < buf.size() && (buf[j] == '\\' || buf[j] == '/')) j++;
std::string token;
while (j < buf.size()) {
char c = (char)buf[j];
if (c == ';' || c == '\r' || c == '\n' || c == ' ' || c == '\t' || c == 0) break;
token += c;
j++;
if (token.size() > 32) break;
}
const size_t slash = token.find_last_of("\\/");
if (slash != std::string::npos) token = token.substr(slash + 1);
const std::string norm = normalize_serial(token);
if (!norm.empty()) return norm;
}
return std::string();
}
uint32_t crc32_stream(std::ifstream& f) {
f.clear();
f.seekg(0, std::ios::beg);
uint32_t crc = 0xFFFFFFFFu;
std::vector<uint8_t> chunk(1u << 20); // 1 MiB
while (f.good()) {
f.read(reinterpret_cast<char*>(chunk.data()), (std::streamsize)chunk.size());
const std::streamsize got = f.gcount();
if (got <= 0) break;
crc = crc32_update(crc, chunk.data(), (size_t)got);
}
return crc ^ 0xFFFFFFFFu;
}
} // namespace
DiscIdentity identify_disc(const fs::path& path,
const std::string& expected_serial,
uint32_t expected_crc, bool has_expected_crc,
bool compute_crc) {
DiscIdentity v;
v.expected_serial_given = !expected_serial.empty();
v.expected_crc_given = has_expected_crc;
// Resolve through the shared picked-path resolver so identity is read from
// the data track no matter which member of the dump the user handed us —
// the .cue and its .bin must produce the same verdict, serial and CRC32.
const DiscPathResolution resolved = resolve_disc_path(path);
const fs::path data_path = resolved.data;
std::ifstream f(data_path, std::ios::binary | std::ios::ate);
if (!f.is_open()) {
v.detail = resolved.note.empty()
? "Could not open the disc image or its CUE-referenced BIN file."
: resolved.note;
return v;
}
v.opened = true;
const uint64_t size = (uint64_t)f.tellg();
// PVD: try raw (2352) then cooked (2048) sector 16.
uint8_t sector[2048];
if ((size >= (16ull * 2352ull + 24ull + sizeof(sector))) &&
read_at(f, 16ull * 2352ull + 24ull, sector, sizeof(sector)) && is_iso_pvd(sector)) {
v.has_header = true;
} else if ((size >= (16ull * 2048ull + sizeof(sector))) &&
read_at(f, 16ull * 2048ull, sector, sizeof(sector)) && is_iso_pvd(sector)) {
v.has_header = true;
}
if (!v.has_header) {
v.detail = "No ISO9660 CD001 header was found at the standard PS1 disc locations.";
// Region may still be inferable from the expected serial for display.
v.region = region_from_serial(expected_serial);
return v;
}
// PVD volume identifier: offset 40, 32 bytes (space-padded).
{
std::string vol((const char*)&sector[40], 32);
const size_t last = vol.find_last_not_of(" \t\0");
if (last != std::string::npos) vol.erase(last + 1); else vol.clear();
v.volume_id = vol;
}
// Scan the early image for the boot serial + (if given) the expected serial.
const size_t scan_len = (size_t)std::min<uint64_t>(size, 16ull * 1024ull * 1024ull);
std::vector<uint8_t> scan(scan_len);
if (read_at(f, 0, scan.data(), scan.size())) {
v.detected_serial = scan_boot_serial(scan);
if (v.expected_serial_given) {
// Match either the game-id form (SCUS-94236) or the EXE form (SCUS_942.36).
const std::string serial_id = uppercase_ascii(expected_serial);
std::string exe_id = serial_id; // SCUS-94236 -> SCUS_942.36
if (serial_id.size() == 10 && serial_id[4] == '-')
exe_id = serial_id.substr(0, 4) + "_" + serial_id.substr(5, 3) + "." +
serial_id.substr(8, 2);
auto contains = [&](const std::string& needle) {
if (needle.empty() || needle.size() > scan.size()) return false;
return std::search(scan.begin(), scan.end(), needle.begin(), needle.end()) !=
scan.end();
};
v.serial_matches = contains(serial_id) || contains(exe_id);
if (!v.serial_matches && !v.detected_serial.empty() &&
v.detected_serial == serial_id)
v.serial_matches = true; // boot line agrees even if the literal bytes differ
}
} else {
v.detail = "The ISO header is present, but the image could not be scanned.";
}
// Region: prefer the disc's own serial, fall back to the expected one.
v.region = region_from_serial(!v.detected_serial.empty() ? v.detected_serial
: expected_serial);
// CRC32 (full image), streamed.
if (compute_crc) {
v.crc = crc32_stream(f);
v.crc_computed = true;
if (has_expected_crc) v.crc_matches = (v.crc == expected_crc);
}
return v;
}
} // namespace PSXRecompV4