From 6f4ad8968f5671b6581bb840e697bcdd1fe30f4f Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 12 Jun 2026 12:57:23 -0700 Subject: [PATCH] =?UTF-8?q?launcher:=20Phase=203=20=E2=80=94=20disc=20veri?= =?UTF-8?q?fication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a shared disc-identity module (runtime/{include,src}/disc_identity) that opens a .cue/.bin/.iso, checks for the ISO9660 PVD (raw 2352 and cooked 2048 layouts), reads the PVD volume id, parses the boot serial from SYSTEM.CNF, derives the region from the serial prefix, and optionally streams a full-file CRC32 and compares against expected serial/CRC. This is now the single source of truth for "is this the right disc?" — main.cpp's launch-time validate_disc_image is rewritten to delegate to it (dropping its duplicated raw-read logic and the now dead cue_data_path/is_iso_pvd/psx_exe_id helpers) so the launch check and the launcher badge can never drift. game.toml [game] gains optional disc_crc / disc_sha1 (GameConfig + config_loader). Left unset by default: the serial check is dump- agnostic, whereas a baked CRC would flag every legitimate dump of a different revision/region. Launcher: GameInfo carries the expected serial + CRC; the launcher recomputes verification on entry and on every Browse, showing a colour-coded badge (Serial verified / Disc verified / CRC mismatch / Wrong game? / Not a PlayStation disc) plus a detail line with the detected serial · region · CRC. CRC is only hashed when an expected CRC is configured. Verified all three tiers on Tomba: Serial verified (no CRC), CRC mismatch (wrong CRC), Disc verified (CRC B00ECB0D match). tools/shot_launcher.ps1: bring the launcher GL window to front and screenshot its client area (GL windows screenshot black via PrintWindow). Co-Authored-By: Claude Opus 4.8 (1M context) --- recompiler/src/config_loader.cpp | 17 ++ recompiler/src/config_loader.h | 7 + runtime/include/disc_identity.h | 55 +++++++ runtime/launcher/assets/launcher.rml | 35 ++++ runtime/launcher/launcher.cpp | 92 ++++++++++- runtime/launcher/launcher.h | 13 +- runtime/runtime.cmake | 1 + runtime/src/disc_identity.cpp | 238 +++++++++++++++++++++++++++ runtime/src/main.cpp | 128 +++----------- tools/shot_launcher.ps1 | 33 ++++ 10 files changed, 511 insertions(+), 108 deletions(-) create mode 100644 runtime/include/disc_identity.h create mode 100644 runtime/src/disc_identity.cpp create mode 100644 tools/shot_launcher.ps1 diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 04111eca..9e30c8bb 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -382,6 +382,20 @@ GameConfig load_game_config(const fs::path& config_path_in) { discs.push_back(fs::absolute(root / d)); } + // Optional expected disc identity (launcher verification badge). + bool has_disc_crc = false; + uint32_t disc_crc = 0; + std::string disc_sha1; + if (game.contains("disc_crc")) { + disc_crc = parse_hex(toml::find(game, "disc_crc"), "game.disc_crc"); + has_disc_crc = true; + } + if (game.contains("disc_sha1")) { + disc_sha1 = toml::find(game, "disc_sha1"); + // normalize to lowercase hex + for (char& c : disc_sha1) c = (char)std::tolower((unsigned char)c); + } + // [recompiler] if (!cfg.contains("recompiler")) { throw std::runtime_error( @@ -430,6 +444,9 @@ GameConfig load_game_config(const fs::path& config_path_in) { /*text_size*/ text_size, /*stack_base*/ stack_base, /*discs*/ discs, + /*has_disc_crc*/ has_disc_crc, + /*disc_crc*/ disc_crc, + /*disc_sha1*/ disc_sha1, /*seeds_path*/ seeds_path, /*bios_thunks_path*/ bios_thunks_path, /*out_dir*/ out_dir, diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 8556a340..2d7e4747 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -179,6 +179,13 @@ struct GameConfig { // the union here). std::vector discs; + // Optional expected disc identity, for the launcher's "Disc verified" badge. + // disc_crc: full-file CRC32 (IEEE) of the data track. disc_sha1: lowercase + // hex SHA-1. Either may be absent (has_disc_crc / disc_sha1.empty()). + bool has_disc_crc = false; + uint32_t disc_crc = 0; + std::string disc_sha1; + // [recompiler] block std::filesystem::path seeds_path; // absolute path to seeds (text or json) std::filesystem::path bios_thunks_path; // optional; empty if not set diff --git a/runtime/include/disc_identity.h b/runtime/include/disc_identity.h new file mode 100644 index 00000000..83ff3385 --- /dev/null +++ b/runtime/include/disc_identity.h @@ -0,0 +1,55 @@ +// disc_identity.h — disc-image identification + verification. +// +// Single source of truth for "is this the right disc?". Reads a .cue (or raw +// .bin/.iso), checks for the ISO9660 PVD, extracts the volume id and the +// PlayStation boot serial (from SYSTEM.CNF), derives the region from the +// serial prefix, and optionally compares against an expected serial / CRC32. +// +// Used both by the runtime's launch-time disc check (runtime/src/main.cpp) +// and by the integrated launcher's "Disc verified" badge (launcher.cpp), so +// the two never drift apart. + +#pragma once + +#include +#include +#include + +namespace PSXRecompV4 { + +struct DiscIdentity { + bool opened = false; // data file (cue-referenced bin or raw image) opened + bool has_header = false; // ISO9660 "CD001" PVD found at a PS1 disc offset + std::string volume_id; // PVD volume identifier (trimmed), "" if none + std::string detected_serial; // serial parsed from SYSTEM.CNF BOOT line, normalized + // to the "SCUS-94236" form; "" if not found + std::string region; // "NTSC-U" | "NTSC-J" | "PAL" derived from the serial + // prefix (detected first, else expected); "" if unknown + + // Serial comparison vs the expected game id (only meaningful when one was given). + bool expected_serial_given = false; + bool serial_matches = false; // expected serial present in early disc metadata + + // Full-file CRC32 (IEEE 802.3 over the data track / image bytes). + bool crc_computed = false; + uint32_t crc = 0; + bool expected_crc_given = false; + bool crc_matches = false; + + std::string detail; // human-readable note (failure reason or extra info) +}; + +// Identify and (optionally) verify a disc image. +// path : a .cue file or a raw .bin/.iso image +// expected_serial : the game id, e.g. "SCUS-94236" ("" skips the serial check) +// expected_crc : full-file CRC32 to match against +// has_expected_crc : whether expected_crc is meaningful +// compute_crc : compute the full-file CRC32 (streamed; can be slow on big +// images — callers typically only request it when there is an +// expected CRC to compare against) +DiscIdentity identify_disc(const std::filesystem::path& path, + const std::string& expected_serial, + uint32_t expected_crc, bool has_expected_crc, + bool compute_crc); + +} // namespace PSXRecompV4 diff --git a/runtime/launcher/assets/launcher.rml b/runtime/launcher/assets/launcher.rml index 61eaa92f..4a1000a4 100644 --- a/runtime/launcher/assets/launcher.rml +++ b/runtime/launcher/assets/launcher.rml @@ -84,6 +84,30 @@ button:hover { background-color: #30363d; } overflow: hidden; } .path.empty { color: #f85149; } +/* Disc verification badge — colour driven by data-class on disc_state. */ +.badge { + display: inline-block; + border-radius: 6dp; + padding: 4dp 12dp; + font-size: 13dp; + font-weight: bold; + background-color: #30363d; + color: #adbac7; +} +.badge.ok { background-color: #1f6f3f; color: #d6f7df; } +.badge.warn { background-color: #7a5a13; color: #ffe9a8; } +.badge.bad { background-color: #7a2230; color: #ffd6d6; } +.badge.none { background-color: #30363d; color: #8b949e; } +.badge-detail { + flex: 1 1 auto; + min-width: 0dp; + margin-left: 12dp; + color: #7d8590; + font-size: 14dp; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} #actions { display: flex; flex-direction: row; @@ -174,6 +198,17 @@ button:hover { background-color: #30363d; } (not set) +
+ Verification + + {{disc_status}} + {{disc_detail}} + +
diff --git a/runtime/launcher/launcher.cpp b/runtime/launcher/launcher.cpp index 0f09bb21..7777e0db 100644 --- a/runtime/launcher/launcher.cpp +++ b/runtime/launcher/launcher.cpp @@ -8,6 +8,7 @@ #include "launcher.h" #include "config_loader.h" +#include "disc_identity.h" #include #include @@ -49,6 +50,11 @@ struct LauncherModel { Rml::String crt_label; Rml::String texfilter_label; + // Disc verification (recomputed whenever disc_path changes). + Rml::String disc_status; // headline, e.g. "Disc verified" + Rml::String disc_detail; // serial / region / CRC line + Rml::String disc_state; // "ok" | "warn" | "bad" | "none" — drives the badge colour + bool launch_requested = false; bool quit_requested = false; }; @@ -70,6 +76,73 @@ void refresh_labels(LauncherModel& m) { m.texfilter_label = texfilter_name(m.texture_filter); } +// Re-run disc verification against m.disc_path and update the badge fields. +// `expected_serial`/`expected_crc` come from game.toml (via GameInfo). +void refresh_disc_status(LauncherModel& m, const std::string& expected_serial, + uint32_t expected_crc, bool has_expected_crc) { + if (m.disc_path.empty()) { + m.disc_status = "No disc selected"; + m.disc_detail = "Pick a disc image to verify it against this build."; + m.disc_state = "none"; + return; + } + + // Only spend time hashing when there is an expected CRC to compare against. + const PSXRecompV4::DiscIdentity id = PSXRecompV4::identify_disc( + fs::path(std::string(m.disc_path)), expected_serial, + expected_crc, has_expected_crc, /*compute_crc=*/has_expected_crc); + + if (!id.opened) { + m.disc_status = "Disc not found"; + m.disc_detail = "Could not open the image or its CUE-referenced BIN."; + m.disc_state = "bad"; + return; + } + if (!id.has_header) { + m.disc_status = "Not a PlayStation disc"; + m.disc_detail = "No ISO9660 header at the expected sectors."; + m.disc_state = "bad"; + return; + } + + // Build the detail line: detected serial + region (+ CRC when computed). + std::string detail; + const std::string shown_serial = + !id.detected_serial.empty() ? id.detected_serial : std::string("unknown serial"); + detail = shown_serial; + if (!id.region.empty()) detail += " · " + id.region; + if (id.crc_computed) { + char crcbuf[16]; + std::snprintf(crcbuf, sizeof(crcbuf), "%08X", id.crc); + detail += " · CRC "; + detail += crcbuf; + } + m.disc_detail = detail; + + const bool serial_ok = !id.expected_serial_given || id.serial_matches; + const bool crc_ok = !id.expected_crc_given || id.crc_matches; + + if (id.expected_crc_given && id.crc_computed && id.crc_matches) { + m.disc_status = "Disc verified"; // strongest: exact CRC match + m.disc_state = "ok"; + } else if (id.expected_crc_given && id.crc_computed && !id.crc_matches) { + m.disc_status = "CRC mismatch"; + m.disc_state = "bad"; + } else if (serial_ok && id.expected_serial_given) { + m.disc_status = "Serial verified"; + m.disc_state = "ok"; + } else if (!serial_ok) { + m.disc_status = "Wrong game?"; + m.disc_detail = "Disc serial does not match this build (" + expected_serial + "). " + + m.disc_detail; + m.disc_state = "warn"; + } else { + m.disc_status = "PlayStation disc"; // header ok, nothing to compare against + m.disc_state = "ok"; + } + (void)crc_ok; +} + #if defined(_WIN32) // Native open-file dialog. Returns "" if cancelled. std::string win_pick_file(SDL_Window* parent, const char* title, const char* filter) { @@ -118,10 +191,14 @@ namespace psx_launcher { Result run(SDL_Window* window, void* gl_context, PSXRecompV4::UserSettings& io, - const char* game_name, const char* assets_dir) + const GameInfo& game, const char* assets_dir) { (void)gl_context; // already created + current; we only need the window. + const std::string expected_serial = game.expected_serial ? game.expected_serial : ""; + const uint32_t expected_crc = game.expected_crc; + const bool has_expected_crc = game.has_expected_crc; + if (!RmlGL3::Initialize()) { std::fprintf(stderr, "launcher: RmlGL3::Initialize failed\n"); return Result::Unavailable; @@ -172,6 +249,7 @@ Result run(SDL_Window* window, void* gl_context, m.bios_path = io.has_bios_path ? io.bios_path.generic_string() : Rml::String(); m.disc_path = io.has_disc_path ? io.disc_path.generic_string() : Rml::String(); refresh_labels(m); + refresh_disc_status(m, expected_serial, expected_crc, has_expected_crc); // ---- Data model: bind fields + action callbacks ---- Rml::DataModelConstructor c = context->CreateDataModel("settings"); @@ -180,7 +258,7 @@ Result run(SDL_Window* window, void* gl_context, RmlGL3::Shutdown(); return Result::Unavailable; } - Rml::String title = game_name ? Rml::String(game_name) : Rml::String("PSX"); + Rml::String title = game.name ? Rml::String(game.name) : Rml::String("PSX"); c.BindFunc("game_name", [title](Rml::Variant& out) { out = title; }); c.Bind("supersampling", &m.supersampling); c.Bind("antialiasing", &m.antialiasing); @@ -190,6 +268,9 @@ Result run(SDL_Window* window, void* gl_context, c.Bind("texfilter_label",&m.texfilter_label); c.Bind("bios_path", &m.bios_path); c.Bind("disc_path", &m.disc_path); + c.Bind("disc_status", &m.disc_status); + c.Bind("disc_detail", &m.disc_detail); + c.Bind("disc_state", &m.disc_state); Rml::DataModelHandle handle = c.GetModelHandle(); @@ -233,12 +314,17 @@ Result run(SDL_Window* window, void* gl_context, } }); c.BindEventCallback("browse_disc", - [&m, window, handle](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable { + [&m, window, handle, expected_serial, expected_crc, has_expected_crc] + (Rml::DataModelHandle, Rml::Event&, const Rml::VariantList&) mutable { std::string p = win_pick_file(window, "Select disc image", "Disc image (*.cue;*.bin;*.iso)\0*.cue;*.bin;*.iso\0All files (*.*)\0*.*\0\0"); if (!p.empty()) { m.disc_path = fs::path(p).generic_string(); + refresh_disc_status(m, expected_serial, expected_crc, has_expected_crc); handle.DirtyVariable("disc_path"); + handle.DirtyVariable("disc_status"); + handle.DirtyVariable("disc_detail"); + handle.DirtyVariable("disc_state"); } }); c.BindEventCallback("launch", diff --git a/runtime/launcher/launcher.h b/runtime/launcher/launcher.h index fcde2b39..efa0f3b0 100644 --- a/runtime/launcher/launcher.h +++ b/runtime/launcher/launcher.h @@ -13,6 +13,8 @@ #pragma once +#include + struct SDL_Window; namespace PSXRecompV4 { struct UserSettings; } @@ -25,6 +27,15 @@ enum class Result { Unavailable, // launcher could not initialise (assets/GL); caller boots as if skipped }; +// Static facts about the game the launcher is configuring. Drives the title and +// the disc-verification badge. Extends naturally for later phases. +struct GameInfo { + const char* name = nullptr; // display name, e.g. "Tomba!" + const char* expected_serial = nullptr; // game id "SCUS-94236" (null = no serial check) + uint32_t expected_crc = 0; // full-file CRC32 of the data track + bool has_expected_crc = false; // whether expected_crc is meaningful +}; + // Run the launcher loop to completion. `gl_context` is an SDL_GLContext (void* // to avoid leaking SDL types into this header) already created and current on // `window`. `io` is seeded with the effective settings (game.toml ∪ settings.toml) @@ -32,6 +43,6 @@ enum class Result { // is the directory holding launcher.rml / .rcss / fonts. Result run(SDL_Window* window, void* gl_context, PSXRecompV4::UserSettings& io, - const char* game_name, const char* assets_dir); + const GameInfo& game, const char* assets_dir); } // namespace psx_launcher diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index ee03f1da..5a24ea13 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -137,6 +137,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/freeze_heartbeat.c ${PSXRECOMP_ROOT}/runtime/src/gte.cpp ${PSXRECOMP_ROOT}/runtime/src/crc32.c + ${PSXRECOMP_ROOT}/runtime/src/disc_identity.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/disc_identity.cpp b/runtime/src/disc_identity.cpp new file mode 100644 index 00000000..7d546738 --- /dev/null +++ b/runtime/src/disc_identity.cpp @@ -0,0 +1,238 @@ +// disc_identity.cpp — see disc_identity.h. +// +// The header detection mirrors the proven raw-sector logic previously inlined +// in main.cpp (handles both 2352-byte raw and 2048-byte cooked sector layouts, +// which is what real .bin/.cue and .iso dumps use respectively). + +#include "disc_identity.h" + +#include "crc32.h" + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace PSXRecompV4 { + +namespace { + +std::string uppercase_ascii(std::string s) { + for (char& c : s) c = (char)std::toupper((unsigned char)c); + return s; +} + +bool 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); + if (!f.good()) return false; + f.read(reinterpret_cast(out), (std::streamsize)len); + return f.gcount() == (std::streamsize)len; +} + +bool is_iso_pvd(const uint8_t* sector) { + return sector[0] == 0x01 && std::memcmp(§or[1], "CD001", 5) == 0 && sector[6] == 0x01; +} + +// "SCUS-94236" -> region. PSX serial prefixes encode the territory. +std::string region_from_serial(const std::string& serial) { + if (serial.size() < 4) return std::string(); + const std::string p = uppercase_ascii(serial.substr(0, 4)); + if (p == "SCUS" || p == "SLUS" || p == "LSP9") return "NTSC-U"; + if (p == "SCES" || p == "SLES" || p == "SCED" || p == "SLED") return "PAL"; + if (p == "SCPS" || p == "SLPS" || p == "SLPM" || p == "SCPM" || p == "ESPM") return "NTSC-J"; + return std::string(); +} + +// Normalize a scanned EXE-style id "SCUS_942.36" to the game-id form +// "SCUS-94236". Pass-through for things already in game-id form. Returns "" +// if the token doesn't look like a serial. +std::string normalize_serial(const std::string& raw) { + std::string s; + for (char c : raw) { + if (std::isalnum((unsigned char)c)) s += (char)std::toupper((unsigned char)c); + // drop separators ('_', '.', '-') + } + // Expect 4 letters + 5 digits. + if (s.size() < 9) return std::string(); + for (int i = 0; i < 4; i++) if (!std::isalpha((unsigned char)s[i])) return std::string(); + for (int i = 4; i < 9; i++) if (!std::isdigit((unsigned char)s[i])) return std::string(); + return s.substr(0, 4) + "-" + s.substr(4, 5); +} + +// Scan the SYSTEM.CNF text region for the BOOT serial. PSX SYSTEM.CNF holds a +// line like `BOOT = cdrom:\SCUS_942.36;1`. We look for "cdrom:" and read the +// following filename token, then normalize it. +std::string scan_boot_serial(const std::vector& buf) { + static const char kKey[] = "cdrom:"; + const size_t klen = sizeof(kKey) - 1; + if (buf.size() < klen) return std::string(); + for (size_t i = 0; i + klen < buf.size(); i++) { + bool hit = true; + for (size_t k = 0; k < klen; k++) { + char a = (char)std::tolower((unsigned char)buf[i + k]); + if (a != kKey[k]) { hit = false; break; } + } + if (!hit) continue; + // Read the token after "cdrom:", skipping any leading path separators. + size_t j = i + klen; + while (j < buf.size() && (buf[j] == '\\' || buf[j] == '/')) j++; + std::string token; + while (j < buf.size()) { + char c = (char)buf[j]; + if (c == ';' || c == '\r' || c == '\n' || c == ' ' || c == '\t' || c == 0) break; + token += c; + j++; + if (token.size() > 32) break; + } + const std::string norm = normalize_serial(token); + if (!norm.empty()) return norm; + } + return std::string(); +} + +uint32_t crc32_stream(std::ifstream& f) { + f.clear(); + f.seekg(0, std::ios::beg); + uint32_t crc = 0xFFFFFFFFu; + std::vector chunk(1u << 20); // 1 MiB + while (f.good()) { + f.read(reinterpret_cast(chunk.data()), (std::streamsize)chunk.size()); + const std::streamsize got = f.gcount(); + if (got <= 0) break; + crc = crc32_update(crc, chunk.data(), (size_t)got); + } + return crc ^ 0xFFFFFFFFu; +} + +} // namespace + +DiscIdentity identify_disc(const fs::path& path, + const std::string& expected_serial, + uint32_t expected_crc, bool has_expected_crc, + bool compute_crc) { + DiscIdentity v; + v.expected_serial_given = !expected_serial.empty(); + v.expected_crc_given = has_expected_crc; + + const fs::path data_path = cue_data_path(path); + 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."; + return v; + } + v.opened = true; + const uint64_t size = (uint64_t)f.tellg(); + + // PVD: try raw (2352) then cooked (2048) sector 16. + uint8_t sector[2048]; + if ((size >= (16ull * 2352ull + 24ull + sizeof(sector))) && + read_at(f, 16ull * 2352ull + 24ull, sector, sizeof(sector)) && is_iso_pvd(sector)) { + v.has_header = true; + } else if ((size >= (16ull * 2048ull + sizeof(sector))) && + read_at(f, 16ull * 2048ull, sector, sizeof(sector)) && is_iso_pvd(sector)) { + v.has_header = true; + } + + if (!v.has_header) { + v.detail = "No ISO9660 CD001 header was found at the standard PS1 disc locations."; + // Region may still be inferable from the expected serial for display. + v.region = region_from_serial(expected_serial); + return v; + } + + // PVD volume identifier: offset 40, 32 bytes (space-padded). + { + std::string vol((const char*)§or[40], 32); + const size_t last = vol.find_last_not_of(" \t\0"); + if (last != std::string::npos) vol.erase(last + 1); else vol.clear(); + v.volume_id = vol; + } + + // Scan the early image for the boot serial + (if given) the expected serial. + const size_t scan_len = (size_t)std::min(size, 16ull * 1024ull * 1024ull); + std::vector scan(scan_len); + if (read_at(f, 0, scan.data(), scan.size())) { + v.detected_serial = scan_boot_serial(scan); + + if (v.expected_serial_given) { + // Match either the game-id form (SCUS-94236) or the EXE form (SCUS_942.36). + const std::string serial_id = uppercase_ascii(expected_serial); + std::string exe_id = serial_id; // SCUS-94236 -> SCUS_942.36 + if (serial_id.size() == 10 && serial_id[4] == '-') + exe_id = serial_id.substr(0, 4) + "_" + serial_id.substr(5, 3) + "." + + serial_id.substr(8, 2); + auto contains = [&](const std::string& needle) { + if (needle.empty() || needle.size() > scan.size()) return false; + return std::search(scan.begin(), scan.end(), needle.begin(), needle.end()) != + scan.end(); + }; + v.serial_matches = contains(serial_id) || contains(exe_id); + if (!v.serial_matches && !v.detected_serial.empty() && + v.detected_serial == serial_id) + v.serial_matches = true; // boot line agrees even if the literal bytes differ + } + } else { + v.detail = "The ISO header is present, but the image could not be scanned."; + } + + // Region: prefer the disc's own serial, fall back to the expected one. + v.region = region_from_serial(!v.detected_serial.empty() ? v.detected_serial + : expected_serial); + + // CRC32 (full image), streamed. + if (compute_crc) { + v.crc = crc32_stream(f); + v.crc_computed = true; + if (has_expected_crc) v.crc_matches = (v.crc == expected_crc); + } + + return v; +} + +} // namespace PSXRecompV4 diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index ac781cf5..ab33e975 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -27,6 +27,7 @@ #include "freeze_heartbeat.h" #include "config_loader.h" #include "crc32.h" +#include "disc_identity.h" #if defined(PSX_LAUNCHER) #include "launcher.h" #endif @@ -257,47 +258,6 @@ static std::string uppercase_ascii(std::string s) { return s; } -static 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; -} - -static std::filesystem::path cue_data_path(const std::filesystem::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); - const size_t file_pos = upper.find("FILE"); - const size_t binary_pos = upper.find("BINARY"); - if (file_pos == std::string::npos || binary_pos == std::string::npos) continue; - - 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; - - std::filesystem::path bin_path(bin_name); - if (bin_path.is_relative()) return path.parent_path() / bin_path; - return bin_path; - } - return path; -} - static 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); @@ -306,17 +266,6 @@ static bool read_at(std::ifstream& f, uint64_t offset, uint8_t* out, size_t len) return f.gcount() == (std::streamsize)len; } -static bool is_iso_pvd(const uint8_t* sector) { - return sector[0] == 0x01 && std::memcmp(§or[1], "CD001", 5) == 0 && sector[6] == 0x01; -} - -static std::string psx_exe_id_from_game_id(const std::string& game_id) { - if (game_id.size() == 10 && game_id[4] == '-') { - return game_id.substr(0, 4) + "_" + game_id.substr(5, 3) + "." + game_id.substr(8, 2); - } - return game_id; -} - struct DiscValidation { bool opened = false; bool has_header = false; @@ -326,57 +275,21 @@ struct DiscValidation { static DiscValidation validate_disc_image(const std::filesystem::path& selected_path, const std::string& game_id) { + // Delegate to the shared disc-identity module so the launch-time check and + // the launcher badge can never drift apart. No CRC here — the launch check + // only cares about openability / header / serial; the launcher does the + // (slower) CRC pass when an expected CRC is configured. + const PSXRecompV4::DiscIdentity id = + PSXRecompV4::identify_disc(selected_path, game_id, /*expected_crc*/0, + /*has_expected_crc*/false, /*compute_crc*/false); DiscValidation v; - const std::filesystem::path data_path = cue_data_path(selected_path); - std::ifstream f(data_path, std::ios::binary | std::ios::ate); - if (!f.is_open()) { - v.detail = "Could not open the selected disc image or its CUE-referenced BIN file."; - return v; - } - v.opened = true; - - const uint64_t size = (uint64_t)f.tellg(); - uint8_t sector[2048]; - if ((size >= (16ull * 2352ull + 24ull + sizeof(sector))) && - read_at(f, 16ull * 2352ull + 24ull, sector, sizeof(sector)) && - is_iso_pvd(sector)) { - v.has_header = true; - } else if ((size >= (16ull * 2048ull + sizeof(sector))) && - read_at(f, 16ull * 2048ull, sector, sizeof(sector)) && - is_iso_pvd(sector)) { - v.has_header = true; - } - - if (!v.has_header) { - v.detail = "No ISO9660 CD001 header was found at the standard PS1 disc locations."; - return v; - } - - if (game_id.empty()) { - v.id_matches = true; - return v; - } - - const std::string exe_id = uppercase_ascii(psx_exe_id_from_game_id(game_id)); - const std::string serial_id = uppercase_ascii(game_id); - const uint64_t scan_len_u64 = std::min(size, 16ull * 1024ull * 1024ull); - const size_t scan_len = (size_t)scan_len_u64; - std::vector scan(scan_len); - if (!read_at(f, 0, scan.data(), scan.size())) { - v.detail = "The ISO header is present, but the image could not be scanned for the game ID."; - return v; - } - - auto contains_ascii = [&](const std::string& needle) { - if (needle.empty() || needle.size() > scan.size()) return false; - return std::search(scan.begin(), scan.end(), needle.begin(), needle.end()) != scan.end(); - }; - - if (contains_ascii(exe_id) || contains_ascii(serial_id)) { - v.id_matches = true; - } else { + v.opened = id.opened; + v.has_header = id.has_header; + v.id_matches = game_id.empty() ? true : id.serial_matches; + v.detail = id.detail; + if (id.opened && id.has_header && !v.id_matches && v.detail.empty()) { v.detail = "The disc header is readable, but it does not contain the expected game ID " + - exe_id + " / " + serial_id + " in the early disc metadata."; + uppercase_ascii(game_id) + " in the early disc metadata."; } return v; } @@ -1327,6 +1240,8 @@ int main(int argc, char** argv) { uint16_t debug_port = (uint16_t)DEFAULT_DEBUG_PORT; std::string game_name; std::string game_id; + bool game_has_disc_crc = false; + uint32_t game_disc_crc = 0; std::string disc_speed; /* "1x" | "2x" | "4x" | "instant" */ int instant_rate = 0; /* 0 = cdrom.c built-in default */ uint32_t game_entry_pc = 0; @@ -1337,6 +1252,8 @@ int main(int argc, char** argv) { const auto gc = PSXRecompV4::load_game_config(game_config_path); game_name = gc.name; game_id = gc.id; + game_has_disc_crc = gc.has_disc_crc; + game_disc_crc = gc.disc_crc; if (!gc.discs.empty()) resolved_disc = gc.discs.front(); if (gc.runtime.has_memcard_dir) memcard_dir = gc.runtime.memcard_dir; if (gc.runtime.has_window_title) window_title = gc.runtime.window_title; @@ -1453,9 +1370,12 @@ int main(int argc, char** argv) { SDL_GL_MakeCurrent(lwin, lctx); SDL_GL_SetSwapInterval(1); std::string assets = exe_dir_from_argv(argv[0]).string(); - lr = psx_launcher::run(lwin, lctx, seed, - game_name.empty() ? nullptr : game_name.c_str(), - assets.c_str()); + psx_launcher::GameInfo ginfo; + ginfo.name = game_name.empty() ? nullptr : game_name.c_str(); + ginfo.expected_serial = game_id.empty() ? nullptr : game_id.c_str(); + ginfo.expected_crc = game_disc_crc; + ginfo.has_expected_crc = game_has_disc_crc; + lr = psx_launcher::run(lwin, lctx, seed, ginfo, assets.c_str()); SDL_GL_DeleteContext(lctx); } SDL_DestroyWindow(lwin); diff --git a/tools/shot_launcher.ps1 b/tools/shot_launcher.ps1 new file mode 100644 index 00000000..3622a8f4 --- /dev/null +++ b/tools/shot_launcher.ps1 @@ -0,0 +1,33 @@ +param([string]$Out = "F:\Projects\psxrecomp\TombaRecomp\build-clean\launcher_shot.png") +$p = Get-Process psx-runtime -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1 +if (-not $p) { Write-Output "no window"; exit 1 } +Write-Output ("hwnd=" + $p.MainWindowHandle + " title=" + $p.MainWindowTitle) +$sig = @' +using System; +using System.Runtime.InteropServices; +public class W { + [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); + [DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr h, out RECT r); + [DllImport("user32.dll")] public static extern bool ClientToScreen(IntPtr h, ref POINT p); + public struct RECT { public int left, top, right, bottom; } + public struct POINT { public int x, y; } +} +'@ +Add-Type -TypeDefinition $sig 2>$null +$h = $p.MainWindowHandle +[W]::SetWindowPos($h, [IntPtr](-1), 0,0,0,0, 0x40 -bor 0x1 -bor 0x2) | Out-Null +[W]::SetForegroundWindow($h) | Out-Null +Start-Sleep -Milliseconds 400 +$r = New-Object W+RECT +[W]::GetClientRect($h, [ref]$r) | Out-Null +$tl = New-Object W+POINT +[W]::ClientToScreen($h, [ref]$tl) | Out-Null +$w = $r.right - $r.left; $ht = $r.bottom - $r.top +Add-Type -AssemblyName System.Drawing +$bmp = New-Object System.Drawing.Bitmap($w, $ht) +$g = [System.Drawing.Graphics]::FromImage($bmp) +$g.CopyFromScreen($tl.x, $tl.y, 0, 0, (New-Object System.Drawing.Size($w, $ht))) +$bmp.Save($Out, [System.Drawing.Imaging.ImageFormat]::Png) +$g.Dispose(); $bmp.Dispose() +Write-Output ("saved " + $Out + " (" + $w + "x" + $ht + ")")