Files
psxrecomp/runtime/src/disc_path.cpp
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

199 lines
7.1 KiB
C++

// disc_path.cpp — see disc_path.h.
#include "disc_path.h"
#include "cue_sheet.h"
#include <system_error>
#include <vector>
namespace fs = std::filesystem;
namespace PSXRecompV4 {
namespace {
// Raw image extensions we are willing to mount on their own, in preference
// order for the cue-fallback search.
const char* const kImageExtensions[] = { ".bin", ".img", ".iso" };
bool is_cue(const fs::path& p) { return path_has_extension_ci(p, ".cue"); }
bool is_raw_image(const fs::path& p) {
for (const char* ext : kImageExtensions) {
if (path_has_extension_ci(p, ext)) return true;
}
return false;
}
bool same_file_path(const fs::path& a, const fs::path& b) {
std::error_code ec;
if (fs::exists(a, ec) && fs::exists(b, ec)) {
ec.clear();
if (fs::equivalent(a, b, ec) && !ec) return true;
ec.clear();
}
return a.lexically_normal() == b.lexically_normal();
}
fs::path absolute_or_self(const fs::path& p) {
std::error_code ec;
fs::path abs = fs::absolute(p, ec);
return ec ? p : abs;
}
// The BINARY payload an unusable cue still points at: the data track when it
// is present, else the first existing BINARY FILE entry. Empty when the cue
// names nothing that is both BINARY and on disk.
fs::path first_existing_binary(const CueSheet& sheet) {
const size_t di = sheet.data_file_index();
if (di < sheet.files.size() && sheet.files[di].is_binary && sheet.files[di].exists)
return sheet.files[di].path;
for (const CueFileRef& f : sheet.files) {
if (f.is_binary && f.exists) return f.path;
}
return {};
}
// <stem>.bin / .img / .iso next to a cue we could not use.
fs::path find_sibling_image(const fs::path& cue) {
std::error_code ec;
for (const char* ext : kImageExtensions) {
fs::path cand = cue;
cand.replace_extension(ext);
ec.clear();
if (fs::exists(cand, ec) && !fs::is_directory(cand, ec)) return cand;
}
// Case-insensitive sweep for filesystems where ".BIN" != ".bin".
const fs::path dir = cue.parent_path();
ec.clear();
if (fs::is_directory(dir, ec)) {
const fs::path stem = cue.stem();
ec.clear();
for (fs::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec)) {
if (it->path().stem() != stem) continue;
if (is_raw_image(it->path())) return it->path();
}
}
return {};
}
// The cue that owns `image`: try <stem>.cue first (the common single-bin
// layout), then any cue in the same directory whose FILE list names it (the
// redump multi-track layout, where the cue is "Game.cue" and the payloads are
// "Game (Track 01).bin", ...).
fs::path find_owning_cue(const fs::path& image) {
std::error_code ec;
auto cue_owns_image = [&](const fs::path& cue) {
const CueSheet sheet = parse_cue_sheet(cue);
if (!sheet.usable()) return false;
for (const CueFileRef& f : sheet.files) {
if (same_file_path(f.path, image)) return true;
}
return false;
};
fs::path same_stem = image;
same_stem.replace_extension(".cue");
if (fs::exists(same_stem, ec) && cue_owns_image(same_stem)) return same_stem;
const fs::path dir = image.parent_path();
ec.clear();
if (!fs::is_directory(dir, ec)) return {};
ec.clear();
for (fs::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec)) {
if (!is_cue(it->path())) continue;
if (same_file_path(it->path(), same_stem)) continue; // already tried
if (cue_owns_image(it->path())) return it->path();
}
return {};
}
} // namespace
DiscPathResolution resolve_disc_path(const fs::path& picked) {
DiscPathResolution r;
r.picked = absolute_or_self(picked);
r.mount = r.picked;
r.data = r.picked;
if (r.picked.empty()) return r;
if (is_cue(r.picked)) {
const CueSheet sheet = parse_cue_sheet(r.picked);
if (sheet.usable()) {
const size_t di = sheet.data_file_index();
r.from_cue = true;
r.mount = r.picked;
r.data = di < sheet.files.size() ? sheet.files[di].path : r.picked;
return r;
}
// The cue is unusable. Recover to a raw image rather than failing the
// launch outright — a renamed or missing bin is the single most common
// way a hand-assembled dump folder is broken.
//
// Prefer a BINARY payload the cue itself names and that actually
// exists: a cue whose only defect is a WAVE audio track still points
// at a perfectly good data track. Only fall back to a stem-matched
// sibling when the cue names nothing usable at all.
fs::path sibling = first_existing_binary(sheet);
if (sibling.empty()) sibling = find_sibling_image(r.picked);
if (!sibling.empty()) {
r.mount = absolute_or_self(sibling);
r.data = r.mount;
r.cue_fallback = true;
r.note = "The cue sheet \"" + r.picked.filename().string() +
"\" does not resolve to a usable BINARY payload; falling back to \"" +
r.mount.filename().string() +
"\". CD audio tracks described by the cue will not be available.";
return r;
}
if (!sheet.opened) {
r.note = "The cue sheet \"" + r.picked.filename().string() + "\" could not be read.";
} else if (sheet.has_non_binary_file()) {
r.note = "The cue sheet \"" + r.picked.filename().string() +
"\" references a non-BINARY track payload (WAVE/MP3), which has no fixed "
"sector geometry and cannot be mounted.";
} else if (sheet.files.empty()) {
r.note = "The cue sheet \"" + r.picked.filename().string() + "\" names no FILE.";
} else {
std::string missing;
for (const CueFileRef& f : sheet.files) {
if (f.exists) continue;
if (!missing.empty()) missing += ", ";
missing += "\"" + f.name + "\"";
}
r.note = "The cue sheet \"" + r.picked.filename().string() +
"\" references files that are not next to it: " + missing + ".";
}
return r;
}
if (is_raw_image(r.picked)) {
const fs::path cue = find_owning_cue(r.picked);
if (!cue.empty()) {
const CueSheet sheet = parse_cue_sheet(cue);
const size_t di = sheet.data_file_index();
r.mount = absolute_or_self(cue);
r.data = di < sheet.files.size() ? sheet.files[di].path : r.picked;
r.from_cue = true;
r.upgraded_to_cue = true;
if (sheet.tracks.size() > 1) {
r.note = "Mounting \"" + r.mount.filename().string() + "\" instead of \"" +
r.picked.filename().string() + "\" so the disc's " +
std::to_string(sheet.tracks.size()) +
" tracks (including CD audio) are available.";
}
return r;
}
}
return r;
}
} // namespace PSXRecompV4