diff --git a/SCPH5552.toml b/SCPH5552.toml index 157d5015..35a4f1e2 100644 --- a/SCPH5552.toml +++ b/SCPH5552.toml @@ -5,7 +5,7 @@ [program] name = "SCPH5552 BIOS (Europe)" id = "SCPH-5552" -rom = "EUR-PSX-SCPH5552.bin" +rom = "bios/EUR-PSX-SCPH5552.bin" load_address = "0xBFC00000" entry_pc = "0xBFC00000" text_size = "0x80000" diff --git a/recompiler/include/bios_rom_alias.h b/recompiler/include/bios_rom_alias.h new file mode 100644 index 00000000..249a093a --- /dev/null +++ b/recompiler/include/bios_rom_alias.h @@ -0,0 +1,95 @@ +// bios_rom_alias.h — accept both BIOS ROM filename conventions. +// +// The multi-BIOS work introduced region-qualified ROM filenames +// ("US-PSX-SCPH1001.BIN", "US-PSOne-SCPH101.BIN", "EUR-PSX-SCPH5552.bin") +// alongside the historical bare model name ("SCPH1001.BIN"). Both conventions +// are in the wild: every existing dump folder, bios.cfg, game.toml and +// tools/regen_bios.sh default uses the bare name, while master's SCPH*.toml +// files now name the qualified one. Whichever a config asks for, the other +// must still be found — otherwise a config and a dump folder that disagree +// fatal out ("cannot open BIOS file") even though the right ROM is sitting +// right there. +// +// Matching is on the MODEL TOKEN: the last '-'-separated component of the +// filename stem, compared case-insensitively. +// +// "US-PSX-SCPH1001.BIN" -> SCPH1001 +// "SCPH1001.BIN" -> SCPH1001 +// "EUR-PSX-SCPH5552.bin" -> SCPH5552 +// +// That is convention-agnostic rather than a hardcoded alias table, so a ROM +// named with a region prefix nobody has thought of yet still resolves. +// +// Header-only, and deliberately placed in recompiler/include because both +// build trees already put that directory on the include path (see +// runtime/runtime.cmake) — the recompiler resolves the [program] rom field +// with it, the runtime resolves its BIOS search rungs with it, and neither can +// drift from the other. + +#pragma once + +#include +#include +#include +#include + +namespace PSXRecompV4 { + +// The model token of a BIOS filename: the last '-'-separated piece of the +// stem, uppercased. "" for an empty stem. +inline std::string bios_model_token(const std::filesystem::path& p) { + std::string stem = p.stem().string(); + const size_t dash = stem.find_last_of('-'); + if (dash != std::string::npos) stem = stem.substr(dash + 1); + for (char& c : stem) c = (char)std::toupper((unsigned char)c); + return stem; +} + +// Does `candidate` name the same BIOS model as `wanted_token`, with a +// BIOS-ish extension? Extension is compared case-insensitively (.BIN/.bin). +inline bool bios_name_matches(const std::filesystem::path& candidate, + const std::string& wanted_token) { + if (wanted_token.empty()) return false; + std::string ext = candidate.extension().string(); + for (char& c : ext) c = (char)std::tolower((unsigned char)c); + if (ext != ".bin" && ext != ".rom") return false; + return bios_model_token(candidate) == wanted_token; +} + +// Resolve a configured BIOS ROM path to something that exists, tolerating +// either filename convention. Returns `configured` unchanged when nothing +// better is found, so the caller's own "cannot open BIOS file" diagnostic +// still fires and still names what was asked for. +inline std::filesystem::path resolve_bios_rom(const std::filesystem::path& configured) { + namespace fs = std::filesystem; + std::error_code ec; + + if (configured.empty()) return configured; + if (fs::exists(configured, ec) && !fs::is_directory(configured, ec)) return configured; + + const std::string want = bios_model_token(configured); + + // Same directory, other naming convention (either direction). + auto scan = [&](const fs::path& dir) -> fs::path { + std::error_code dec; + if (dir.empty() || !fs::is_directory(dir, dec)) return {}; + dec.clear(); + for (fs::directory_iterator it(dir, dec), end; !dec && it != end; it.increment(dec)) { + if (!it->is_regular_file(dec)) continue; + if (bios_name_matches(it->path(), want)) return it->path(); + } + return {}; + }; + + if (fs::path hit = scan(configured.parent_path()); !hit.empty()) return hit; + + // A config that names the ROM without its "bios/" directory (master's + // SCPH5552.toml does exactly this) still resolves against the conventional + // location. + const fs::path bios_dir = configured.parent_path() / "bios"; + if (fs::path hit = scan(bios_dir); !hit.empty()) return hit; + + return configured; +} + +} // namespace PSXRecompV4 diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 61630415..609d34d0 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -9,6 +9,7 @@ #include #include +#include "bios_rom_alias.h" #include "fmt/format.h" #include "ps1_exe_parser.h" @@ -542,7 +543,10 @@ BiosConfig load_bios_config(const fs::path& config_path_in) { fmt::format("{}: [program] missing 'rom' or 'exe' field", config_path.string())); } - const fs::path rom_path = fs::absolute(root / rom_field); + // Tolerate either BIOS filename convention (bare "SCPH1001.BIN" vs + // region-qualified "US-PSX-SCPH1001.BIN") — see bios_rom_alias.h. A no-op + // for game [program].exe fields, which never match a BIOS model token. + const fs::path rom_path = resolve_bios_rom(fs::absolute(root / rom_field)); const uint32_t load_address = parse_hex(toml::find(prog, "load_address"), "program.load_address"); diff --git a/recompiler/src/main_bios.cpp b/recompiler/src/main_bios.cpp index 5b44c0f9..2b0ef5dc 100644 --- a/recompiler/src/main_bios.cpp +++ b/recompiler/src/main_bios.cpp @@ -40,6 +40,7 @@ #include "fmt/format.h" #include "bios_slice_walker.h" +#include "bios_rom_alias.h" #include "config_loader.h" #include "full_function_emitter.h" #include "function_discovery.h" @@ -965,7 +966,11 @@ int main(int argc, char** argv) { " psxrecomp-bios --emit-full \n"); return 2; } - const fs::path bios_path = argv[1]; + // Accept either BIOS filename convention: the legacy bare model name + // ("SCPH1001.BIN", which tools/regen_bios.sh still defaults to) and the + // region-qualified one ("US-PSX-SCPH1001.BIN") each resolve to whichever + // is actually on disk. See recompiler/include/bios_rom_alias.h. + const fs::path bios_path = PSXRecompV4::resolve_bios_rom(fs::path(argv[1])); const fs::path out_dir = argv[2]; std::optional cc_override; std::optional seed_path; @@ -986,8 +991,13 @@ int main(int argc, char** argv) { } if (emit_full_seed_path) { - // Derive out_stem from ROM filename: "SCPH1001.BIN" -> "SCPH1001" - const std::string stem = bios_path.stem().string(); + // Derive out_stem from the ROM's MODEL TOKEN, not its raw stem, so + // the emitted filenames are the same either way the ROM is named: + // "SCPH1001.BIN" -> SCPH1001 + // "US-PSX-SCPH1001.BIN" -> SCPH1001 (not "US-PSX-SCPH1001") + // runtime.cmake links generated/_full.c by the model name, so + // deriving the raw stem here would emit files it cannot find. + const std::string stem = PSXRecompV4::bios_model_token(bios_path); return run_emit_full(bios_path, out_dir, *emit_full_seed_path, stem); } else if (seed_path) { return run_discover(bios_path, out_dir, *seed_path); diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 5cdee5d1..9a253320 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -85,6 +85,31 @@ if(BUILD_TESTING) target_include_directories(overlay_path_canon_test PRIVATE include) add_test(NAME overlay_path_canon_test COMMAND overlay_path_canon_test) + # Either member of a disc dump (.cue or .bin) is a valid pick: both mount + # the same disc and identify off the same data track. + add_executable(disc_path_resolve_test + tests/test_disc_path_resolve.cpp + src/disc_path.cpp + src/cue_sheet.cpp + src/iso_reader.cpp) + target_include_directories(disc_path_resolve_test PRIVATE include) + add_test(NAME disc_path_resolve_test COMMAND disc_path_resolve_test) + + # Both BIOS filename conventions (bare vs region-qualified) resolve, in + # both directions, so a config and a dump folder may disagree. + add_executable(bios_rom_alias_test tests/test_bios_rom_alias.cpp) + target_include_directories(bios_rom_alias_test PRIVATE + ${PSXRECOMP_ROOT}/recompiler/include) + add_test(NAME bios_rom_alias_test COMMAND bios_rom_alias_test) + + # Multi-file cue TOC mapping (data track + CD-DA in separate bins). + add_executable(iso_reader_cdda_test + tests/test_iso_reader_cdda.cpp + src/iso_reader.cpp + src/cue_sheet.cpp) + target_include_directories(iso_reader_cdda_test PRIVATE include) + add_test(NAME iso_reader_cdda_test COMMAND iso_reader_cdda_test) + if(WIN32) add_executable(autocompile_publication_test tests/test_autocompile_publication.c diff --git a/runtime/include/cue_sheet.h b/runtime/include/cue_sheet.h new file mode 100644 index 00000000..33f060bc --- /dev/null +++ b/runtime/include/cue_sheet.h @@ -0,0 +1,77 @@ +// cue_sheet.h — the single cue-sheet parser for the runtime. +// +// A .cue is the authoritative table of contents for a PS1 disc dump: it names +// the backing BINARY file(s) and the TRACK/INDEX layout (data track, CD-DA +// audio tracks, pregaps). Everything that needs to understand a cue goes +// through here so the mount path (iso_reader.cpp), the identity/verify path +// (disc_identity.cpp) and the picked-path resolver (disc_path.cpp) can never +// disagree about what a given cue describes. +// +// Parsing is deliberately permissive about surface syntax (case, quoting, +// leading whitespace) and strict about semantics: a FILE whose type is not +// BINARY is reported rather than guessed at, because WAVE/MP3 payloads have no +// fixed sector geometry and mis-mapping the TOC is worse than refusing the +// image. + +#pragma once + +#include +#include +#include +#include + +namespace PSXRecompV4 { + +// One FILE line, resolved against the cue's own directory. +struct CueFileRef { + std::string name; // exactly as written in the cue + std::filesystem::path path; // resolved (absolute where possible) + bool is_binary = false; // FILE type token was BINARY + bool exists = false; // resolved path is a real file +}; + +// One TRACK, with its INDEX times still expressed relative to the OWNING FILE +// (the cue's own convention). Converting to disc-relative LBAs needs each +// file's sector count, which only the mounting code knows. +struct CueTrackRef { + int number = 0; + bool is_audio = false; + size_t file_index = 0; // index into CueSheet::files + uint32_t index01 = 0; // track start, file-relative LBA + uint32_t index00 = 0; // pregap start, file-relative LBA + bool has_index00 = false; +}; + +struct CueSheet { + bool opened = false; // the .cue itself was readable + + std::vector files; // in cue order + std::vector tracks; // in cue order + + // True when every FILE line named a BINARY payload that exists on disk — + // i.e. this cue can actually be mounted as written. + bool usable() const; + + // True when at least one FILE line named a non-BINARY payload. + bool has_non_binary_file() const; + + // Index of the file backing the first non-audio track, else 0. This is the + // file that carries SYSTEM.CNF / the ISO9660 header, so it is the one + // identity and CRC checks must read. + size_t data_file_index() const; +}; + +// Case-insensitive extension test. `dotted_ext` includes the dot, e.g. ".cue". +bool path_has_extension_ci(const std::filesystem::path& p, const char* dotted_ext); + +// Resolve `name` (as written in a cue) against `dir`. Falls back to a +// case-insensitive directory scan when the literal name does not exist, so a +// redump cue written on Windows still mounts on a case-sensitive filesystem. +// Returns the literal resolution when nothing matches. +std::filesystem::path resolve_cue_relative_file(const std::filesystem::path& dir, + const std::string& name); + +// Parse `cue_path`. A sheet whose `opened` is false could not be read at all. +CueSheet parse_cue_sheet(const std::filesystem::path& cue_path); + +} // namespace PSXRecompV4 diff --git a/runtime/include/disc_path.h b/runtime/include/disc_path.h new file mode 100644 index 00000000..63bd1828 --- /dev/null +++ b/runtime/include/disc_path.h @@ -0,0 +1,52 @@ +// disc_path.h — canonicalize whatever disc file the user actually picked. +// +// Players do not reliably know whether to hand us the .cue or the .bin, and +// both are legitimate things to find in a dump folder. Either pick must mount +// and must verify identically, so every entry point that accepts a disc path +// (game.toml [runtime] disc, --disc, the cached disc.cfg, the launcher's +// "Change Disc" picker, the first-run file dialog) routes through +// resolve_disc_path() first. +// +// The rule is "prefer the cue, but never require it": +// +// picked a .cue that resolves -> mount the cue (keeps the TOC) +// picked a .cue with a missing bin -> mount a sibling image, if one exists +// picked a .bin/.iso/.img owned by a cue -> mount that cue (keeps the TOC) +// picked a .bin/.iso/.img with no cue -> mount it directly +// +// Preferring the cue matters beyond tidiness: the cue is the only place the +// CD-DA track layout lives. Silently swapping a cue for its same-named .bin +// (which is what the runtime used to do) drops every audio track and pregap on +// a single-file multi-track dump. +// +// `data` is always the binary carrying the ISO9660 header and SYSTEM.CNF, so +// header / serial / CRC32 checks read the same bytes no matter which file the +// user picked — that is what keeps the "Disc verified" verdict stable across +// picks. + +#pragma once + +#include +#include + +namespace PSXRecompV4 { + +struct DiscPathResolution { + std::filesystem::path picked; // what the caller asked for (absolute where possible) + std::filesystem::path mount; // hand this to ISOReader::Open / cdrom_init + std::filesystem::path data; // data-track binary; read identity/CRC from this + + bool from_cue = false; // `mount` is a cue sheet + bool upgraded_to_cue = false; // caller picked a bin; we found the cue that owns it + bool cue_fallback = false; // caller picked an unusable cue; fell back to a bin + + // Human-readable note describing any substitution, "" when `mount` is just + // `picked`. Suitable for a launcher warning or a startup banner. + std::string note; +}; + +// Never fails: an unrecognizable or missing path resolves to itself, so the +// caller's existing "could not open the disc" reporting still fires. +DiscPathResolution resolve_disc_path(const std::filesystem::path& picked); + +} // namespace PSXRecompV4 diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 1abf42c7..f860dcf6 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -143,6 +143,8 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/gte.cpp ${PSXRECOMP_ROOT}/runtime/src/crc32.c ${PSXRECOMP_ROOT}/runtime/src/disc_identity.cpp + ${PSXRECOMP_ROOT}/runtime/src/cue_sheet.cpp + ${PSXRECOMP_ROOT}/runtime/src/disc_path.cpp ${PSXRECOMP_ROOT}/runtime/src/cdrom.c ${PSXRECOMP_ROOT}/runtime/src/spu.c ${PSXRECOMP_ROOT}/runtime/src/spu_shadow.c diff --git a/runtime/src/cue_sheet.cpp b/runtime/src/cue_sheet.cpp new file mode 100644 index 00000000..bad497dc --- /dev/null +++ b/runtime/src/cue_sheet.cpp @@ -0,0 +1,190 @@ +// cue_sheet.cpp — see cue_sheet.h. + +#include "cue_sheet.h" + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace PSXRecompV4 { + +namespace { + +std::string upper_ascii(std::string s) { + for (char& c : s) c = (char)std::toupper((unsigned char)c); + return s; +} + +bool equal_ci(const std::string& a, const std::string& b) { + if (a.size() != b.size()) return false; + for (size_t i = 0; i < a.size(); i++) { + if (std::toupper((unsigned char)a[i]) != std::toupper((unsigned char)b[i])) return false; + } + return true; +} + +// Extract the FILE line's payload name and type token. Handles both the quoted +// form (`FILE "game.bin" BINARY`) and the bare form (`FILE game.bin BINARY`) +// that some hand-written cues use. +bool parse_file_line(const std::string& line, size_t file_kw_pos, + std::string& out_name, std::string& out_type) { + const size_t after_kw = file_kw_pos + 4; // strlen("FILE") + const size_t q1 = line.find('"', after_kw); + size_t rest_pos; + + if (q1 != std::string::npos) { + const size_t q2 = line.find('"', q1 + 1); + if (q2 == std::string::npos || q2 <= q1 + 1) return false; + out_name = line.substr(q1 + 1, q2 - q1 - 1); + rest_pos = q2 + 1; + } else { + std::istringstream iss(line.substr(after_kw)); + if (!(iss >> out_name) || out_name.empty()) return false; + rest_pos = after_kw; + // Advance past the name we just consumed so the type token scan below + // starts after it. + const size_t name_at = line.find(out_name, after_kw); + if (name_at != std::string::npos) rest_pos = name_at + out_name.size(); + } + + std::istringstream rest(line.substr(rest_pos)); + out_type.clear(); + rest >> out_type; + return true; +} + +} // namespace + +bool CueSheet::usable() const { + if (!opened || files.empty()) return false; + for (const CueFileRef& f : files) { + if (!f.is_binary || !f.exists) return false; + } + return true; +} + +bool CueSheet::has_non_binary_file() const { + for (const CueFileRef& f : files) { + if (!f.is_binary) return true; + } + return false; +} + +size_t CueSheet::data_file_index() const { + for (const CueTrackRef& t : tracks) { + if (!t.is_audio && t.file_index < files.size()) return t.file_index; + } + return 0; +} + +bool path_has_extension_ci(const fs::path& p, const char* dotted_ext) { + return equal_ci(p.extension().string(), std::string(dotted_ext)); +} + +fs::path resolve_cue_relative_file(const fs::path& dir, const std::string& name) { + fs::path named(name); + // Cues written on Windows may use backslashes; std::filesystem on POSIX + // treats those as ordinary characters, so normalize them first. + if (name.find('\\') != std::string::npos) { + std::string slashed = name; + for (char& c : slashed) if (c == '\\') c = '/'; + named = fs::path(slashed); + } + + const fs::path literal = named.is_absolute() ? named : (dir / named); + std::error_code ec; + if (fs::exists(literal, ec)) return literal; + + // Case-insensitive fallback: scan the containing directory for a filename + // that differs only by case. + const fs::path parent = literal.parent_path(); + const std::string want = literal.filename().string(); + ec.clear(); + if (fs::is_directory(parent, ec)) { + ec.clear(); + for (fs::directory_iterator it(parent, ec), end; !ec && it != end; it.increment(ec)) { + if (equal_ci(it->path().filename().string(), want)) return it->path(); + } + } + return literal; +} + +CueSheet parse_cue_sheet(const fs::path& cue_path) { + CueSheet sheet; + + std::ifstream cue(cue_path); + if (!cue.is_open()) return sheet; + sheet.opened = true; + + const fs::path dir = cue_path.parent_path(); + + std::string line; + int cur_track_num = -1; + bool cur_track_audio = false; + uint32_t cur_index00 = 0; + bool cur_has_index00 = false; + + while (std::getline(cue, line)) { + // Strip a trailing CR so cues with CRLF endings parse on POSIX. + if (!line.empty() && line.back() == '\r') line.pop_back(); + + const std::string upper = upper_ascii(line); + + const size_t file_pos = upper.find("FILE"); + if (file_pos != std::string::npos) { + std::string name, type; + if (parse_file_line(line, file_pos, name, type)) { + CueFileRef ref; + ref.name = name; + ref.is_binary = equal_ci(type, "BINARY"); + ref.path = resolve_cue_relative_file(dir, name); + std::error_code ec; + ref.exists = fs::exists(ref.path, ec) && !fs::is_directory(ref.path, ec); + sheet.files.push_back(std::move(ref)); + } + // A FILE line resets any track state carried from the previous file. + cur_track_num = -1; + cur_has_index00 = false; + cur_index00 = 0; + continue; + } + + int tn = 0; + char type_buf[32] = {0}; + if (std::sscanf(upper.c_str(), " TRACK %d %31s", &tn, type_buf) == 2) { + cur_track_num = tn; + cur_track_audio = (std::string(type_buf).find("AUDIO") != std::string::npos); + cur_index00 = 0; + cur_has_index00 = false; + continue; + } + + int idx = 0, mm = 0, ss = 0, ff = 0; + if (std::sscanf(upper.c_str(), " INDEX %d %d:%d:%d", &idx, &mm, &ss, &ff) == 4 && + cur_track_num >= 1 && !sheet.files.empty()) { + const uint32_t index_lba = (uint32_t)(((mm * 60 + ss) * 75) + ff); + if (idx == 0) { + cur_index00 = index_lba; + cur_has_index00 = true; + continue; + } + if (idx != 1) continue; + CueTrackRef t; + t.number = cur_track_num; + t.is_audio = cur_track_audio; + t.file_index = sheet.files.size() - 1; + t.index01 = index_lba; + t.index00 = cur_index00; + t.has_index00 = cur_has_index00; + sheet.tracks.push_back(t); + cur_track_num = -1; + } + } + + return sheet; +} + +} // namespace PSXRecompV4 diff --git a/runtime/src/disc_identity.cpp b/runtime/src/disc_identity.cpp index 04ca7625..f5c78ac7 100644 --- a/runtime/src/disc_identity.cpp +++ b/runtime/src/disc_identity.cpp @@ -7,6 +7,7 @@ #include "disc_identity.h" #include "crc32.h" +#include "disc_path.h" #include #include @@ -26,47 +27,6 @@ std::string uppercase_ascii(std::string s) { return s; } -bool ends_with_ci(const std::string& s, const std::string& suffix) { - if (s.size() < suffix.size()) return false; - for (size_t i = 0; i < suffix.size(); i++) { - char a = (char)std::toupper((unsigned char)s[s.size() - suffix.size() + i]); - char b = (char)std::toupper((unsigned char)suffix[i]); - if (a != b) return false; - } - return true; -} - -// If `path` is a .cue, return the first BINARY FILE it references (resolved -// relative to the cue's directory). Otherwise return `path` unchanged. -fs::path cue_data_path(const fs::path& path) { - if (!ends_with_ci(path.string(), ".cue")) return path; - std::ifstream cue(path); - if (!cue.is_open()) return path; - - std::string line; - while (std::getline(cue, line)) { - const std::string upper = uppercase_ascii(line); - if (upper.find("FILE") == std::string::npos || - upper.find("BINARY") == std::string::npos) - continue; - const size_t file_pos = upper.find("FILE"); - size_t q1 = line.find('"', file_pos); - size_t q2 = (q1 == std::string::npos) ? std::string::npos : line.find('"', q1 + 1); - std::string bin_name; - if (q1 != std::string::npos && q2 != std::string::npos && q2 > q1 + 1) { - bin_name = line.substr(q1 + 1, q2 - q1 - 1); - } else { - std::istringstream iss(line.substr(file_pos + 4)); - iss >> bin_name; - } - if (bin_name.empty()) break; - fs::path bin_path(bin_name); - if (bin_path.is_relative()) return path.parent_path() / bin_path; - return bin_path; - } - return path; -} - 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); @@ -162,10 +122,16 @@ DiscIdentity identify_disc(const fs::path& path, v.expected_serial_given = !expected_serial.empty(); v.expected_crc_given = has_expected_crc; - const fs::path data_path = cue_data_path(path); + // 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 = "Could not open the disc image or its CUE-referenced BIN file."; + v.detail = resolved.note.empty() + ? "Could not open the disc image or its CUE-referenced BIN file." + : resolved.note; return v; } v.opened = true; diff --git a/runtime/src/disc_path.cpp b/runtime/src/disc_path.cpp new file mode 100644 index 00000000..669c6681 --- /dev/null +++ b/runtime/src/disc_path.cpp @@ -0,0 +1,198 @@ +// disc_path.cpp — see disc_path.h. + +#include "disc_path.h" + +#include "cue_sheet.h" + +#include +#include + +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 {}; +} + +// .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 .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 diff --git a/runtime/src/iso_reader.cpp b/runtime/src/iso_reader.cpp index 400361d1..7667b656 100644 --- a/runtime/src/iso_reader.cpp +++ b/runtime/src/iso_reader.cpp @@ -1,4 +1,5 @@ #include "iso_reader.h" +#include "cue_sheet.h" #include #include #include @@ -46,101 +47,30 @@ bool ISOReader::Open(const std::string& filename) { // OWNING FILE, so remember which file each track belongs to and convert // to disc-relative LBAs once the segment table (with each file's first // disc sector) is built below. - struct PendingTrack { - int number; - bool is_audio; - size_t file_index; // index into bin_files - uint32_t index01; // INDEX 01 as a file-relative LBA - uint32_t index00; // INDEX 00 pregap, file-relative - bool has_index00; - }; + using PendingTrack = PSXRecompV4::CueTrackRef; std::vector pending_tracks; - auto ends_with = [](const std::string& s, const std::string& suffix) { - return s.size() >= suffix.size() && - s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0; - }; - if (ends_with(filename, ".cue") || ends_with(filename, ".CUE")) { - // Parse the .cue: resolve every FILE, AND build the track TOC from - // the TRACK/INDEX lines. Single-file cues keep their historical - // behavior; multi-file cues (data track + CD-DA audio tracks in - // separate .bin files) map each file to a contiguous run of disc - // sectors, concatenated in cue order. - std::ifstream cue_file(filename); - if (!cue_file.is_open()) { + if (PSXRecompV4::path_has_extension_ci(std::filesystem::path(filename), ".cue")) { + // Parse the .cue via the shared parser: resolve every FILE, AND build + // the track TOC from the TRACK/INDEX lines. Single-file cues keep + // their historical behavior; multi-file cues (data track + CD-DA audio + // tracks in separate .bin files) map each file to a contiguous run of + // disc sectors, concatenated in cue order. + const PSXRecompV4::CueSheet sheet = + PSXRecompV4::parse_cue_sheet(std::filesystem::path(filename)); + + // A cue we cannot read, one that names no FILE, or one whose payload is + // not raw BINARY (WAVE/MP3 have no fixed sector geometry — mis-mapping + // the TOC would be worse than refusing the image) has nothing to mount. + if (!sheet.opened || sheet.files.empty() || sheet.has_non_binary_file()) { return false; } - std::string line; - int cur_track_num = -1; - bool cur_track_audio = false; - uint32_t cur_index00 = 0; - bool cur_has_index00 = false; - while (std::getline(cue_file, line)) { - // FILE "filename.bin" BINARY - size_t file_pos = line.find("FILE"); - if (file_pos != std::string::npos) { - size_t quote1 = line.find('"', file_pos); - size_t quote2 = (quote1 == std::string::npos) - ? std::string::npos : line.find('"', quote1 + 1); - if (quote1 != std::string::npos && quote2 != std::string::npos) { - // Only raw BINARY payloads are supported (WAVE/MP3 audio - // files have no fixed sector geometry). Mis-mapping the - // TOC would be worse than refusing the image. - if (line.find("BINARY", quote2) == std::string::npos) { - return false; - } - std::string bin_name = line.substr(quote1 + 1, quote2 - quote1 - 1); - std::filesystem::path cue_path(filename); - std::filesystem::path bin_path(bin_name); - if (bin_path.is_relative()) { - bin_files.push_back((cue_path.parent_path() / bin_name).string()); - } else { - bin_files.push_back(bin_name); - } - } - continue; - } - - // TRACK NN MODE2/2352 | TRACK NN AUDIO - int tn = 0; - char type[32] = {0}; - if (std::sscanf(line.c_str(), " TRACK %d %31s", &tn, type) == 2) { - cur_track_num = tn; - cur_track_audio = (std::strstr(type, "AUDIO") != nullptr); - cur_index00 = 0; - cur_has_index00 = false; - continue; - } - - // INDEX 01 MM:SS:FF — the track's start (INDEX 00 is its pregap). - int idx = 0, mm = 0, ss = 0, ff = 0; - if (std::sscanf(line.c_str(), " INDEX %d %d:%d:%d", &idx, &mm, &ss, &ff) == 4 - && cur_track_num >= 1 && !bin_files.empty()) { - const uint32_t index_lba = (uint32_t)(((mm * 60 + ss) * 75) + ff); - if (idx == 0) { - cur_index00 = index_lba; - cur_has_index00 = true; - continue; - } - if (idx != 1) continue; - PendingTrack t; - t.number = cur_track_num; - t.is_audio = cur_track_audio; - t.file_index = bin_files.size() - 1; - t.index01 = index_lba; - t.index00 = cur_index00; - t.has_index00 = cur_has_index00; - pending_tracks.push_back(t); - cur_track_num = -1; - } - } - cue_file.close(); - - // A cue with no FILE line has nothing to mount. - if (bin_files.empty()) { - return false; + bin_files.reserve(sheet.files.size()); + for (const PSXRecompV4::CueFileRef& f : sheet.files) { + bin_files.push_back(f.path.string()); } + pending_tracks = sheet.tracks; } else { bin_files.push_back(filename); } @@ -178,9 +108,9 @@ bool ISOReader::Open(const std::string& filename) { CDTrack t; t.number = p.number; t.is_audio = p.is_audio; - t.start_lba = segments_[p.file_index].start_lba + p.index01; - t.pregap_lba = segments_[p.file_index].start_lba + - (p.has_index00 ? p.index00 : p.index01); + t.start_lba = segments_[p.file_index].start_lba + p.index01; + t.pregap_lba = segments_[p.file_index].start_lba + + (p.has_index00 ? p.index00 : p.index01); tracks_.push_back(t); } @@ -188,7 +118,7 @@ bool ISOReader::Open(const std::string& filename) { // parseable TRACK entries, so TrackCount() is always >= 1. if (tracks_.empty()) { CDTrack t; - t.number = 1; t.is_audio = false; t.start_lba = 0; t.pregap_lba = 0; + t.number = 1; t.is_audio = false; t.start_lba = 0; t.pregap_lba = 0; tracks_.push_back(t); } @@ -333,19 +263,19 @@ int ISOReader::TrackCount() const { return static_cast(tracks_.size()); } -uint32_t ISOReader::TrackStartLBA(int track) const { +uint32_t ISOReader::TrackStartLBA(int track) const { for (const auto& t : tracks_) { if (t.number == track) return t.start_lba; } return 0; -} - -uint32_t ISOReader::TrackPregapLBA(int track) const { - for (const auto& t : tracks_) { - if (t.number == track) return t.pregap_lba; - } - return 0; -} +} + +uint32_t ISOReader::TrackPregapLBA(int track) const { + for (const auto& t : tracks_) { + if (t.number == track) return t.pregap_lba; + } + return 0; +} bool ISOReader::TrackIsAudio(int track) const { for (const auto& t : tracks_) { diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index f8f8ee66..4f211aab 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -56,6 +56,8 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "game_options.h" #include "crc32.h" #include "disc_identity.h" +#include "disc_path.h" +#include "bios_rom_alias.h" #include "iso_reader.h" /* text-image guard: extract the boot EXE from the disc */ #include "launcher_device.h" /* recomp-ui controller-source round-trip */ #include "psx_keybinds.h" /* configurable keyboard->DualShock keybinds (keybinds.ini) */ @@ -979,28 +981,18 @@ static bool validate_disc_for_launch(const std::filesystem::path& path, return true; } +// Canonicalize whichever member of a dump the user pointed us at. Picking the +// .cue and picking the .bin must both mount and both verify — see +// runtime/include/disc_path.h for the preference rules. This used to swap a +// .cue for its same-named .bin unconditionally, which silently discarded the +// cue's table of contents (and with it every CD-DA track and pregap) on +// single-file multi-track dumps. +// Pure: no dialog here. Every caller feeds the result to +// validate_disc_for_launch(), whose detail text already carries the resolver's +// note (identify_disc surfaces it), so a broken dump is reported exactly once +// instead of popping a modal on every boot. static std::filesystem::path normalize_disc_path_for_launch(const std::filesystem::path& path) { - namespace fs = std::filesystem; - std::error_code ec; - fs::path p = fs::absolute(path, ec); - if (ec) p = path; - - if (uppercase_ascii(p.extension().string()) == ".CUE") { - fs::path bin = p; - bin.replace_extension(".bin"); - if (fs::exists(bin, ec)) { - fs::path abs = fs::absolute(bin, ec); - return ec ? bin : abs; - } - ec.clear(); - bin.replace_extension(".BIN"); - if (fs::exists(bin, ec)) { - fs::path abs = fs::absolute(bin, ec); - return ec ? bin : abs; - } - } - - return p; + return PSXRecompV4::resolve_disc_path(path).mount; } static bool validate_bios_for_launch(const std::filesystem::path& path) { @@ -1148,11 +1140,26 @@ static std::filesystem::path resolve_bios_path(const char* requested, const char fs::path abs = fs::absolute(p, ec); return ec ? p : abs; } + // Either BIOS filename convention is acceptable: a dump folder holding + // "US-PSX-SCPH1001.BIN" satisfies a request for "SCPH1001.BIN" and vice + // versa (see recompiler/include/bios_rom_alias.h). + if (fs::path aliased = PSXRecompV4::resolve_bios_rom(p); aliased != p) { + fs::path abs = fs::absolute(aliased, ec); + return ec ? aliased : abs; + } if (p.is_absolute()) return p; // Anchor on the exe directory — never cwd (see exe_dir_from_argv). fs::path found = find_upward(exe_dir_from_argv(argv0), p); if (!found.empty()) return found / p; + // Same walk, accepting the other naming convention at each rung: the + // literal name is absent but a region-qualified sibling may be present. + for (fs::path dir = fs::absolute(exe_dir_from_argv(argv0), ec); + !dir.empty(); dir = dir.parent_path()) { + const fs::path aliased = PSXRecompV4::resolve_bios_rom(dir / p); + if (aliased != dir / p && fs::exists(aliased, ec)) return aliased; + if (!dir.has_parent_path() || dir.parent_path() == dir) break; + } // Dev-checkout rung: game projects keep the framework at // /psxrecomp-v4 (junction/worktree), so a relative default like diff --git a/runtime/tests/test_bios_rom_alias.cpp b/runtime/tests/test_bios_rom_alias.cpp new file mode 100644 index 00000000..30420c14 --- /dev/null +++ b/runtime/tests/test_bios_rom_alias.cpp @@ -0,0 +1,120 @@ +// test_bios_rom_alias.cpp — both BIOS filename conventions resolve, both ways. +// +// Regression anchor for the multi-BIOS rename: master's SCPH*.toml name +// "US-PSX-SCPH1001.BIN" while every pre-existing dump folder, bios.cfg and +// tools/regen_bios.sh default name "SCPH1001.BIN". A config and a folder that +// disagree used to fatal with "cannot open BIOS file" despite the right ROM +// being present. + +#include "bios_rom_alias.h" + +#include +#include +#include +#include + +namespace fs = std::filesystem; +using PSXRecompV4::bios_model_token; +using PSXRecompV4::resolve_bios_rom; + +static int g_checks = 0; + +static void check(bool cond, const char* what) { + ++g_checks; + if (!cond) { std::fprintf(stderr, "FAIL: %s\n", what); std::abort(); } +} + +static void touch(const fs::path& p) { + fs::create_directories(p.parent_path()); + std::ofstream(p, std::ios::binary).put('\0'); +} + +static bool same(const fs::path& a, const fs::path& b) { + return a.lexically_normal() == b.lexically_normal(); +} + +int main() { + // ---- model-token extraction ------------------------------------------- + check(bios_model_token("SCPH1001.BIN") == "SCPH1001", "bare stem token"); + check(bios_model_token("US-PSX-SCPH1001.BIN") == "SCPH1001", "qualified stem token"); + check(bios_model_token("US-PSOne-SCPH101.BIN") == "SCPH101", "PSOne token"); + check(bios_model_token("EUR-PSX-SCPH5552.bin") == "SCPH5552", "lowercase-ext token"); + check(bios_model_token("scph1001.bin") == "SCPH1001", "token is case-folded"); + + const fs::path root = fs::temp_directory_path() / "psxrecomp-bios-alias-test"; + fs::remove_all(root); + + // ---- new config name, old folder layout -------------------------------- + { + const fs::path dir = root / "old-folder" / "bios"; + touch(dir / "SCPH1001.BIN"); + const fs::path got = resolve_bios_rom(dir / "US-PSX-SCPH1001.BIN"); + check(same(got, dir / "SCPH1001.BIN"), "qualified request finds bare ROM"); + } + + // ---- old config name, new folder layout -------------------------------- + { + const fs::path dir = root / "new-folder" / "bios"; + touch(dir / "US-PSX-SCPH1001.BIN"); + const fs::path got = resolve_bios_rom(dir / "SCPH1001.BIN"); + check(same(got, dir / "US-PSX-SCPH1001.BIN"), "bare request finds qualified ROM"); + } + + // ---- exact match always wins over an alias ----------------------------- + { + const fs::path dir = root / "both" / "bios"; + touch(dir / "SCPH1001.BIN"); + touch(dir / "US-PSX-SCPH1001.BIN"); + check(same(resolve_bios_rom(dir / "SCPH1001.BIN"), dir / "SCPH1001.BIN"), + "exact bare wins"); + check(same(resolve_bios_rom(dir / "US-PSX-SCPH1001.BIN"), dir / "US-PSX-SCPH1001.BIN"), + "exact qualified wins"); + } + + // ---- lowercase extension + a different region prefix ------------------- + { + const fs::path dir = root / "eur" / "bios"; + touch(dir / "EUR-PSX-SCPH5552.bin"); + check(same(resolve_bios_rom(dir / "SCPH5552.BIN"), dir / "EUR-PSX-SCPH5552.bin"), + "lowercase .bin + EUR prefix resolves"); + } + + // ---- a config that omits the bios/ directory (master's SCPH5552.toml) -- + { + const fs::path gameroot = root / "no-prefix"; + touch(gameroot / "bios" / "EUR-PSX-SCPH5552.bin"); + check(same(resolve_bios_rom(gameroot / "EUR-PSX-SCPH5552.bin"), + gameroot / "bios" / "EUR-PSX-SCPH5552.bin"), + "missing bios/ prefix still resolves"); + } + + // ---- a different model must NOT be substituted ------------------------- + { + const fs::path dir = root / "wrong-model" / "bios"; + touch(dir / "US-PSX-SCPH1001.BIN"); + const fs::path asked = dir / "SCPH101.BIN"; + check(same(resolve_bios_rom(asked), asked), + "SCPH101 is not satisfied by SCPH1001"); + } + + // ---- non-BIOS files in the folder are ignored -------------------------- + { + const fs::path dir = root / "noise" / "bios"; + touch(dir / "SCPH1001.json"); + touch(dir / "openbios.bin"); + const fs::path asked = dir / "SCPH1001.BIN"; + check(same(resolve_bios_rom(asked), asked), "wrong extension is not a match"); + } + + // ---- nothing found: return the request unchanged so the caller's own + // diagnostic still names what was asked for ------------------------- + { + const fs::path asked = root / "empty" / "bios" / "SCPH1001.BIN"; + check(same(resolve_bios_rom(asked), asked), "unresolvable request passes through"); + check(resolve_bios_rom({}).empty(), "empty path passes through"); + } + + fs::remove_all(root); + std::printf("bios_rom_alias: %d checks passed\n", g_checks); + return 0; +} diff --git a/runtime/tests/test_disc_path_resolve.cpp b/runtime/tests/test_disc_path_resolve.cpp new file mode 100644 index 00000000..6fd07130 --- /dev/null +++ b/runtime/tests/test_disc_path_resolve.cpp @@ -0,0 +1,207 @@ +// test_disc_path_resolve.cpp — either member of a disc dump is a valid pick. +// +// The contract under test (runtime/include/disc_path.h): whichever file the +// player hands us — the .cue or the .bin — we mount the same disc and read +// identity/CRC from the same data track. Regression anchor for the old +// behavior, which swapped a .cue for its same-named .bin unconditionally and +// silently dropped the cue's CD-DA tracks. + +#include "cue_sheet.h" +#include "disc_path.h" +#include "iso_reader.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using PSXRecompV4::resolve_disc_path; + +namespace { + +int g_checks = 0; + +void check(bool cond, const char* what) { + ++g_checks; + if (!cond) { + std::fprintf(stderr, "FAIL: %s\n", what); + std::abort(); + } +} + +void write_sectors(const fs::path& path, int count, uint8_t marker) { + std::ofstream out(path, std::ios::binary); + std::vector sector(2352, marker); + for (int i = 0; i < count; ++i) + out.write(reinterpret_cast(sector.data()), (std::streamsize)sector.size()); +} + +void write_text(const fs::path& path, const std::string& body) { + std::ofstream out(path); + out << body; +} + +bool same(const fs::path& a, const fs::path& b) { + return a.lexically_normal() == b.lexically_normal(); +} + +// ---- 1. single-file dump: cue and bin are interchangeable ------------------- +void test_single_file_dump(const fs::path& root) { + const fs::path dir = root / "single"; + fs::create_directories(dir); + write_sectors(dir / "game.bin", 4, 0x11); + write_text(dir / "game.cue", + "FILE \"game.bin\" BINARY\n" + " TRACK 01 MODE2/2352\n" + " INDEX 01 00:00:00\n"); + + const auto from_cue = resolve_disc_path(dir / "game.cue"); + check(from_cue.from_cue, "single: picking the cue mounts the cue"); + check(same(from_cue.mount, dir / "game.cue"), "single: cue mount path"); + check(same(from_cue.data, dir / "game.bin"), "single: cue -> bin data track"); + + const auto from_bin = resolve_disc_path(dir / "game.bin"); + check(from_bin.upgraded_to_cue, "single: picking the bin finds the owning cue"); + check(same(from_bin.mount, dir / "game.cue"), "single: bin upgrades to the cue"); + + // The whole point: both picks agree on what to read identity/CRC from. + check(same(from_cue.data, from_bin.data), "single: both picks share a data track"); +} + +// ---- 2. redump multi-track: picking any track file finds the cue ------------ +void test_multi_track_dump(const fs::path& root) { + const fs::path dir = root / "multi"; + fs::create_directories(dir); + write_sectors(dir / "Game (Track 1).bin", 4, 0x11); + write_sectors(dir / "Game (Track 2).bin", 3, 0x22); + write_text(dir / "Game.cue", + "FILE \"Game (Track 1).bin\" BINARY\n" + " TRACK 01 MODE2/2352\n" + " INDEX 01 00:00:00\n" + "FILE \"Game (Track 2).bin\" BINARY\n" + " TRACK 02 AUDIO\n" + " INDEX 00 00:00:00\n" + " INDEX 01 00:00:01\n"); + + const auto from_cue = resolve_disc_path(dir / "Game.cue"); + check(from_cue.from_cue, "multi: cue mounts as a cue"); + check(same(from_cue.data, dir / "Game (Track 1).bin"), "multi: data track is track 1"); + + // Picking the DATA track's bin: must upgrade to the cue, or the audio track + // disappears. This is the case the old normalize_disc_path_for_launch got + // wrong in the other direction. + const auto from_data_bin = resolve_disc_path(dir / "Game (Track 1).bin"); + check(from_data_bin.upgraded_to_cue, "multi: data bin upgrades to the cue"); + check(same(from_data_bin.mount, dir / "Game.cue"), "multi: data bin -> Game.cue"); + + // Picking an AUDIO track's bin still resolves identity to the data track, + // so the verify verdict does not depend on which file was clicked. + const auto from_audio_bin = resolve_disc_path(dir / "Game (Track 2).bin"); + check(from_audio_bin.upgraded_to_cue, "multi: audio bin upgrades to the cue"); + check(same(from_audio_bin.data, dir / "Game (Track 1).bin"), + "multi: audio bin still identifies via the data track"); + + // And the mounted TOC really does carry both tracks, for every pick. + for (const fs::path& pick : {dir / "Game.cue", + dir / "Game (Track 1).bin", + dir / "Game (Track 2).bin"}) { + PS1::ISOReader reader; + check(reader.Open(resolve_disc_path(pick).mount.string()), "multi: mount opens"); + check(reader.TrackCount() == 2, "multi: both tracks present regardless of pick"); + check(reader.TrackIsAudio(2), "multi: track 2 is audio regardless of pick"); + reader.Close(); + } +} + +// ---- 3. broken cue: fall back to the sibling image instead of failing ------- +void test_cue_with_missing_bin(const fs::path& root) { + const fs::path dir = root / "broken"; + fs::create_directories(dir); + write_sectors(dir / "game.bin", 4, 0x11); + // The cue names a payload that is not there (renamed dump, partial copy). + write_text(dir / "game.cue", + "FILE \"game (Track 01).bin\" BINARY\n" + " TRACK 01 MODE2/2352\n" + " INDEX 01 00:00:00\n"); + + const auto r = resolve_disc_path(dir / "game.cue"); + check(r.cue_fallback, "broken cue: reports the fallback"); + check(same(r.mount, dir / "game.bin"), "broken cue: falls back to the sibling bin"); + check(same(r.data, dir / "game.bin"), "broken cue: identity reads the sibling bin"); + check(!r.note.empty(), "broken cue: explains itself"); +} + +// ---- 4. no cue at all: a bare image mounts as itself ------------------------ +void test_bare_image(const fs::path& root) { + const fs::path dir = root / "bare"; + fs::create_directories(dir); + write_sectors(dir / "game.bin", 4, 0x11); + + const auto r = resolve_disc_path(dir / "game.bin"); + check(!r.from_cue, "bare: no cue to upgrade to"); + check(same(r.mount, dir / "game.bin"), "bare: mounts itself"); + check(same(r.data, dir / "game.bin"), "bare: identity reads itself"); + check(r.note.empty(), "bare: nothing to report"); + + // .iso is equally acceptable. + write_sectors(dir / "game2.iso", 4, 0x33); + const auto iso = resolve_disc_path(dir / "game2.iso"); + check(same(iso.mount, dir / "game2.iso"), "bare: iso mounts itself"); +} + +// ---- 5. surface-syntax tolerance in the shared cue parser ------------------- +void test_cue_syntax_tolerance(const fs::path& root) { + const fs::path dir = root / "syntax"; + fs::create_directories(dir); + write_sectors(dir / "game.bin", 4, 0x11); + + // Lowercase keywords, unquoted filename, CRLF line endings. + write_text(dir / "lower.cue", + "file game.bin binary\r\n" + " track 01 mode2/2352\r\n" + " index 01 00:00:00\r\n"); + const auto lower = PSXRecompV4::parse_cue_sheet(dir / "lower.cue"); + check(lower.usable(), "syntax: lowercase + unquoted + CRLF parses"); + check(same(lower.files.front().path, dir / "game.bin"), "syntax: resolves the payload"); + + // A WAVE payload has no fixed sector geometry — refuse rather than guess. + write_text(dir / "wave.cue", + "FILE \"game.bin\" BINARY\n" + " TRACK 01 MODE2/2352\n" + " INDEX 01 00:00:00\n" + "FILE \"track2.wav\" WAVE\n" + " TRACK 02 AUDIO\n" + " INDEX 01 00:00:00\n"); + const auto wave = PSXRecompV4::parse_cue_sheet(dir / "wave.cue"); + check(wave.has_non_binary_file(), "syntax: WAVE payload is flagged"); + check(!wave.usable(), "syntax: WAVE cue is not mountable"); + + PS1::ISOReader reader; + check(!reader.Open((dir / "wave.cue").string()), "syntax: ISOReader refuses a WAVE cue"); + + // ...and the resolver recovers to the sibling bin rather than dying. + const auto r = resolve_disc_path(dir / "wave.cue"); + check(same(r.mount, dir / "game.bin"), "syntax: WAVE cue falls back to the bin"); +} + +} // namespace + +int main() { + const fs::path root = fs::temp_directory_path() / "psxrecomp-disc-path-test"; + fs::remove_all(root); + fs::create_directories(root); + + test_single_file_dump(root); + test_multi_track_dump(root); + test_cue_with_missing_bin(root); + test_bare_image(root); + test_cue_syntax_tolerance(root); + + fs::remove_all(root); + std::printf("disc_path_resolve: %d checks passed\n", g_checks); + return 0; +}