Merge pull request #428 from RetroPortingToolKit/fix/rematch-session-start

Run the full session start on the lobby rematch
This commit is contained in:
Matt Stanley
2026-09-29 11:20:45 -07:00
committed by GitHub
7 changed files with 208 additions and 133 deletions
+25 -14
View File
@@ -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
+99 -90
View File
@@ -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);
@@ -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
@@ -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
+5 -3
View File
@@ -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
@@ -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:"), \
+21
View File
@@ -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;