From a149511e410969a590f8b40e983cd1d8286afc8e Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 12:16:27 -0700 Subject: [PATCH 1/2] Add launcher quick controls, regional locked disc and artwork root Mods declare quick = true for the Quick Mods card; [launcher] quick_controls opts a title in. Regional identity now hashes portably. Based on michaelm-afk's handoff. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/MOD_PACKAGES.md | 7 ++++++ recompiler/src/config_loader.cpp | 8 +++++++ recompiler/src/config_loader.h | 3 +++ runtime/include/mod_packages.h | 9 ++++++++ runtime/include/regional_launch.h | 14 ++++++++---- runtime/src/main.cpp | 12 ++++++++++ runtime/src/mod_packages.cpp | 33 +++++++++++++++++++++++++++ runtime/src/mod_runtime.cpp | 21 +++++++++++++++++ runtime/tests/test_mod_packages.cpp | 35 +++++++++++++++++++++++++++++ 9 files changed, 138 insertions(+), 4 deletions(-) diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 8a3de9b89..dfd97de47 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -259,6 +259,13 @@ feature is hidden is not listed under "Installed packages". Either way it runs exactly as `default_enabled` and `mods/state.toml` say, so a hidden default-on feature is simply active. (recomp-ui `launcher_mod_visibility.h`.) +A feature with `quick = true` is offered in the launcher dashboard's Quick Mods +card when the title enables `[launcher] quick_controls = true` in `game.toml`. +The player can add or remove any listed feature from the Mods page ("Show in +Quick Mods"); that choice is saved under `[quick_mods]` in `mods/state.toml` +and overrides the manifest. Quick membership is presentation only: it never +changes activation, preparation or the execution fingerprint. + ## Bounded integer patches Package format 2 can encode a bounded integer option directly into a guarded diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index ef8b8f189..69fd7ec3a 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -1037,6 +1037,14 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& } } + // Optional [launcher] block — game-declared launcher surfaces. + if (cfg.contains("launcher")) { + const toml::value& launcher = toml::find(cfg, "launcher"); + if (launcher.contains("quick_controls")) { + rt.launcher_quick_controls = toml::find(launcher, "quick_controls"); + } + } + // Optional [audio] block. if (cfg.contains("audio")) { const toml::value& audio = toml::find(cfg, "audio"); diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 402c42392..2d1afd682 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -649,6 +649,9 @@ struct RuntimeConfig { // Hide unqualified threading/frame-generation controls in player packages. // Existing titles keep their current UI unless they opt out explicitly. bool video_offer_render_pipeline = true; + // [launcher] quick_controls: show Quick Mods and Quick Settings cards next + // to the memory cards on the launcher dashboard. Off by default. + bool launcher_quick_controls = false; // texture_window_batching: OpenGL only. Let textured primitives with // different GP0(E2h) texture windows share one draw (the window rides in diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 78c779734..e7812b5d0 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -83,6 +83,9 @@ struct ModFeature { bool default_enabled = false; bool hidden = false; bool legacy = false; + /* Manifest `quick`: offered in the launcher's Quick Mods card by default. + * The player's own choice (state.toml [quick_mods]) overrides it. */ + bool quick = false; }; enum class ModConstraintKind { @@ -479,6 +482,11 @@ class ModPackageManager { bool scan(std::string* error = nullptr); bool load_state(std::string* error = nullptr); bool save_state(std::string* error = nullptr) const; + /* Quick Mods card membership: the saved player choice, else the + * manifest's quick flag. Never changes activation or the fingerprint. */ + bool quick_visible(const std::string& package, const std::string& feature) const; + bool set_quick_visible(const std::string& package, const std::string& feature, + bool visible, std::string* error = nullptr); bool prepare_resources(const std::string& game_id, const std::filesystem::path& disc_path, const std::filesystem::path& cache_root, @@ -608,6 +616,7 @@ class ModPackageManager { std::map> packages_; std::vector disabled_packages_; std::map selections_; + std::map quick_visibility_; /* "package/feature" */ std::map sources_; }; diff --git a/runtime/include/regional_launch.h b/runtime/include/regional_launch.h index 415095d62..fd39ae8ec 100644 --- a/runtime/include/regional_launch.h +++ b/runtime/include/regional_launch.h @@ -3,7 +3,7 @@ * 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 "psx_sha256_file.h" #include #include #include @@ -20,6 +20,9 @@ namespace regional { namespace fs = std::filesystem; struct Profile { std::string id, sha256; uint64_t size; fs::path backend; }; inline fs::path root, self; +/* Catalog `disc_selection_locked`: the packaged entry has already chosen and + * verified the disc, so the launcher must not offer another one. */ +inline bool disc_selection_locked=false; inline std::vector profiles; inline std::string selected_disc, pending_disc; inline const Profile* pending = nullptr; @@ -47,6 +50,7 @@ inline void load(const char* argv0) { if(!fs::is_regular_file(file)) continue; root=fs::weakly_canonical(folder); const auto config=toml::parse(file.string()); + disc_selection_locked=toml::find_or(config,"disc_selection_locked",false); 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"))}; @@ -59,15 +63,17 @@ inline void load(const char* argv0) { return; } } -inline const Profile* identify(const fs::path& picked) { +/* require_backend=false identifies a disc whose regional backend is not + * installed, so a caller can name the missing package. */ +inline const Profile* identify(const fs::path& picked, bool require_backend = true) { 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; + uint8_t digest[32];if(psx_sha256_file(disc.data,digest)!=PsxFileHashResult::Ok) 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; + if(p.sha256==hex && (!require_backend || fs::is_regular_file(p.backend))) return &p; } return nullptr; } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 76c329219..d9a8c6add 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1570,6 +1570,7 @@ static int g_low_latency_input = 1; * GL call are done; s_render_thread_tried keeps it to one attempt. */ static int g_render_thread = 0; static bool g_offer_render_pipeline = true; +static bool g_launcher_quick_controls = false; /* game.toml [launcher] quick_controls */ static int g_render_thread_frames = 2; /* [video] frame_generation (docs/FRAME_GENERATION.md), with the render thread. */ static int g_frame_generation = 0; @@ -16896,6 +16897,16 @@ namespace { (void)ws_ultrawide_offered_b; launcher_profile_apply("psx", gi); psx_launcher_texture_filter_catalog(gi); +#if defined(RECOMP_LAUNCHER_HAS_ARTWORK_GALLERY) + /* A regional package keeps its covers beside the shared entry, not + * per backend; elsewhere the launcher uses /artwork. */ + static std::string regional_artwork; + regional_artwork = PSXRecompV4::regional::root.empty() + ? std::string() : (PSXRecompV4::regional::root / "artwork").u8string(); + gi->artwork_root = regional_artwork.empty() ? nullptr : regional_artwork.c_str(); + gi->disc_selection_locked = PSXRecompV4::regional::disc_selection_locked ? 1 : 0; + gi->dashboard_quick_controls = g_launcher_quick_controls ? 1 : 0; +#endif gi->name = game_name_c; gi->region = region_c; gi->keybinds_path = @@ -18287,6 +18298,7 @@ int main(int argc, char** argv) { gc.runtime.video_texture_window_batching ? 1 : 0); g_render_thread = gc.runtime.video_render_thread ? 1 : 0; g_offer_render_pipeline = gc.runtime.video_offer_render_pipeline; + g_launcher_quick_controls = gc.runtime.launcher_quick_controls; g_frame_generation = gc.runtime.video_frame_generation ? 1 : 0; g_frame_generation_method = gc.runtime.video_frame_generation_method; /* [timing] guest_cycle_scale is a title constant from game.toml diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index de759f620..438bd3075 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -1850,6 +1850,7 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, feature.default_enabled = toml::find_or(v, "default_enabled", false); feature.hidden = toml::find_or(v, "hidden", false); + feature.quick = toml::find_or(v, "quick", false); feature.channel = out.channel; if (v.contains("channel")) { if (out.format_version < 6) @@ -2837,8 +2838,31 @@ bool ModPackageManager::scan(std::string* error) { return true; } +bool ModPackageManager::quick_visible(const std::string& package, + const std::string& feature) const { + const auto found = quick_visibility_.find(package + "/" + feature); + if (found != quick_visibility_.end()) return found->second; + const ModFeature* manifest = selected_feature(package, feature); + return manifest && manifest->quick; +} + +bool ModPackageManager::set_quick_visible(const std::string& package, + const std::string& feature, + bool visible, std::string* error) { + if (!selected_feature(package, feature)) { + set_error(error, "unknown feature: " + package + "/" + feature); + return false; + } + const auto previous = quick_visibility_; + quick_visibility_[package + "/" + feature] = visible; + if (save_state(error)) return true; + quick_visibility_ = previous; + return false; +} + bool ModPackageManager::load_state(std::string* error) { selections_.clear(); + quick_visibility_.clear(); sources_.clear(); const fs::path path = root_ / "state.toml"; if (!fs::exists(path)) return true; @@ -2847,6 +2871,10 @@ bool ModPackageManager::load_state(std::string* error) { const int64_t version = toml::find(cfg, "format_version"); if (version != 1 && version != 2) throw std::runtime_error("unsupported state format_version"); + if (cfg.contains("quick_mods")) { + for (const auto& [key, value] : toml::find(cfg, "quick_mods").as_table()) + if (value.is_boolean()) quick_visibility_[key] = value.as_boolean(); + } if (cfg.contains("sources")) { for (const auto& [id, value] : toml::find(cfg, "sources").as_table()) { if (!valid_id(id) || !value.is_string()) throw std::runtime_error("invalid shared source binding"); @@ -2942,6 +2970,11 @@ bool ModPackageManager::save_state(std::string* error) const { return false; } out << "format_version = 2\n"; + if (!quick_visibility_.empty()) { + out << "\n[quick_mods]\n"; + for (const auto& [key, value] : quick_visibility_) + out << quote_toml(key) << " = " << (value ? "true" : "false") << "\n"; + } if (!sources_.empty()) { out << "\n[sources]\n"; for (const auto& [id, path] : sources_) diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index a7c83fb75..ebe0ef2b7 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -1188,6 +1188,9 @@ int provider_feature_get(void*, int index, RecompLauncherCModFeature* out) { copy_text(out->source_url, sizeof(out->source_url), package->source_url); copy_text(out->group, sizeof(out->group), feature->group); out->hidden = feature->hidden ? 1 : 0; +#if defined(RECOMP_LAUNCHER_HAS_QUICK_MODS) + out->quick = state().manager.quick_visible(package->id, feature->id) ? 1 : 0; +#endif switch (feature->channel) { case ModChannel::Experimental: out->channel = RECOMP_MOD_CHANNEL_EXPERIMENTAL; @@ -1281,6 +1284,21 @@ int provider_feature_set_option(void*, const char* package_id, return feature_set_option_impl(package_id, feature_id, option_id, value); } +#if defined(RECOMP_LAUNCHER_HAS_QUICK_MODS) +/* Quick Mods card membership only: saved to state.toml without touching + * activation, the preparation ticket or the execution fingerprint. */ +int provider_feature_set_quick(void*, const char* package_id, + const char* feature_id, int quick) { + if (!package_id || !feature_id) return 0; + std::string error; + if (!state().manager.set_quick_visible(package_id, feature_id, quick != 0, &error)) { + set_error(error); + return 0; + } + return 1; +} +#endif + int provider_feature_resource_count(void*, const char* package_id, const char* feature_id) { if (!package_id || !feature_id) return 0; @@ -2576,6 +2594,9 @@ const std::filesystem::path& mod_runtime_effective_disc_path() { #if defined(RECOMP_LAUNCHER) const RecompLauncherCModProvider* mod_runtime_launcher_provider() { +#if defined(RECOMP_LAUNCHER_HAS_QUICK_MODS) + provider.feature_set_quick = provider_feature_set_quick; +#endif #if defined(RECOMP_LAUNCHER_HAS_WORKER_MOD_COMMIT) && defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE) provider.commit_worker_safe = 1; #if defined(RECOMP_LAUNCHER_HAS_PREPARED_MOD_COMMIT) diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index 0ca049475..7befaedb3 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -468,6 +468,7 @@ int main() { "id = \"retranslation\"\n" "name = \"Retranslation\"\n" "group = \"Localization\"\n" + "quick = true\n" "\n[[feature]]\n" "id = \"title-collision\"\n" "name = \"Title Collision\"\n" @@ -602,6 +603,40 @@ int main() { check(feature_reload.resolve("SLUS-TEST").fingerprint == partial_compatible.fingerprint, "feature state must resolve deterministically after reload"); + { + /* Quick Mods membership: manifest default, then the player's saved + * choice; never activation or the execution fingerprint. */ + const auto fingerprint = feature_reload.resolve("SLUS-TEST").fingerprint; + check(!feature_reload.quick_visible("features.mod", "title-screen") && + feature_reload.quick_visible("features.mod", "retranslation"), + "unsaved quick membership must follow the manifest quick flag"); + check(feature_reload.set_quick_visible("features.mod", "title-screen", true, &error), + error.c_str()); + check(feature_reload.set_quick_visible("features.mod", "retranslation", false, &error), + error.c_str()); + check(!feature_reload.set_quick_visible("unknown.mod", "unknown", true, &error), + "quick membership of an unknown feature must be rejected"); + ModPackageManager quick_reload(root); + check(quick_reload.scan(&error) && quick_reload.load_state(&error), error.c_str()); + check(quick_reload.quick_visible("features.mod", "title-screen") && + !quick_reload.quick_visible("features.mod", "retranslation"), + "saved quick membership must survive reload and override the manifest"); + check(quick_reload.feature_enabled("features.mod", "title-screen") && + quick_reload.feature_enabled("features.mod", "retranslation") && + quick_reload.resolve("SLUS-TEST").fingerprint == fingerprint, + "quick membership must not change activation or the fingerprint"); + check(quick_reload.set_feature_enabled("features.mod", "title-screen", false, &error) && + quick_reload.save_state(&error), + error.c_str()); + ModPackageManager after_toggle(root); + check(after_toggle.scan(&error) && after_toggle.load_state(&error), error.c_str()); + check(after_toggle.quick_visible("features.mod", "title-screen") && + !after_toggle.feature_enabled("features.mod", "title-screen"), + "activation edits must preserve quick membership"); + check(after_toggle.set_feature_enabled("features.mod", "title-screen", true, &error) && + after_toggle.save_state(&error), + error.c_str()); + } fs::create_directories(root / "selected", ec); write_text(root / "selected/bezel.png", "not a decoded image"); From ec6b77b3d626c202e979092b6622b01f1dd42123 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 13:26:15 -0700 Subject: [PATCH 2/2] Include windows.h directly in regional_launch.h Co-Authored-By: Claude Opus 5.5 (1M context) --- runtime/include/regional_launch.h | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/runtime/include/regional_launch.h b/runtime/include/regional_launch.h index fd39ae8ec..d3148c9bd 100644 --- a/runtime/include/regional_launch.h +++ b/runtime/include/regional_launch.h @@ -12,6 +12,13 @@ #include #include #if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include /* GetModuleFileNameW, MoveFileExW, GetCurrentProcessId */ #include #endif