diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index e8ca60199..4203f747e 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 b7cf574ac..91bfc519b 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -1162,6 +1162,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 8f9bda694..04fada2a3 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -664,6 +664,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 936c199ea..f310d3bd0 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..d3148c9bd 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 @@ -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 @@ -20,6 +27,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 +57,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 +70,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 d6e62d9ee..f5ab5a953 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1571,6 +1571,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; @@ -16906,6 +16907,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 = @@ -18297,6 +18308,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 318c86bc7..3fe9dd529 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -1862,6 +1862,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) @@ -2849,8 +2850,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; @@ -2859,6 +2883,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"); @@ -2954,6 +2982,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 ee8da3ed6..d7029ae25 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -1210,6 +1210,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; @@ -1303,6 +1306,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; @@ -2612,6 +2630,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");