mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
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>
This commit is contained in:
+1
-1
@@ -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"
|
||||
|
||||
@@ -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 <cctype>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
|
||||
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
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
#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<std::string>(prog, "load_address"), "program.load_address");
|
||||
|
||||
@@ -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 <bios.bin> <out_dir> --emit-full <seeds.json>\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<std::string> cc_override;
|
||||
std::optional<fs::path> 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/<stem>_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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <cstdint>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<CueFileRef> files; // in cue order
|
||||
std::vector<CueTrackRef> 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
|
||||
@@ -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 <filesystem>
|
||||
#include <string>
|
||||
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// cue_sheet.cpp — see cue_sheet.h.
|
||||
|
||||
#include "cue_sheet.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
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
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "disc_identity.h"
|
||||
|
||||
#include "crc32.h"
|
||||
#include "disc_path.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// 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
|
||||
+32
-102
@@ -1,4 +1,5 @@
|
||||
#include "iso_reader.h"
|
||||
#include "cue_sheet.h"
|
||||
#include <filesystem>
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
@@ -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<PendingTrack> 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<int>(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_) {
|
||||
|
||||
+28
-21
@@ -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
|
||||
// <game root>/psxrecomp-v4 (junction/worktree), so a relative default like
|
||||
|
||||
@@ -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 <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<uint8_t> sector(2352, marker);
|
||||
for (int i = 0; i < count; ++i)
|
||||
out.write(reinterpret_cast<const char*>(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;
|
||||
}
|
||||
Reference in New Issue
Block a user