diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 6a75927f..e8ca6019 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -1182,3 +1182,10 @@ existing post-commit path, not the preparation worker. The `prepare_resources` method is separate from read-only `resolve`, so editing launcher settings does not trigger conversions. Runtime activation still sees only fully verified immutable resources. + +Function-override package arming is transactional and runs before activation +callbacks. Address collisions or capacity failures reject the launch through +`mod_runtime_activation_error()` and remove all package entries, retaining +always-on direct implementations. Every session rearms its selected plan. +Replacement overrides cannot be selected as netplay local-view plugins: they +change shared execution rather than only sandboxed presentation. diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index c81a2eeb..9a686e77 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -493,9 +493,9 @@ between Play and TCP availability. ## Complete command index (generated) -**386 commands registered** — 373 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**387 commands registered** — 374 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -85 of 386 have prose above; **301 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. +85 of 387 have prose above; **302 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code. @@ -630,6 +630,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `frame_trace` | ✓ | | | | `frame_wall_ring` | ✓ | | | | `freeze_check` | ✓ | | | +| `func_override` | ✓ | | | | `game_options` | ✓ | | | | `gcp_ring` | ✓ | | ✓ | | `geom_correction` | ✓ | | ✓ | diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index f6af2899..52d60175 100644 --- a/runtime/include/mod_runtime.h +++ b/runtime/include/mod_runtime.h @@ -113,6 +113,8 @@ void mod_runtime_set_progress_hook(void (*hook)(void)); /* Invokes activation callbacks for the committed plan. Call after the final * launcher commit and before renderer/window initialization. */ void mod_runtime_activate_plugins(void); +/* NULL on success; a non-NULL arming failure requires refusing this launch. */ +const char* mod_runtime_activation_error(void); void mod_runtime_on_vblank(void); /* Host VBlank query for the active title's optional disc-request provider. */ int mod_runtime_disc_request(int *ready); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index bf442910..9ecd7977 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -20412,6 +20412,10 @@ int main(int argc, char** argv) { netplay && PSXRecompV4::mod_runtime_netplay_view_active() ? 1 : 0); psx_mod_immediate_begin_session(netplay ? 1 : 0); mod_runtime_activate_plugins(); + if (const char* error = mod_runtime_activation_error()) { + std::fprintf(stderr,"psxrecomp: %s\n",error); + return false; + } func_override_install(); if (!psx_mod_immediate_session_valid()) { std::fprintf(stderr, "psxrecomp: immediate-selector enhancements are offline-only\n"); diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index baac2634..b6b7bf8d 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -1696,8 +1696,8 @@ bool mod_runtime_clear_for_netplay(std::string* error) { * diverging from a peer without the mod. Also re-arm-able: dropping the * armed flag lets a later plan register the same override again. */ const int disarmed = func_override_reset_package_armed(); - if (disarmed > 0) - for (FunctionOverridePlugin& pending : function_override_plugins()) + (void)disarmed; + for (FunctionOverridePlugin& pending : function_override_plugins()) pending.armed = false; if (error) error->clear(); std::fprintf(stdout, "psxrecomp: mods cleared for netplay (vanilla session)\n"); @@ -2998,6 +2998,11 @@ bool mod_runtime_read_disc_file_sectors(const std::string& path, uint32_t max_by } } // namespace PSXRecompV4 +static std::string s_override_activation_error; +extern "C" const char* mod_runtime_activation_error(void) { + return s_override_activation_error.empty() ? nullptr : s_override_activation_error.c_str(); +} + extern "C" void mod_runtime_activate_plugins(void) { using namespace PSXRecompV4; HostLaunchTimingScope timing(HOST_LAUNCH_PLUGIN_ACTIVATION); @@ -3005,6 +3010,9 @@ extern "C" void mod_runtime_activate_plugins(void) { psx_host_set_hud_draw_cb(nullptr, nullptr); mod_reset_plugin_sessions(); RuntimeMods& s = state(); + s_override_activation_error.clear(); + func_override_reset_package_armed(); + for (auto& pending : function_override_plugins()) pending.armed = false; s.ticket_ready = false; psx_mod_set_disc_request_provider(nullptr); psx_projection_reset_session(); @@ -3015,6 +3023,27 @@ extern "C" void mod_runtime_activate_plugins(void) { s.disc_extents.clear(); s.audio_tracks.clear(); s.extent_start = 0; + /* Arm the entire tier before activation callbacks change host state. */ + for (auto& pending : function_override_plugins()) { + const bool selected = std::any_of(s.plan.plugins.begin(),s.plan.plugins.end(), + [&](const ModResolution::Plugin& p) { return p.id == pending.plugin; }); + if (!selected) continue; + const int rc = s.netplay_view_plan ? FO_ERR_ARGS : func_override_add_package( + pending.id.c_str(),pending.address,pending.fn, + pending.n_guard ? pending.guard : nullptr,pending.n_guard,pending.credit); + if (rc != FO_OK) { + s_override_activation_error = "Cannot arm function override " + pending.id + + " (registration error " + std::to_string(rc) + ")"; + s.error = s_override_activation_error; + s.plan.ok = false; + func_override_reset_package_armed(); + for (auto& item : function_override_plugins()) item.armed = false; + clear_function_entry_hooks(); + return; + } + pending.armed = true; + } + func_override_install(); s.activating = true; psx_mod_immediate_begin_activation(); for (const ModResolution::Plugin& plugin : s.plan.plugins) { @@ -3024,26 +3053,6 @@ extern "C" void mod_runtime_activate_plugins(void) { mod_invoke_activation_plugin(plugin.id); plugin_timing.success(); } - /* Arm the package-gated function overrides for plan-selected plugin - * ids, then (re)install the dispatcher hook. func_override_add refuses - * duplicate addresses; a refusal here means two active plugins claim - * one function, which the resolver should have prevented — the armed - * flag stays false and the `func_override` TCP command shows the gap. */ - for (FunctionOverridePlugin& pending : function_override_plugins()) { - if (pending.armed) continue; - const bool selected = std::any_of( - s.plan.plugins.begin(), s.plan.plugins.end(), - [&](const ModResolution::Plugin& plugin) { - return plugin.id == pending.plugin; - }); - if (!selected) continue; - const int rc = func_override_add_package( - pending.id.c_str(), pending.address, pending.fn, - pending.n_guard ? pending.guard : nullptr, pending.n_guard, - pending.credit); - pending.armed = (rc == FO_OK); - } - func_override_install(); psx_mod_immediate_end_activation(); s.activating = false; build_function_entry_hooks(s); @@ -3531,7 +3540,7 @@ extern "C" int psx_mod_register_function_override( const char* id, uint32_t address, PSXModFunctionOverrideFn fn, const uint32_t* expected_words, int n_words, int32_t credit) { using namespace PSXRecompV4; - if (!id || !*id || !address || !fn) return 0; + if (!id || !*id || !(address & 0x1fffffffu) || (address & 3u) || !fn) return 0; if (n_words < 0 || n_words > FO_MAX_GUARD_WORDS) return 0; if (n_words > 0 && !expected_words) return 0; if (credit < FO_CREDIT_SELF) return 0; diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index bdf564a9..33a55313 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -1,4 +1,5 @@ #include "mod_runtime.h" +#include "func_override.h" #include "mod_netplay.h" #include "host_launch_timing.h" #include "host_overlay.h" @@ -32,6 +33,9 @@ namespace fs = std::filesystem; static std::array ram; static int failures; static int activation_calls; +static int override_activation_calls; +static void override_activate() { ++override_activation_calls; } +static int override_body(CPUState*) { return 1; } static int plugin_calls; static int restore_calls; static int option_renderer_activations; @@ -540,6 +544,7 @@ int main(int argc, char** argv) { const fs::path root = fs::temp_directory_path() / "psxrecomp-mod-runtime-test"; #endif std::error_code ec; + fs::remove_all(root, ec); const std::vector stock(8 * 2352, 0); std::vector overlay(3000); @@ -2439,6 +2444,50 @@ int main(int argc, char** argv) { "resolver preserves missing selection for existing downstream validation"); } + { + const auto base = root/"function-overrides"; + write_text(base/"bundled/override.test/1.0.0/manifest.toml", R"toml( +format_version = 5 +id = "override.test" +version = "1.0.0" +name = "Overrides" +resolver = "declarative" +save_compatibility = "shared" +[[target]] +game_id = "SLUS-OVERRIDE" +[[feature]] +id = "replace" +name = "Replace" +default_enabled = true +[[plugin]] +feature = "replace" +id = "override.test" +)toml"); + check(PSXRecompV4::mod_register_activation_plugin("override.test",override_activate),"register override activation"); + check(psx_mod_register_function_override("override.test:first",0x80006004,override_body,nullptr,0,0),"queue first override"); + check(psx_mod_register_function_override("override.test:second",0x80006000,override_body,nullptr,0,0),"queue second override"); + check(!psx_mod_register_function_override("override.test:bad",0x80006001,override_body,nullptr,0,0),"reject misaligned override"); + check(PSXRecompV4::mod_runtime_initialize(base,"SLUS-OVERRIDE",0x80002000,{},&error),error.c_str()); + check(PSXRecompV4::mod_runtime_commit({},&error),error.c_str()); + mod_runtime_activate_plugins(); + check(!mod_runtime_activation_error() && func_override_count()==2 && override_activation_calls==1,"selected overrides arm together"); + mod_runtime_activate_plugins(); + check(!mod_runtime_activation_error() && func_override_count()==2 && override_activation_calls==2,"session reactivation replaces entries without duplicates"); + check(PSXRecompV4::mod_runtime_clear_for_netplay(&error),error.c_str()); + check(func_override_count()==0,"cleared plan disarms all package entries"); + override_activation_calls=0; + check(func_override_add("direct",0x80006000,override_body,0)==FO_OK,"register colliding direct override"); + check(PSXRecompV4::mod_runtime_initialize(base,"SLUS-OVERRIDE",0x80002000,{},&error),error.c_str()); + check(PSXRecompV4::mod_runtime_commit({},&error),error.c_str()); + mod_runtime_activate_plugins(); + check(mod_runtime_activation_error()!=nullptr && override_activation_calls==0, + "late collision rejects launch before callbacks"); + check(func_override_count()==1,"collision rolls back all package entries and preserves direct entry"); + check(PSXRecompV4::mod_runtime_clear_for_netplay(&error),error.c_str()); + mod_runtime_activate_plugins(); + check(!mod_runtime_activation_error() && func_override_count()==1,"vanilla session clears failure and retains direct tier"); + } + fs::remove_all(root, ec); if (failures) return 1; std::cout << "mod runtime tests passed\n";