diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index f20511ce..18e4e4a7 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -534,10 +534,11 @@ there, netplay or offline, re-enters the emulator in the same process. The session before a rematch is therefore always a netplay match, which ran with the plan cleared, so no plugin activated in it. -The reset runs immediately before activation in the first session, and on the -rematch path after its commit or netplay clear. What it fixes today is the -netplay local viewport's Fit and fixed aspect carrying from a match into an -offline rematch. The rest of the table is defensive, for the state listed here. +The reset is one step of the session start that every session runs, +immediately before activation (see *A rematch is a full session start* below). +What it fixes today is the netplay local viewport's Fit and fixed aspect +carrying from a match into an offline rematch. The rest of the table is +defensive, for the state listed here. | State | Setter | Reset to | When | |---|---|---|---| @@ -576,7 +577,8 @@ Not reset, and why: `psx_mod_set_bezel_artwork` force) are launcher controls. A rematch takes both from the lobby launcher, which is seeded from the live values. For the aspect, the rematch path then re-applies the Settings clamp (4:3, since - widescreen is mod-owned) before the netplay local viewport, so a match's + widescreen is mod-owned) before its session start, where a plugin's + activation or the netplay local viewport can still replace it, so a match's 16:9 or 21:9 does not carry into an offline rematch. A renderer forced by a plugin would be carried the same way, but no plugin activates in the session before a rematch. @@ -586,15 +588,24 @@ Not reset, and why: for the process. A bank ID is read only from packets in a plugin's own arena, which stock game code does not use. -**A rematch is not a full session start.** It re-enters below the first -session's setup block, so it does not run `mod_runtime_activate_plugins()` and -does not reset controller overrides and policies, load acceleration or disc -speed (the first session resets those before activation). An offline rematch -with mods enabled therefore applies the plan's main-EXE and disc patches and -runs its VBlank callbacks, but no activation callback runs and no -function-entry hook runs. A VBlank callback must not assume its plugin's -activation ran in the same session. If the rematch path ever activates -plugins, the reset must run before that activation. +**A rematch is a full session start.** It re-enters below the first session's +setup block, so after its commit or netplay clear it runs the same sequence as +the first session: + +1. Clear controller-mode overrides and presentation policies, load + acceleration and disc speed. +2. Reset the mod-owned state in the table above. +3. Run `mod_runtime_activate_plugins()`: the plan's activation callbacks, then + its function-entry hook table. +4. Apply what activation chose: the netplay local viewport, controller-mode + overrides and load acceleration. Disc speed is read when the session boots. +5. Mount the plan's derived disc image if it built one, else the stock disc. + +An offline rematch with mods enabled therefore runs like a first launch with +those mods: activation callbacks, function-entry hooks, VBlank callbacks and +the main-EXE and disc patches. A netplay rematch clears the plan, so none of +them run and the match stays vanilla. Either way activation still precedes +renderer and window creation, which every session reaches only when it boots. A plugin should establish what it needs in its activation callback and not rely on state from an earlier session; its own static variables are its diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index fd17c314..4b1f8d6c 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1403,10 +1403,10 @@ extern "C" int psx_mod_set_adaptive_display_aspect( /* * Mod-owned session state: what a trusted plugin's psx_mod_* setters (or the * netplay local viewport, through the same setters) change and no launcher - * control owns. Called at every session start, BEFORE activation: immediately - * before mod_runtime_activate_plugins() in the first-boot session block, and - * on the lobby rematch path after its commit or netplay clear. Anything added - * to the rematch path that activates plugins must come after this call. + * control owns. Called at every session start, BEFORE activation: by + * start_mod_session() in main(), immediately before + * mod_runtime_activate_plugins(). The first boot and the lobby rematch both + * run that sequence after their commit or netplay clear. * * The rematch is the only in-process second session, and the session before * it is always a netplay match: an offline session ends the process, and only @@ -1418,11 +1418,8 @@ extern "C" int psx_mod_set_adaptive_display_aspect( * renderer and fixed aspect are launcher controls; guest/GPU-DMA memory, * texture-packet arenas and defined texture banks live for the process). * - * The rematch path jumps past the first-boot block (goto session_reboot), so - * it does not re-run mod_runtime_activate_plugins() or the controller, load - * and disc-speed resets there. Function-entry hooks need nothing here: the - * commit or netplay clear empties upstream's hook table, and only activation - * rebuilds it. + * Function-entry hooks need nothing here: the commit or netplay clear empties + * upstream's hook table, and only activation rebuilds it. * * The first call captures the pre-activation scalars (mod_session_baseline.h) * and changes nothing that is not already at its initial value, so the first @@ -1470,6 +1467,19 @@ static void reset_mod_owned_presentation(void) { g_frame_interpolation_blend = g_frame_interpolation_blend_default; } +/* The disc a session mounts: a disc-patching mod's private patched image when + * the committed plan built one, else the stock disc, which stays untouched + * (master behaviour). Every session start calls it after its commit. */ +static std::string session_disc_path(const std::filesystem::path& stock_disc) { + const std::filesystem::path& mod_disc = + PSXRecompV4::mod_runtime_effective_disc_path(); + if (mod_disc.empty()) return stock_disc.string(); + std::fprintf(stdout, + "psxrecomp: stock disc remains %s; mounting private mod cache %s\n", + stock_disc.string().c_str(), mod_disc.string().c_str()); + return mod_disc.string(); +} + extern "C" int psx_mod_set_native_vblank_rate( uint32_t frames_per_second) { if (frames_per_second != 0 && @@ -14706,54 +14716,65 @@ int main(int argc, char** argv) { return 1; } } - /* Activation callbacks are re-run after every launcher session. Clear - * game-owned controller overrides/policies first so disabling a package - * cannot leave its prior state latched across a soft return. */ - g_mod_controller_mode_override.fill(-1); - for (auto& policy : g_mod_controller_policy) - policy = ModControllerPresentationPolicy{}; - g_mod_load_wall_multiplier = -1; - g_mod_load_release_frames = -1; - g_mod_disc_speed_divisor = -1; - g_mod_disc_instant_rate = -1; - g_turbo_audio_sink_enabled = g_turbo_audio_sink_config_enabled; - g_turbo_load_wall_multiplier = 0; - g_turbo_load_release_frames = TURBO_LOADS_RELEASE_FRAMES; - if (!turbo_loads_offered) - g_turbo_loads_enabled = 0; - /* The helper restores interpolation and Skip FMVs to their pre-activation - * values, which is only right while no launcher control owns them. */ - static_assert(!frame_interpolation_offered && !skip_fmv_offered, - "reset_mod_owned_presentation() would clobber a launcher " - "setting; restore only when the feature is mod-owned"); - reset_mod_owned_presentation(); - mod_runtime_activate_plugins(); - apply_netplay_local_viewport_aspect(net_cfg.enabled); - for (int i = 0; i < PSX_MAX_PLAYERS; ++i) { - if (g_mod_controller_mode_override[i] >= 0) - player_mode[i] = g_mod_controller_mode_override[i]; - } - if (g_mod_load_wall_multiplier >= 0) { - g_turbo_loads_enabled = 1; - g_turbo_load_wall_multiplier = g_mod_load_wall_multiplier; - g_turbo_load_release_frames = g_mod_load_release_frames; - /* Fast Loading advances the guest at a host rate greater than real - * time. Keep the canonical SPU/CD stream running, but discard the - * accelerated presentation-side audio until pacing resumes; otherwise - * the SDL bridge overflows and the load becomes observably unstable. */ - g_turbo_audio_sink_enabled = g_turbo_load_wall_multiplier > 1; - if (g_turbo_load_wall_multiplier) { - std::fprintf(stdout, - "psxrecomp: mod selected %dx load acceleration " - "(%d release frames)\n", - g_turbo_load_wall_multiplier, g_turbo_load_release_frames); - } else { - std::fprintf(stdout, - "psxrecomp: mod selected uncapped load acceleration " - "(%d release frames)\n", - g_turbo_load_release_frames); + /* Session start: every session runs this after its mod commit or netplay + * clear -- the first boot here, and the lobby rematch, which re-enters at + * session_reboot below this block and so calls it itself. Both paths reach + * renderer and window creation only after session_reboot, so activation + * precedes them either way. A netplay clear leaves no plan, so nothing + * activates and the session stays vanilla. */ + auto start_mod_session = [&](bool netplay) { + /* Clear game-owned controller overrides/policies and load/disc-speed + * choices first so disabling a package cannot leave its prior state + * latched across a soft return. */ + g_mod_controller_mode_override.fill(-1); + for (auto& policy : g_mod_controller_policy) + policy = ModControllerPresentationPolicy{}; + g_mod_load_wall_multiplier = -1; + g_mod_load_release_frames = -1; + g_mod_disc_speed_divisor = -1; + g_mod_disc_instant_rate = -1; + g_turbo_audio_sink_enabled = g_turbo_audio_sink_config_enabled; + g_turbo_load_wall_multiplier = 0; + g_turbo_load_release_frames = TURBO_LOADS_RELEASE_FRAMES; + if (!turbo_loads_offered) + g_turbo_loads_enabled = 0; + /* The helper restores interpolation and Skip FMVs to their + * pre-activation values, which is only right while no launcher + * control owns them. */ + static_assert(!frame_interpolation_offered && !skip_fmv_offered, + "reset_mod_owned_presentation() would clobber a launcher " + "setting; restore only when the feature is mod-owned"); + reset_mod_owned_presentation(); + mod_runtime_activate_plugins(); + apply_netplay_local_viewport_aspect(netplay); + for (int i = 0; i < PSX_MAX_PLAYERS; ++i) { + if (g_mod_controller_mode_override[i] >= 0) + player_mode[i] = g_mod_controller_mode_override[i]; } - } + if (g_mod_load_wall_multiplier >= 0) { + g_turbo_loads_enabled = 1; + g_turbo_load_wall_multiplier = g_mod_load_wall_multiplier; + g_turbo_load_release_frames = g_mod_load_release_frames; + /* Fast Loading advances the guest at a host rate greater than real + * time. Keep the canonical SPU/CD stream running, but discard the + * accelerated presentation-side audio until pacing resumes; + * otherwise the SDL bridge overflows and the load becomes + * observably unstable. */ + g_turbo_audio_sink_enabled = g_turbo_load_wall_multiplier > 1; + if (g_turbo_load_wall_multiplier) { + std::fprintf(stdout, + "psxrecomp: mod selected %dx load acceleration " + "(%d release frames)\n", + g_turbo_load_wall_multiplier, g_turbo_load_release_frames); + } else { + std::fprintf(stdout, + "psxrecomp: mod selected uncapped load acceleration " + "(%d release frames)\n", + g_turbo_load_release_frames); + } + } + }; + start_mod_session(net_cfg.enabled); /* Re-apply the resolved language to the translation layer. text_xlate_init * (at config load) only saw the game.toml default; this folds in the @@ -14785,17 +14806,7 @@ int main(int argc, char** argv) { std::string bios_path_str = resolved_bios.string(); std::string memcard_dir_str = memcard_dir.string(); - /* A disc-patching mod builds a private patched image; mount that instead of - * the stock disc, leaving the user's original untouched (master behaviour). */ - const std::filesystem::path& mod_disc = - PSXRecompV4::mod_runtime_effective_disc_path(); - std::string disc_path_str = - (mod_disc.empty() ? resolved_disc : mod_disc).string(); - if (!mod_disc.empty()) { - std::fprintf(stdout, - "psxrecomp: stock disc remains %s; mounting private mod cache %s\n", - resolved_disc.string().c_str(), mod_disc.string().c_str()); - } + std::string disc_path_str = session_disc_path(resolved_disc); session_reboot: /* Rematch after lobby soft-return re-enters here with updated net_cfg. */ @@ -16361,14 +16372,10 @@ soft_return_lobby: player_device[i]) <= 1) { player_device[i] = "gamepad"; } - /* Same resolution as the first launcher-exit path, and the - * mod-override arm matters HERE specifically: `goto - * session_reboot` re-enters the emulator BELOW the block - * that applies g_mod_controller_mode_override, so a soft - * return from the lobby never re-runs it. Before this - * helper existed, an override survived a rematch only - * because it round-tripped through ls.pad_mode[]; a bare - * lock clamp here would have silently dropped it. */ + /* Same resolution as the first launcher-exit path. The + * override here is the previous session's; the rematch's + * start_mod_session() below clears it, re-runs activation + * and applies the new session's override over this. */ player_mode[i] = PSXRecompV4::resolve_player_mode_after_launcher( ls.pad_mode[i], ctrl_lock_mode, @@ -16537,7 +16544,9 @@ soft_return_lobby: * clobber whatever the Fast Loading / CD Speed / Tweaks plugins * decided with a stale pre-activation value — turning a player's * enabled mod silently back off on the first in-game Apply. The - * offered flags are false for both, so leave both globals alone. */ + * offered flags are false for both, so leave both globals alone; + * the rematch's start_mod_session() below resets and re-decides + * both. */ if (skip_fmv_offered) g_auto_skip_fmv = ls.auto_skip_fmv ? 1 : 0; if (turbo_loads_offered) g_turbo_loads_enabled = ls.turbo_loads ? 1 : 0; g_fullscreen = ls.fullscreen != 0; @@ -16648,24 +16657,24 @@ soft_return_lobby: } } /* `goto session_reboot` re-enters below the first-boot session - * block, so redo its mod-owned presentation reset here, after the - * commit/clear above (which also emptied the function-entry hook - * table; only activation rebuilds it). Only a netplay match - * returns here, so the reachable leak is the Fit and fixed aspect - * its local viewport set. The launcher round-trips the previous - * session's aspect through ls.aspect_index; widescreen is - * mod-owned on PSX, so the Settings aspect is the 4:3 clamp - * applied at startup. Unlike the first-boot block, this path does - * not run activation or the controller, load and disc-speed - * resets: an offline rematch with mods applies the plan's patches - * and runs its VBlank callbacks without activation, and no - * function-entry hook runs. */ + * block, so run the same session start here, after the + * commit/clear above: the controller, load and disc-speed resets, + * the mod-owned presentation reset, activation (which rebuilds the + * function-entry hook table the commit/clear emptied), then what + * activation chose. A netplay rematch has no plan, so it stays + * vanilla. Only a netplay match returns here, so the reachable + * leak the reset closes is the Fit and fixed aspect its local + * viewport set. The launcher round-trips the previous session's + * aspect through ls.aspect_index; widescreen is mod-owned on PSX, + * so re-apply the Settings 4:3 clamp from startup first, where a + * plugin's activation or the netplay local viewport can still + * replace it. */ if (!ws_offered) { g_video_aspect_num = 4; g_video_aspect_den = 3; } - reset_mod_owned_presentation(); - apply_netplay_local_viewport_aspect(net_cfg.enabled); + start_mod_session(net_cfg.enabled); + disc_path_str = session_disc_path(resolved_disc); std::printf("psxrecomp: rematch from lobby (netplay=%d)\n", net_cfg.enabled ? 1 : 0); std::fflush(stdout); diff --git a/runtime/tests/test_launcher_pad_mode_resolve.cpp b/runtime/tests/test_launcher_pad_mode_resolve.cpp index c638330c..43b2ec66 100644 --- a/runtime/tests/test_launcher_pad_mode_resolve.cpp +++ b/runtime/tests/test_launcher_pad_mode_resolve.cpp @@ -14,10 +14,11 @@ // 2. An ACTIVE mod controller-mode override outranks the lock. The first // version of the clamp was an unconditional `if (lock) mode = locked`, // which is correct for (1) and silently wrong here: `goto session_reboot` -// re-enters the emulator BELOW the block that applies -// g_mod_controller_mode_override, so a soft return from the lobby never -// re-runs it. The override used to survive a rematch only because it -// round-tripped through ls.pad_mode[]; the clamp cut that path. +// re-enters the emulator BELOW the first-boot block that applied +// g_mod_controller_mode_override, so a soft return from the lobby did not +// re-run it (the rematch now runs start_mod_session() itself). The +// override survived a rematch only because it round-tripped through +// ls.pad_mode[]; the clamp cut that path. // // 3. Whatever this returns is what gets persisted (seed.p_mode[] / // us.p_mode[]), so a mode the game does not support must never come out of diff --git a/runtime/tests/test_launcher_pad_mode_wiring.py b/runtime/tests/test_launcher_pad_mode_wiring.py index 40615567..15595f1c 100644 --- a/runtime/tests/test_launcher_pad_mode_wiring.py +++ b/runtime/tests/test_launcher_pad_mode_wiring.py @@ -127,10 +127,12 @@ assert calls[1] < MAIN.index(PERSIST_US), ( ) # ---- the mod override still cannot be lost across a soft return ---------- -# `goto session_reboot` re-enters the emulator BELOW the block that applies -# g_mod_controller_mode_override, so the soft-return readback is the only place -# an active override can be re-asserted. That is precisely why the resolution -# helper takes it as an argument instead of the clamp being unconditional. +# `goto session_reboot` re-enters the emulator BELOW the first-boot block, so +# the rematch calls start_mod_session() itself: it clears the override, +# re-runs activation and applies the new session's override after the +# soft-return readback. The readback still passes the live override so the +# resolution helper never clamps an active one away, which is why it takes it +# as an argument instead of the clamp being unconditional. APPLY = "player_mode[i] = g_mod_controller_mode_override[i];" assert APPLY in MAIN, "the mod controller-mode override apply block is gone" assert "goto session_reboot;" in MAIN diff --git a/runtime/tests/test_mod_load_acceleration.py b/runtime/tests/test_mod_load_acceleration.py index cc56a95c..026c0fdd 100644 --- a/runtime/tests/test_mod_load_acceleration.py +++ b/runtime/tests/test_mod_load_acceleration.py @@ -54,12 +54,14 @@ assert 'f << "turbo_loads = "' not in CONFIG_CPP assert "if (turbo_loads_offered) g_turbo_loads_enabled = ls.turbo_loads ? 1 : 0;" in MAIN assert "if (skip_fmv_offered) g_auto_skip_fmv = ls.auto_skip_fmv ? 1 : 0;" in MAIN +# These run inside main.cpp's start_mod_session(), which every session start +# (first boot and lobby rematch) calls, hence the lambda-body indentation. reset = """g_mod_load_wall_multiplier = -1; - g_mod_load_release_frames = -1;""" + g_mod_load_release_frames = -1;""" disc_reset = """g_mod_disc_speed_divisor = -1; - g_mod_disc_instant_rate = -1;""" + g_mod_disc_instant_rate = -1;""" disable_mod_owned_baseline = """if (!turbo_loads_offered) - g_turbo_loads_enabled = 0;""" + g_turbo_loads_enabled = 0;""" activate = "mod_runtime_activate_plugins();" apply = "if (g_mod_load_wall_multiplier >= 0) {" assert reset in MAIN diff --git a/runtime/tests/test_mod_owned_display_controls.py b/runtime/tests/test_mod_owned_display_controls.py index ddfb4f9e..ba78ad03 100644 --- a/runtime/tests/test_mod_owned_display_controls.py +++ b/runtime/tests/test_mod_owned_display_controls.py @@ -45,8 +45,8 @@ assert MAIN.index("g_auto_skip_fmv = 0;") < MAIN.index("mod_runtime_activate_plu # Session hygiene: mod-owned presentation state must not outlive the session # that set it. The one in-process second session is the lobby rematch after a -# netplay match; it jumps past the first-boot block, so the reset runs on both -# paths. The reachable leak is the netplay local viewport's Fit/aspect into an +# netplay match; it jumps past the first-boot block, so both paths call the +# same session start, which resets before activating. The reachable leak is the netplay local viewport's Fit/aspect into an # offline rematch; the rest of the list is defensive. The first-call capture # itself is exercised by mod_session_baseline_test (behavioural), which cannot # see main.cpp; this guard pins main.cpp's glue to that helper (the baseline it @@ -162,34 +162,63 @@ assert set_bezel.index("if (s_bezel_tex) { glDeleteTextures(1, &s_bezel_tex); s_ "gl_renderer_set_bezel(NULL, ...) must delete and forget the texture" assert "if (!s_bezel_tex || ww <= 0 || wh <= 0) return;" in GL -# First-boot session block: reset immediately before activation. -assert "reset_mod_owned_presentation();\n mod_runtime_activate_plugins();" in MAIN, \ - "reset must run immediately before mod activation" +# Session start: one sequence for every session, after its commit or netplay +# clear. It clears the controller, load and disc-speed choices, resets +# mod-owned presentation state, activates, then applies what activation chose. +session_start = MAIN.index("auto start_mod_session = [&](bool netplay) {") +session = MAIN[session_start:MAIN.index("\n };\n", session_start)] +order = [session.index(step) for step in ( + "g_mod_controller_mode_override.fill(-1);", + "policy = ModControllerPresentationPolicy{};", + "g_mod_load_wall_multiplier = -1;", + "g_mod_disc_speed_divisor = -1;", + "g_mod_disc_instant_rate = -1;", + "g_turbo_load_wall_multiplier = 0;", + "reset_mod_owned_presentation();\n mod_runtime_activate_plugins();", + "apply_netplay_local_viewport_aspect(netplay);", + "player_mode[i] = g_mod_controller_mode_override[i];", + "g_turbo_load_wall_multiplier = g_mod_load_wall_multiplier;", +)] +assert order == sorted(order), \ + "session start must reset, then activate, then apply what activation chose" +# Nothing activates or resets outside that sequence. +assert MAIN.count("mod_runtime_activate_plugins();") == 1, \ + "plugins must activate only in start_mod_session" +assert MAIN.count("reset_mod_owned_presentation();") == 1, \ + "the mod-owned reset must run only in start_mod_session" + +# First boot: commit (or netplay clear), then the session start, then the +# disc the plan selects, all above session_reboot. +reboot = MAIN.index("\nsession_reboot:\n") +first_commit = MAIN.index("mod_runtime_commit(resolved_disc, &mod_error)") +first_start = MAIN.index("start_mod_session(net_cfg.enabled);") +first_disc = MAIN.index("std::string disc_path_str = session_disc_path(resolved_disc);") +assert first_commit < session_start < first_start < first_disc < reboot, \ + "first boot must commit, start the session, then mount the plan's disc" # Soft return (rematch) re-enters below that block via `goto session_reboot`, -# so the rematch path must reset too, after its commit / netplay clear. +# so the rematch path runs the same session start after its commit / netplay +# clear, and mounts the disc the new plan selects. rematch = MAIN[MAIN.index('"psxrecomp: cannot clear mods for netplay "'):] rematch = rematch[:rematch.index("goto session_reboot;")] commit = rematch.index("mod_runtime_commit(resolved_disc,") -reset_at = rematch.index("reset_mod_owned_presentation();") -viewport = rematch.index("apply_netplay_local_viewport_aspect(net_cfg.enabled);") -assert commit < reset_at < viewport, \ - "rematch must reset mod-owned state after its commit, before netplay aspect" -# If the rematch path ever activates plugins, the reset must come first, or it -# would undo what activation just set. -if "mod_runtime_activate_plugins();" in rematch: - assert reset_at < rematch.index("mod_runtime_activate_plugins();"), \ - "rematch must reset mod-owned state before activation" +rematch_session = rematch.index("start_mod_session(net_cfg.enabled);") +rematch_disc = rematch.index("disc_path_str = session_disc_path(resolved_disc);") +assert commit < rematch_session < rematch_disc, \ + "rematch must commit, start the session, then mount the plan's disc" +assert MAIN.count("start_mod_session(net_cfg.enabled);") == 2, \ + "exactly the first boot and the rematch start a session" # Rematch 4:3 re-clamp: the launcher round-trips the previous match's aspect # (16:9/21:9 from the netplay local viewport) through ls.aspect_index. While # widescreen is mod-owned the Settings aspect is 4:3, so the rematch path -# re-applies that clamp after the launcher, before the local viewport. +# re-applies that clamp after the launcher, before the session start, where +# a plugin's activation or the netplay local viewport can still replace it. clamp = "if (!ws_offered) {\n g_video_aspect_num = 4;\n g_video_aspect_den = 3;\n }" assert rematch.count(clamp) == 1, "rematch must re-clamp the aspect to 4:3" clamp_at = rematch.index(clamp) -assert commit < clamp_at < viewport, \ - "4:3 re-clamp must follow the commit and precede the netplay local viewport" +assert commit < clamp_at < rematch_session, \ + "4:3 re-clamp must follow the commit and precede the session start" rematch_start = MAIN.index('"psxrecomp: cannot clear mods for netplay "') assert MAIN.rfind("case 1: g_video_aspect_num = 16; g_video_aspect_den = 9; break;", 0, rematch_start) > MAIN.index("soft_return_lobby:"), \ diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 07498cce..49791598 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -393,6 +393,27 @@ int main() { check(active_entry_hits == 4 && disabled_entry_hits == 0 && unselected_entry_hits == 0, "re-activation restores exactly the active plan's hooks"); + /* The lobby rematch, in the order main.cpp's start_mod_session() drives + * it: a netplay match (plan cleared, then activated) and an offline + * rematch that commits and activates exactly like a first boot. */ + check(PSXRecompV4::mod_runtime_clear_for_netplay(&error), error.c_str()); + mod_runtime_activate_plugins(); + mod_runtime_on_vblank(); + psx_mod_function_entry(&entry_cpu, 0x80003000u); + check(activation_calls == 2 && plugin_calls == 1 && + g_psx_mod_function_entry_hooks == 0 && active_entry_hits == 4, + "a netplay session must stay vanilla: no activation, VBlank or hook"); + check(PSXRecompV4::mod_runtime_commit(cue_path, &error), error.c_str()); + mod_runtime_activate_plugins(); + check(activation_calls == 3, + "an offline rematch after netplay must run activation callbacks"); + mod_runtime_on_vblank(); + check(plugin_calls == 2, + "an offline rematch after netplay must run VBlank plugins"); + psx_mod_function_entry(&entry_cpu, 0x80003000u); + check(g_psx_mod_function_entry_hooks == 1 && active_entry_hits == 5 && + disabled_entry_hits == 0 && unselected_entry_hits == 0, + "an offline rematch after netplay must arm exactly its plan's hooks"); ram[0x1000] = 1; ram[0x1001] = 2; ram[0x1002] = 3; ram[0x1003] = 4; ram[0x1100] = 0; ram[0x1101] = 0;