mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
launcher: Phase 3 — disc verification
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<std::string>(game, "disc_crc"), "game.disc_crc");
|
||||
has_disc_crc = true;
|
||||
}
|
||||
if (game.contains("disc_sha1")) {
|
||||
disc_sha1 = toml::find<std::string>(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,
|
||||
|
||||
@@ -179,6 +179,13 @@ struct GameConfig {
|
||||
// the union here).
|
||||
std::vector<std::filesystem::path> 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
|
||||
|
||||
@@ -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 <cstdint>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
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
|
||||
@@ -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; }
|
||||
<span class="path empty" data-if="disc_path == ''">(not set)</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">Verification</span>
|
||||
<span class="control">
|
||||
<span class="badge"
|
||||
data-class-ok="disc_state == 'ok'"
|
||||
data-class-warn="disc_state == 'warn'"
|
||||
data-class-bad="disc_state == 'bad'"
|
||||
data-class-none="disc_state == 'none'">{{disc_status}}</span>
|
||||
<span class="badge-detail">{{disc_detail}}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="actions">
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "launcher.h"
|
||||
|
||||
#include "config_loader.h"
|
||||
#include "disc_identity.h"
|
||||
|
||||
#include <RmlUi/Core.h>
|
||||
#include <RmlUi/Core/Context.h>
|
||||
@@ -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",
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <vector>
|
||||
|
||||
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<char*>(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<uint8_t>& 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<uint8_t> chunk(1u << 20); // 1 MiB
|
||||
while (f.good()) {
|
||||
f.read(reinterpret_cast<char*>(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<uint64_t>(size, 16ull * 1024ull * 1024ull);
|
||||
std::vector<uint8_t> 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
|
||||
+24
-104
@@ -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<uint64_t>(size, 16ull * 1024ull * 1024ull);
|
||||
const size_t scan_len = (size_t)scan_len_u64;
|
||||
std::vector<uint8_t> 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);
|
||||
|
||||
@@ -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 + ")")
|
||||
Reference in New Issue
Block a user