Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/MOD_PACKAGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions recompiler/src/config_loader.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>(launcher, "quick_controls");
}
}

// Optional [audio] block.
if (cfg.contains("audio")) {
const toml::value& audio = toml::find(cfg, "audio");
Expand Down
3 changes: 3 additions & 0 deletions recompiler/src/config_loader.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions runtime/include/mod_packages.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -608,6 +616,7 @@ class ModPackageManager {
std::map<std::string, std::map<std::string, ModPackage>> packages_;
std::vector<std::string> disabled_packages_;
std::map<std::string, ModSelection> selections_;
std::map<std::string, bool> quick_visibility_; /* "package/feature" */
std::map<std::string, std::filesystem::path> sources_;
};

Expand Down
21 changes: 17 additions & 4 deletions runtime/include/regional_launch.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 <toml.hpp>
#include <cstdio>
#include <fstream>
Expand All @@ -12,6 +12,13 @@
#include <vector>
#include <stdexcept>
#if defined(_WIN32)
#ifndef NOMINMAX
#define NOMINMAX
#endif
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h> /* GetModuleFileNameW, MoveFileExW, GetCurrentProcessId */
#include <process.h>
#endif

Expand All @@ -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<Profile> profiles;
inline std::string selected_disc, pending_disc;
inline const Profile* pending = nullptr;
Expand Down Expand Up @@ -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<bool>(config,"disc_selection_locked",false);
for(const auto& entry:toml::find(config,"profile").as_array()) {
Profile p{toml::find<std::string>(entry,"id"),toml::find<std::string>(entry,"sha256"),
toml::find<uint64_t>(entry,"size"),inside(toml::find<std::string>(entry,"backend"))};
Expand All @@ -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;
}
Expand Down
12 changes: 12 additions & 0 deletions runtime/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 <exe dir>/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 =
Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions runtime/src/mod_packages.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1862,6 +1862,7 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out,
feature.default_enabled =
toml::find_or<bool>(v, "default_enabled", false);
feature.hidden = toml::find_or<bool>(v, "hidden", false);
feature.quick = toml::find_or<bool>(v, "quick", false);
feature.channel = out.channel;
if (v.contains("channel")) {
if (out.format_version < 6)
Expand Down Expand Up @@ -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;
Expand All @@ -2859,6 +2883,10 @@ bool ModPackageManager::load_state(std::string* error) {
const int64_t version = toml::find<int64_t>(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");
Expand Down Expand Up @@ -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_)
Expand Down
21 changes: 21 additions & 0 deletions runtime/src/mod_runtime.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions runtime/tests/test_mod_packages.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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");
Expand Down
Loading