From f739d1feb78b85f5a0e1ed49670aa99596a452db Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:54:03 -0700 Subject: [PATCH] Live mod option changes (in-game overlay P5) psx_mod_set_option_live records a session override for a committed feature's option and calls the plugin's new option-changed callback in that plugin's context; psx_mod_option_value answers the override. It returns 1 when applied live, 0 when the change waits for a restart, -1 during netplay (the plan is negotiated between peers). Nothing changes unless an overlay calls it. Co-Authored-By: Claude Opus 5.5 --- runtime/include/mod_plugins.h | 18 ++++++++++ runtime/src/mod_runtime.cpp | 53 ++++++++++++++++++++++++++++-- runtime/tests/test_mod_runtime.cpp | 30 +++++++++++++++++ 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 81e92287..a4af918e 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -372,6 +372,24 @@ int psx_mod_option_value(const char* package_id, const char* feature_id, * The same committed-plan and buffer rules as psx_mod_option_value apply. */ int psx_mod_current_option_value(const char* option_id, char* out, uint32_t out_size); +/* + * Live option changes (in-game overlay P5). A trusted plugin that can re-read + * an option on a running game registers an option-changed callback under its + * plugin id; it runs in that plugin's context (psx_mod_current_option_value + * already answers the new value) and returns 1 if the change took effect now. + * + * psx_mod_set_option_live records a new value for a committed feature's + * option for the rest of the session and notifies its plugin. Returns 1 when + * a plugin applied it live, 0 when it only takes effect after a restart (or + * the feature is not in the plan), -1 when refused: during netplay the plan is + * negotiated between peers and must not change. Persisting the choice stays + * the mod provider's job (mods/state.toml). + */ +typedef int (*PSXModOptionChangedCallback)(const char* option_id, const char* value); +int psx_mod_register_option_changed_plugin(const char* id, + PSXModOptionChangedCallback callback); +int psx_mod_set_option_live(const char* package_id, const char* feature_id, + const char* option_id, const char* value); /* * Read the committed owner-selected path for a resource declared by the * package feature whose trusted plugin is currently running. Returns 0 when diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 92eab572..f1b78fbe 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -2824,6 +2824,51 @@ extern "C" int psx_mod_game_started(void) { return fntrace_is_game_started(); } +/* ---- Live option overrides (in-game overlay P5) ------------------------- */ +namespace { +std::map& live_option_values() { + static std::map m; + return m; +} +std::map& option_changed_plugins() { + static std::map m; + return m; +} +std::string live_option_key(const char* p, const char* f, const char* o) { + return std::string(p) + '\x1f' + f + '\x1f' + o; +} +} // namespace + +extern "C" int psx_mod_register_option_changed_plugin(const char* id, + PSXModOptionChangedCallback cb) { + if (!id || !*id || !cb) return 0; + option_changed_plugins()[id] = cb; + return 1; +} + +extern "C" int psx_mod_set_option_live(const char* package_id, const char* feature_id, + const char* option_id, const char* value) { + using namespace PSXRecompV4; + if (!package_id || !feature_id || !option_id || !value) return 0; + if (psx_netplay_active()) return -1; + RuntimeMods& s = state(); + if (!s.initialized || !s.plan.ok) return 0; + bool in_plan = false; + for (const ModResolution::Plugin& plugin : s.plan.plugins) + if (plugin.package_id == package_id && plugin.feature_id == feature_id) in_plan = true; + if (!in_plan) return 0; + live_option_values()[live_option_key(package_id, feature_id, option_id)] = value; + int live = 0; + for (const ModResolution::Plugin& plugin : s.plan.plugins) { + if (plugin.package_id != package_id || plugin.feature_id != feature_id) continue; + auto it = option_changed_plugins().find(plugin.id); + if (it == option_changed_plugins().end()) continue; + PluginCallbackScope scope(s, &plugin); + if (it->second(option_id, value)) live = 1; + } + return live; +} + extern "C" int psx_mod_option_value(const char* package_id, const char* feature_id, const char* option_id, @@ -2839,9 +2884,11 @@ extern "C" int psx_mod_option_value(const char* package_id, * treat an empty string as a value. */ if (!s.initialized || !s.plan.ok) return 0; /* Read the committed plan's selection, which includes features its - * [[requirement]]s activated, not live launcher state. */ - const std::string value = s.manager.feature_option_value( - s.plan, package_id, feature_id, option_id); + * [[requirement]]s activated, not live launcher state -- unless the + * in-game menu changed it this session (psx_mod_set_option_live). */ + auto live = live_option_values().find(live_option_key(package_id, feature_id, option_id)); + const std::string value = live != live_option_values().end() ? live->second : + s.manager.feature_option_value(s.plan, package_id, feature_id, option_id); if (value.empty()) return 0; if (value.size() + 1 > (size_t)out_size) return 0; std::memcpy(out, value.c_str(), value.size() + 1); diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 0398f7d8..e796afc4 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -318,6 +318,18 @@ int iso_read_cdda_sector(void*,uint32_t,uint8_t*,int); } static int big_ram_activations; +static int option_changed_hits = 0; +static std::string option_changed_value; +static int test_option_changed(const char* option_id, const char* value) { + char cur[32]; + option_changed_hits++; + /* Runs in the plugin's context: current_option_value answers the new value. */ + if (std::string(option_id) == "size" && + psx_mod_current_option_value("size", cur, sizeof(cur)) == 1) + option_changed_value = cur; + (void)value; + return 1; +} static void test_big_ram_activation(void) { big_ram_activations++; } @@ -1082,6 +1094,24 @@ int main(int argc, char** argv) { sizeof(value)) == 1 && std::string(value) == "eight", "an implicit feature's plugin must read its option values"); + /* In-game overlay P5: a live option change reaches the plugin and + * every later read, and is refused while netplay owns the plan. */ + check(psx_mod_set_option_live("runtime.ram", "big-ram", "size", "four") == 0, + "without an option-changed callback a live set waits for restart"); + check(psx_mod_option_value("runtime.ram", "big-ram", "size", value, sizeof(value)) == 1 && + std::string(value) == "four", + "a live override must answer later reads"); + check(psx_mod_register_option_changed_plugin("runtime.big-ram", test_option_changed), + "option-changed hook must register"); + check(psx_mod_set_option_live("runtime.ram", "big-ram", "size", "eight") == 1 && + option_changed_hits == 1 && option_changed_value == "eight", + "the plugin must see the change in its own context"); + check(psx_mod_set_option_live("runtime.ram", "no-such-feature", "size", "x") == 0, + "features outside the plan are not live-settable"); + test_netplay_active = 1; + check(psx_mod_set_option_live("runtime.ram", "big-ram", "size", "four") == -1, + "netplay must refuse live option changes"); + test_netplay_active = 0; /* commit rewrites state.toml in canonical form; what matters is that * the player's choice survives and the derived one is never added. */ const std::string after = read_state();