From cd4250438fdab92bc00207519032ce8b7342a94e Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 13:04:54 -0700 Subject: [PATCH] Scope mod option controls to offline or online launcher flows --- docs/MOD_PACKAGES.md | 7 +++++++ runtime/include/mod_packages.h | 10 ++++++++++ runtime/include/mod_runtime.h | 2 ++ runtime/src/main.cpp | 6 ++++++ runtime/src/mod_packages.cpp | 8 ++++++++ runtime/src/mod_runtime.cpp | 25 ++++++++++++++++++++----- runtime/tests/test_mod_packages.cpp | 29 +++++++++++++++++++++++++++++ 7 files changed, 82 insertions(+), 5 deletions(-) diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index be07b9ed..f63c334a 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -252,6 +252,13 @@ feature-local: `when = { option = "value", ... }` requires every listed option to match. The legacy `when_option`/`when_value` pair remains accepted for a single condition. +Options may declare `display_context = "online"` or `"offline"` to present +their control only in that launcher flow. The default `"any"` preserves +existing visibility. This is presentation metadata: hiding a control does not +clear its value, change feature activation or remove it from the committed +session identity. Entering Netplay reveals online controls; returning to the +offline dashboard restores offline controls. + Format10 also permits these conditions on `[[resource]]`. Only matching resources appear in the feature's file pickers, preparation inputs/outputs, and resolved plan. A required resource is required only while its condition diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index a012bca0..72f28465 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -49,6 +49,8 @@ enum class ModOptionType { Integer, }; +enum class ModOptionDisplayContext { Any, Offline, Online }; + struct ModChoice { std::string value; std::string label; @@ -71,6 +73,14 @@ struct ModOption { * it out and plugins ignore its value. Models "tick Instant and the speed * box stops mattering" without inventing a second widget type. */ std::string disabled_by; + /* Presentation only: selections remain part of the committed plan even + * when their control is hidden outside its launcher context. */ + ModOptionDisplayContext display_context = ModOptionDisplayContext::Any; + bool visible_in(bool online) const { + return display_context == ModOptionDisplayContext::Any || + (online ? display_context == ModOptionDisplayContext::Online + : display_context == ModOptionDisplayContext::Offline); + } }; struct ModFeature { diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index 149c4fe1..9b0126b4 100644 --- a/runtime/include/mod_runtime.h +++ b/runtime/include/mod_runtime.h @@ -53,6 +53,8 @@ bool mod_runtime_netplay_settings_fingerprint(const PsxLobbyMatchCaps& caps, std::string* error = nullptr); /* Profile-specific lobby capacity, including an optional last roster seat. */ int mod_runtime_netplay_player_limit(int fallback); +/* Launcher presentation only; never changes selection or simulation policy. */ +void mod_runtime_set_launcher_netplay_context(bool online); /* [netplay] content_negotiation from game.toml. Off (default): clear package * simulation mods; an executable-owned profile takes precedence when present. */ void mod_runtime_set_netplay_content_negotiation(bool enabled); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 90c17cb5..4fe794ec 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -16977,6 +16977,12 @@ namespace { #endif gi->memcard_inspect = ae_memcard_inspect; gi->mods = PSXRecompV4::mod_runtime_launcher_provider(); +#if defined(RECOMP_LAUNCHER_HAS_NETPLAY_MODE_POLICY) + PSXRecompV4::mod_runtime_set_launcher_netplay_context(false); + gi->netplay_mode_changed = +[](int online) { + PSXRecompV4::mod_runtime_set_launcher_netplay_context(online != 0); + }; +#endif gi->bios_verify = ae_bios_verify; /* Launcher window icon: the SAME file psx_apply_window_icon() puts on * the game window (assets/psxrecomp.png beside the exe, which diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 052721a3..2c063be2 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -1916,6 +1916,14 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, option.group = v.contains("group") ? toml::find(v, "group") : "General"; option.disabled_by = v.contains("disabled_by") ? toml::find(v, "disabled_by") : ""; + const std::string display_context = v.contains("display_context") + ? toml::find(v, "display_context") : "any"; + if (display_context == "online") + option.display_context = ModOptionDisplayContext::Online; + else if (display_context == "offline") + option.display_context = ModOptionDisplayContext::Offline; + else if (display_context != "any") + throw std::runtime_error("option display_context must be any, offline or online"); const std::string type = toml::find(v, "type"); if (!find_feature(out, option.feature_id)) throw std::runtime_error("option references unknown feature"); diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 0a6572d5..4cc137f4 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -148,6 +148,7 @@ struct RuntimeMods { /* The plan is a netplay match's own-view set: its hooks run only inside * psx_mod_render_local_view, its vblank/savestate callbacks not at all. */ bool netplay_view_plan = false; + bool launcher_netplay_context = false; const ModResolution::Plugin* current_plugin = nullptr; CPUState* current_function_cpu = nullptr; bool current_function_finished = false; @@ -960,6 +961,14 @@ int feature_set_option_impl(const char* package_id, const char* feature_id, } #if defined(RECOMP_LAUNCHER) +std::vector provider_options(const ModPackage& package) { + std::vector out; + const bool online = state().launcher_netplay_context || psx_mod_netplay_is_active(); + for (const ModOption& option : package.options) + if (option.visible_in(online)) out.push_back(&option); + return out; +} + void copy_text(char* out, size_t capacity, const std::string& value) { if (!out || capacity == 0) return; std::snprintf(out, capacity, "%s", value.c_str()); @@ -995,7 +1004,7 @@ int provider_package_get(void*, int index, RecompLauncherCModPackage* out) { copy_text(out->source_name, sizeof(out->source_name), package->source_name); copy_text(out->source_url, sizeof(out->source_url), package->source_url); out->enabled = package_has_enabled_feature(*package); - out->option_count = (int)package->options.size(); + out->option_count = (int)provider_options(*package).size(); /* A bundled package is build output. Offering to remove it would succeed * and then be silently undone by the next build. */ out->removable = !package_in_use(*package) && @@ -1007,8 +1016,10 @@ int provider_option_get(void*, const char* package_id, int index, RecompLauncherCModOption* out) { if (!package_id || !out || index < 0) return 0; const ModPackage* package = selected_package(package_id); - if (!package || (size_t)index >= package->options.size()) return 0; - const ModOption& option = package->options[(size_t)index]; + if (!package) return 0; + const auto options = provider_options(*package); + if ((size_t)index >= options.size()) return 0; + const ModOption& option = *options[(size_t)index]; std::memset(out, 0, sizeof(*out)); copy_text(out->id, sizeof(out->id), option.id); copy_text(out->label, sizeof(out->label), option.label); @@ -1065,8 +1076,8 @@ bool provider_feature_at(int index, const ModPackage*& package, std::vector provider_feature_options( const ModPackage& package, const std::string& feature_id) { std::vector out; - for (const ModOption& option : package.options) - if (option.feature_id == feature_id) out.push_back(&option); + for (const ModOption* option : provider_options(package)) + if (option->feature_id == feature_id) out.push_back(option); return out; } @@ -1483,6 +1494,10 @@ int mod_runtime_netplay_player_limit(int fallback) { return count?(int)count:fallback; } +void mod_runtime_set_launcher_netplay_context(bool online) { + state().launcher_netplay_context = online; +} + static std::optional offline_manager_before_netplay; static bool netplay_content_negotiation_enabled = false; diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index c9231c64..3e75d0a7 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -2569,6 +2569,35 @@ int main() { check(!ModPackageManager::read_manifest(a,malformed,&error),"duplicate default roster rejected"); } + { + const fs::path path = root / "option-display-context.toml"; + for (const char* context : {"any", "offline", "online", "invalid"}) { + write_text(path, manifest("context.mod", "1.0.0", + "[[option]]\nid = \"view\"\nlabel = \"View\"\n" + "type = \"boolean\"\ndefault = \"true\"\n" + "display_context = \"" + std::string(context) + "\"\n")); + ModPackage package; + const bool valid = ModPackageManager::read_manifest(path, package, &error); + if (std::string(context) == "invalid") { + check(!valid, "unknown option display context must be rejected"); + continue; + } + check(valid && package.options.size() == 1, + "option display context must parse"); + if (!valid || package.options.empty()) continue; + const ModOption& option = package.options.front(); + check(option.visible_in(false) == (std::string(context) != "online"), + "offline option visibility must respect display context"); + check(option.visible_in(true) == (std::string(context) != "offline"), + "online option visibility must respect display context"); + check(option.default_value == "true", + "presentation context must not discard an option selection"); + } + ModOption legacy; + check(legacy.visible_in(false) && legacy.visible_in(true), + "unspecified display context must preserve legacy visibility"); + } + fs::remove_all(root, ec); if (failures) { std::cerr << failures << " mod package test(s) failed\n";