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 + ")")