diff --git a/runtime/include/regional_launch.h b/runtime/include/regional_launch.h new file mode 100644 index 00000000..b3efa969 --- /dev/null +++ b/runtime/include/regional_launch.h @@ -0,0 +1,167 @@ +#pragma once +/* Optional shipped-backend catalog. No catalog means no behavior change. + * A profile switch is a process handoff, never a live code/config transplant. */ +#include "disc_identity.h" +#include "disc_path.h" +#include "host_sha256.h" +#include +#include +#include +#include +#include +#include +#if defined(_WIN32) +#include +#endif + +namespace PSXRecompV4 { +namespace regional { +namespace fs = std::filesystem; +struct Profile { std::string id, sha256; uint64_t size; fs::path backend; }; +inline fs::path root, self; +inline std::vector profiles; +inline std::string selected_disc, pending_disc; +inline const Profile* pending = nullptr; +inline std::vector launch_flags; +inline bool same_backend(const fs::path& a,const fs::path& b) { + std::error_code error; + return a==b || fs::equivalent(a,b,error); +} +inline fs::path inside(const fs::path& relative) { + if(relative.empty() || relative.is_absolute()) throw std::runtime_error("Profile backend must be relative"); + auto p=fs::weakly_canonical(root/relative); + auto rel=p.lexically_relative(root); + if(rel.empty() || *rel.begin()=="..") throw std::runtime_error("Profile backend escapes package"); + return p; +} +inline void load(const char* argv0) { + self=fs::weakly_canonical(fs::absolute(argv0)); +#if defined(_WIN32) + wchar_t module[32768];const DWORD length=GetModuleFileNameW(nullptr,module,32768); + if(length>0 && length<32768)self=fs::weakly_canonical(fs::path(module)); +#endif + auto folder=self.parent_path(); + for(int i=0;i<4 && !folder.empty();++i,folder=folder.parent_path()) { + auto file=folder/"regional-launch.toml"; + if(!fs::is_regular_file(file)) continue; + root=fs::weakly_canonical(folder); + const auto config=toml::parse(file.string()); + for(const auto& entry:toml::find(config,"profile").as_array()) { + Profile p{toml::find(entry,"id"),toml::find(entry,"sha256"), + toml::find(entry,"size"),inside(toml::find(entry,"backend"))}; + if(p.id.empty() || p.sha256.size()!=64 || !p.size) throw std::runtime_error("Invalid regional identity"); + for(const auto& old:profiles) if(old.id==p.id || same_backend(old.backend,p.backend)) throw std::runtime_error("Duplicate regional profile"); + profiles.push_back(p); + } + bool owns=false;for(const auto& p:profiles) owns|=same_backend(p.backend,self); + if(!owns) throw std::runtime_error("Executable not declared in regional catalog"); + return; + } +} +inline const Profile* identify(const fs::path& picked) { + auto disc=resolve_disc_path(picked); + auto id=identify_disc(picked,"",0,false,false); + if(!id.opened || !id.has_header || disc.cue_fallback) return nullptr; + for(const auto& p:profiles) { + if(id.detected_serial!=p.id || fs::file_size(disc.data)!=p.size) continue; + uint8_t digest[32];if(!host_sha256_file(disc.data,digest)) return nullptr; + char hex[65];for(int i=0;i<32;++i) std::snprintf(hex+i*2,3,"%02x",digest[i]); + if(p.sha256==hex && fs::is_regular_file(p.backend)) return &p; + } + return nullptr; +} +inline void remember(const Profile& profile,const std::string& disc) { + toml::value state{{"profile",profile.id},{"disc",disc}}; + auto temp=root/("europe-selection-"+std::to_string( +#if defined(_WIN32) + GetCurrentProcessId() +#else + 0 +#endif + )+".tmp"); + {std::ofstream out(temp,std::ios::binary);out< args{target.backend.wstring(),L"--disc",fs::path(disc).wstring()}; + for(const auto& flag:launch_flags)args.push_back(fs::path(flag).wstring()); + /* _spawn passes argv through the Windows command-line decoder. Quote each + * argument with the inverse CRT rules, including trailing backslashes. */ + for(auto& arg:args) { + std::wstring quoted=L"\"";unsigned slashes=0; + for(wchar_t ch:arg) { + if(ch==L'\\'){++slashes;continue;} + quoted.append(ch==L'"'?slashes*2+1:slashes,L'\\');slashes=0;quoted+=ch; + } + quoted.append(slashes*2,L'\\');quoted+=L'"';arg=quoted; + } + std::vector pointers;for(const auto& arg:args)pointers.push_back(arg.c_str());pointers.push_back(nullptr); + /* CRT spawn constructs a direct process invocation; no command shell. */ + if(_wspawnv(_P_NOWAIT,target.backend.c_str(),pointers.data())==-1) return 1; + remember(target,disc); + return 0; +#else + (void)target;(void)disc;return 1; // Windows distribution opt-in only. +#endif +} +/* Called by the existing disc verification callback. Never touches cards or + * settings. 1 requests a clean UI exit, -1 rejects, 0 stays in this backend. */ +inline int picked(const char* path) { + if(profiles.empty()) return 0; + try { + const auto disc=fs::absolute(path).lexically_normal().string(); + const auto* p=identify(disc);if(!p)return -1; + if(!same_backend(p->backend,self)) {pending=p;pending_disc=disc;return 1;} + remember(*p,disc);return 0; + } catch(const std::exception& e){std::fprintf(stderr,"regional selection: %s\n",e.what());return -1;} +} +inline int handoff() { + if(!pending)return 1; + try{return spawn(*pending,pending_disc);}catch(const std::exception& e){std::fprintf(stderr,"regional handoff: %s\n",e.what());return 1;} +} +/* Before SDL/config initialization. Probe/select-only are CLI acceptance hooks, + * exercising the exact production identity and persistence code without UI. */ +inline int startup(int argc,char** argv) { + try { + load(argv[0]);if(profiles.empty())return -1; + std::string disc;bool probe=false,select_only=false,explicit_disc=false,exec_probe=false,ui_probe=false; + for(int i=1;i(state,"disc"); + } catch(...) {std::fprintf(stderr,"Ignoring unreadable European selection record\n");} + } + if(disc.empty())return (probe||select_only)?1:-1; + disc=fs::absolute(disc).lexically_normal().string(); + const auto* p=identify(disc); + if(!p){std::fprintf(stderr,"Unsupported European disc or missing backend\n");return explicit_disc||probe||select_only?1:-1;} + if(probe||select_only) { + if(select_only)remember(*p,disc); + std::printf("regional profile=%s backend=%s\n",p->id.c_str(),p->backend.filename().string().c_str());return 0; + } + if(!same_backend(p->backend,self)) { + if(ui_probe){if(picked(disc.c_str())!=1)return 1;return handoff();} + return spawn(*p,disc); + } + if(exec_probe){std::printf("regional entered=%s\n",p->id.c_str());return 0;} + selected_disc=disc;remember(*p,disc);return -1; + } catch(const std::exception& e){std::fprintf(stderr,"regional launcher: %s\n",e.what());return 1;} +} +} // namespace regional +} // namespace PSXRecompV4 diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index e8d5a480..072bd1a8 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -6,6 +6,7 @@ */ #include "cpu_state.h" +#include "regional_launch.h" #include "projection_scale.hpp" #include "projection_scale_config.hpp" #include "mod_controller_source.h" @@ -2686,8 +2687,8 @@ static std::filesystem::path resolve_existing_runtime_path(const char* requested std::error_code ec; fs::path p(requested); - if (fs::exists(p, ec)) return PSXRecompV4::host_absolute(p, ec); - if (PSXRecompV4::host_path_is_absolute(p)) return {}; + if (PSXRecompV4::host_path_is_absolute(p)) + return fs::exists(p, ec) ? PSXRecompV4::host_absolute(p, ec) : fs::path{}; // Anchor exclusively on the exe directory — never cwd (see exe_dir_from_argv). const fs::path root = exe_dir_from_argv(argv0); @@ -10360,6 +10361,12 @@ namespace { if (!disc_path || !disc_path[0] || !out) return 0; g_launcher_disc_path = disc_path; std::memset(out, 0, sizeof(*out)); + const int profile_route = fntrace_is_game_started() ? 0 : PSXRecompV4::regional::picked(disc_path); + if (profile_route != 0) { + out->verdict = profile_route > 0 ? 1 : 3; + out->profile_handoff = profile_route > 0; + return 1; + } /* Which serial THIS disc should carry -- the set's per-disc value, or * the game's own for a single-disc title. The expected CRC is disc * 1's and is only consulted when the game supplied one, so a set that @@ -15653,6 +15660,8 @@ static uint32_t gcs_gate_read_ram(uint32_t phys, uint32_t size) { } int main(int argc, char** argv) { + const int regional_result = PSXRecompV4::regional::startup(argc, argv); + if (regional_result >= 0) return regional_result; /* Force line-buffered output so messages appear even if killed. */ std::setvbuf(stdout, nullptr, _IOLBF, BUFSIZ); std::setvbuf(stderr, nullptr, _IOLBF, BUFSIZ); @@ -15714,6 +15723,7 @@ int main(int argc, char** argv) { * PSX_NO_LAUNCHER env) forces it off. --launcher wins if both are given. */ bool force_launcher = false; bool force_no_launcher = false; + bool cli_launcher_disc_check = false; /* CLI overrides for running several instances side by side (soak fleet). * These win over any game-config value and, crucially, work for the BIOS * (which has no [game]-block config schema, so debug_port/renderer can't be @@ -15803,6 +15813,8 @@ int main(int argc, char** argv) { * seeded with is resolved by then. */ cli_netplay_query = argv[++i]; force_launcher = true; + } else if (std::strcmp(argv[i], "--launcher-disc-check") == 0) { + cli_launcher_disc_check = true; } else if (std::strcmp(argv[i], "--launcher") == 0) { force_launcher = true; } else if (std::strcmp(argv[i], "--no-launcher") == 0) { @@ -15861,6 +15873,21 @@ int main(int argc, char** argv) { } std::string default_game_config_storage; + if (!disc_override_path && !PSXRecompV4::regional::selected_disc.empty()) + disc_override_path = PSXRecompV4::regional::selected_disc.c_str(); + if (!game_config_path) { + /* A catalog backend must own its config. Never inherit another + * regional profile from cwd or a parent if its config is missing. */ + if (!PSXRecompV4::regional::profiles.empty()) { + const auto own = PSXRecompV4::regional::self.parent_path() / "game.toml"; + if (!std::filesystem::is_regular_file(own)) { + std::fprintf(stderr, "Missing regional backend game.toml\n"); + return 1; + } + default_game_config_storage = own.string(); + game_config_path = default_game_config_storage.c_str(); + } + } if (!game_config_path) { std::filesystem::path default_game_config = resolve_existing_runtime_path(PSX_DEFAULT_GAME_CONFIG_PATH, argv[0]); @@ -17127,6 +17154,24 @@ int main(int argc, char** argv) { } #if defined(RECOMP_LAUNCHER) + /* Noninteractive acceptance of the real launcher callback AFTER config + * resolution. Regional routing probes intentionally return before here. */ + if (cli_launcher_disc_check) { + if (overlay_init_thread.joinable()) overlay_init_thread.join(); + g_lnch_expected_serial = game_id; + g_lnch_expected_crc = game_disc_crc; + g_lnch_has_crc = game_has_disc_crc; + g_lnch_argv0 = argv[0]; + const auto disc = disc_override_path + ? normalize_disc_path_for_launch(disc_override_path) : resolved_disc; + RecompLauncherCDiscVerify verdict{}; + const int handled = ae_disc_verify(disc.string().c_str(), &verdict); + std::printf("launcher-disc-check expected=%s serial=%s verdict=%d iso=%d handoff=%d crc_required=%d config=%s\n", + game_id.c_str(), verdict.serial, verdict.verdict, verdict.iso_ok, + verdict.profile_handoff, game_has_disc_crc ? 1 : 0, + game_config_path ? game_config_path : ""); + return handled && verdict.verdict == 1 && verdict.iso_ok && !verdict.profile_handoff ? 0 : 1; + } /* Integrated recomp-ui launcher: shown in its own GL window before the emulator * boots. Seeded with the effective settings (game.toml ∪ settings.toml); * on LAUNCH the user's choices are persisted to settings.toml and applied. @@ -17668,6 +17713,11 @@ int main(int argc, char** argv) { launcher_boot_timing_mark("host:after_run_window"); lr = rui_rc; + if (lr == RECOMP_LAUNCHER_RESULT_PROFILE) { + if (overlay_init_thread.joinable()) overlay_init_thread.join(); + SDL_Quit(); + return PSXRecompV4::regional::handoff(); + } /* The launcher hands back the player's edits on Quit as well as on * Play (recomp_launcher.h: "*io still holds the edits"), so both