Files
psxrecomp/runtime/tests/test_disc_path_resolve.cpp
T
Matthew Stanley fdfad8f567 cdrom: fail loudly when a requested disc does not mount
Root cause of the "black screen on a .cue that works as a .bin" reports.

cdrom_init() calls iso_open() and, on failure, simply leaves the drive empty
(stat_reg = CDSTAT_SHELL). That is correct for a BIOS-only target, which runs
with no disc on purpose -- but when a game was GIVEN a disc path and the image
does not mount, the game boots against an empty drive and renders nothing. No
error, no log, just black.

The earlier validate_disc_for_launch() pass cannot catch this, because the two
sides read the disc differently: identify_disc() opens the data-track FILE
directly, so it reported a green "Disc verified" badge for cues that the
MOUNTING reader then rejected. Verified-but-unmountable was a silent state.

Demonstrated against the pre-fix reader (a8aa640) on one cue:

    lowercase/unquoted cue -> FAILS  (empty drive -> black screen)
    canonical cue          -> MOUNTS
    bare .bin              -> MOUNTS  <- why picking the .bin "fixed" it

So the symptom was never about cue-vs-bin semantics; it was cue PARSING
strictness. The old mount-side parser required the literal uppercase "BINARY"
and a quoted filename, while the verify side uppercased the line first. The
shared cue parser landed in 68667cd already fixes that class (case-insensitive
keywords, unquoted names, CRLF, case-insensitive payload lookup), which is why
all four titles now boot from either file.

This commit closes the remaining hole -- any OTHER reason a mount fails must
not present as a black screen:

- cdrom_has_disc() exposes the drive state (has_disc() was file-static, so no
  caller could tell a successful mount from an empty drive).
- main() checks it right after cdrom_init() and, when a disc path was supplied
  but nothing mounted, reports what was selected, what it was mounted as, the
  resolver's note, and that the .bin can be selected directly -- then exits
  instead of booting into black.

Also pins the regression: the lowercase/unquoted cue must MOUNT, not merely
parse, so the verify side and the mount side can never silently disagree again.
test_disc_path_resolve: 36 -> 38 checks.

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

224 lines
9.3 KiB
C++

// 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");
// THE BLACK-SCREEN CASE. The old mount-side parser matched the literal
// uppercase "BINARY" and required a quoted filename, so a cue like the one
// above made ISOReader::Open() return false -- while the verify side
// (identify_disc) uppercased the line, found the payload, and happily
// reported "Disc verified". The launcher therefore showed a green badge and
// the runtime then booted with an EMPTY DRIVE: a black screen that went
// away if you picked the .bin instead. Both sides share one parser now, so
// pin that they agree: this cue must MOUNT, not just parse.
{
PS1::ISOReader r;
check(r.Open((dir / "lower.cue").string()),
"syntax: a cue the verify side accepts must also MOUNT (black-screen regression)");
check(r.TrackCount() >= 1, "syntax: mounted cue exposes its data track");
r.Close();
}
// 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;
}