Reset mod-owned presentation state at every session start

The lobby rematch is the only in-process second session: an offline
session ends the process, and only a netplay match launched from the
lobby soft-returns (netplay_soft_exit -> psx_request_return_to_lobby).
The rematch path re-enters via goto session_reboot, below the first-boot
session block, so it reset no mod-owned state at all. The reachable leak:
a match with [netplay] local_viewport_aspect = "adaptive" leaves Fit
(g_ws_adaptive_view) on, and an offline rematch then runs adaptive
widescreen. The launcher also round-trips the match's fixed aspect
(16:9/21:9 from the local viewport) through ls.aspect_index.

reset_mod_owned_presentation() runs immediately before activation in the
first-boot session block (absorbing the existing blend reset) and in the
rematch path after its commit or netplay clear. Every call clears Fit, the
world and retained scene predicates, the adaptive backdrop preload, bezel
artwork, the blend mode and native VBlank pacing. Later calls also restore
vsync, frame interpolation and its rate, and automatic FMV skipping to
their first-session values, and clear the 8 MiB RAM request (memory_init()
re-latches it at session_reboot) and the texture-bank resolver and
batching flag. Netplay clears the plan, so no plugin has activated before
a rematch; everything past Fit is defensive. The renderer and fixed aspect
are launcher controls and process-lifetime allocations are left alone
(documented in MOD_PACKAGES.md).

The first-call capture lives in mod_session_baseline.h: its first call
records the pre-activation values (settings.toml, PSX_VSYNC, launcher) and
writes nothing back, so the first session, and every run that never
soft-returns, is unchanged. A static_assert keeps the restore honest: it
is only valid while interpolation and Skip FMVs have no launcher control.
Function-entry hooks need nothing here: the commit or netplay clear
empties the hook table and only activation rebuilds it.

The rematch path also re-applies the Settings aspect clamp (4:3 while
widescreen is mod-owned) before apply_netplay_local_viewport_aspect, so a
netplay rematch still gets its local viewport. The first-boot path keeps
the launcher/lobby aspect as before.

Not changed here: the rematch path still skips activation and the
controller, load and disc-speed resets of the first-boot block, so an
offline rematch with mods enabled applies the plan's patches and runs its
VBlank callbacks without activation.

Tests: mod_session_baseline_test (new, CTest) drives the first-call
capture and later restore with vsync on and off; it fails if the first
call writes anything. test_mod_owned_display_controls.py guards the
reset list, both call sites, reset-before-activation on the rematch path
and the rematch 4:3 re-clamp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Shokunin
2026-09-28 16:26:33 -07:00
parent 16382d22db
commit a87277bdad
5 changed files with 345 additions and 1 deletions
+5
View File
@@ -29,6 +29,11 @@ if(BUILD_TESTING)
add_executable(mod_texture_banks_test tests/test_mod_texture_banks.c src/mod_texture_banks.c)
target_include_directories(mod_texture_banks_test PRIVATE include)
add_test(NAME mod_texture_banks_test COMMAND mod_texture_banks_test)
# First-call capture / later-call restore behind main.cpp's
# reset_mod_owned_presentation() (header-only, no runtime link).
add_executable(mod_session_baseline_test tests/test_mod_session_baseline.c)
target_include_directories(mod_session_baseline_test PRIVATE include)
add_test(NAME mod_session_baseline_test COMMAND mod_session_baseline_test)
if(NOT MSVC)
add_executable(ws_world_scene_test tests/test_ws_world_scene.c
src/psx_video_timing.c)
+58
View File
@@ -0,0 +1,58 @@
#ifndef PSXRECOMP_MOD_SESSION_BASELINE_H
#define PSXRECOMP_MOD_SESSION_BASELINE_H
/*
* First-call capture / later-call restore for the scalar host settings a
* trusted plugin's psx_mod_* setters can change and no launcher control owns
* (vsync, frame interpolation and its rate, automatic FMV skipping, and the
* frame periods native VBlank pacing overrides). reset_mod_owned_presentation()
* in main.cpp runs this at every session start; it lives here so a unit test
* (runtime/tests/test_mod_session_baseline.c) drives the same code.
*
* The first call records the live values -- settings.toml, environment
* overrides such as PSX_VSYNC, and the launcher are resolved by then -- and
* writes nothing back, so the first session keeps exactly what the player
* configured. Later calls write that record back over the live values.
*/
typedef struct PSXModSessionScalars {
int video_vsync;
int frame_interpolation;
int frame_interpolation_fps;
int auto_skip_fmv;
double guest_frame_period_ms;
double frame_period_ms;
} PSXModSessionScalars;
typedef struct PSXModSessionBaseline {
int captured;
PSXModSessionScalars values;
} PSXModSessionBaseline;
/*
* native_vblank_forced: psx_mod_set_native_vblank_rate() owns the frame
* periods. Only then are they restored; otherwise the video-standard follower
* owns them (the guest's GP1(08h) mode), and a restore would undo a PAL/NTSC
* switch. Returns 1 when this call captured (nothing written), 0 when it
* restored.
*/
static inline int psx_mod_session_baseline_apply(PSXModSessionBaseline* base,
PSXModSessionScalars* live,
int native_vblank_forced) {
if (!base->captured) {
base->captured = 1;
base->values = *live;
return 1;
}
live->video_vsync = base->values.video_vsync;
live->frame_interpolation = base->values.frame_interpolation;
live->frame_interpolation_fps = base->values.frame_interpolation_fps;
live->auto_skip_fmv = base->values.auto_skip_fmv;
if (native_vblank_forced) {
live->guest_frame_period_ms = base->values.guest_frame_period_ms;
live->frame_period_ms = base->values.frame_period_ms;
}
return 0;
}
#endif
+97 -1
View File
@@ -84,6 +84,7 @@ extern "C" void psx_event_step_conservative_env_init(void);
#include "launcher_device.h"
#include "game_options.h"
#include "mod_plugins.h"
#include "mod_session_baseline.h"
#include "mod_runtime.h"
#include "crc32.h"
#include "disc_identity.h"
@@ -216,6 +217,8 @@ extern "C" {
/* memory.c */
extern "C" void memory_init(const char* bios_path);
/* psx_ram_geometry.c: drop a plugin's 8 MiB request before the next boot. */
extern "C" void psx_ram_reset_size_request(void);
extern "C" void memory_set_sr_ptr(const uint32_t *p);
/* interrupts.c */
extern "C" void psx_irq_set_cause_ptr(uint32_t *p);
@@ -1397,6 +1400,76 @@ extern "C" int psx_mod_set_adaptive_display_aspect(
return 1;
}
/*
* 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.
*
* 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
* a netplay match launched from the lobby soft-returns. Netplay clears the mod
* plan, so no plugin activated before a rematch. What can leak today is the
* Fit and fixed aspect apply_netplay_local_viewport_aspect() set, into an
* offline rematch. The rest is defensive, for the state listed here only; see
* docs/MOD_PACKAGES.md "Session starts" for what is exempt and why (the
* 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.
*
* 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
* session -- every run that never soft-returns -- behaves exactly as before.
* Later calls restore those scalars and also clear the 8 MiB RAM request
* (memory_init() at session_reboot re-latches it) and the texture-bank
* resolver and batching flag, which a plugin sets in activation.
*/
static PSXModSessionBaseline g_mod_owned_baseline;
static void reset_mod_owned_presentation(void) {
PSXModSessionScalars live;
live.video_vsync = g_video_vsync;
live.frame_interpolation = g_frame_interpolation;
live.frame_interpolation_fps = g_frame_interpolation_fps;
live.auto_skip_fmv = g_auto_skip_fmv;
live.guest_frame_period_ms = g_guest_frame_period_ms;
live.frame_period_ms = g_frame_period_ms;
/* psx_mod_set_frame_interpolation / _native_vblank_rate force vsync off;
* _auto_skip_fmv changes guest-visible FMV timing. */
const int first = psx_mod_session_baseline_apply(
&g_mod_owned_baseline, &live, g_mod_native_vblank_rate ? 1 : 0);
g_video_vsync = live.video_vsync;
g_frame_interpolation = live.frame_interpolation;
g_frame_interpolation_fps = live.frame_interpolation_fps;
g_auto_skip_fmv = live.auto_skip_fmv;
g_guest_frame_period_ms = live.guest_frame_period_ms;
g_frame_period_ms = live.frame_period_ms;
g_mod_native_vblank_rate = false;
g_mod_native_vblank_fps = 0;
if (!first) {
psx_ram_reset_size_request();
psx_mod_set_texture_bank_resolver(nullptr);
psx_mod_set_texture_bank_batching(0);
}
/* Fit / capped resize-driven aspect. The fixed aspect is the launcher's
* (and netplay's) to set, so it is left alone here. */
g_ws_adaptive_view = false;
g_ws_adaptive_max_num = 16;
g_ws_adaptive_max_den = 9;
psx_mod_set_world_scene_predicate(nullptr);
psx_mod_set_retained_scene_predicate(nullptr);
psx_mod_set_adaptive_backdrop_preload(0);
g_bezel_path.clear();
g_frame_interpolation_blend = g_frame_interpolation_blend_default;
}
extern "C" int psx_mod_set_native_vblank_rate(
uint32_t frames_per_second) {
if (frames_per_second != 0 &&
@@ -14653,7 +14726,12 @@ int main(int argc, char** argv) {
g_turbo_load_release_frames = TURBO_LOADS_RELEASE_FRAMES;
if (!turbo_loads_offered)
g_turbo_loads_enabled = 0;
g_frame_interpolation_blend = g_frame_interpolation_blend_default;
/* 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) {
@@ -16574,6 +16652,24 @@ soft_return_lobby:
return 1;
}
}
/* `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. */
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);
std::printf("psxrecomp: rematch from lobby (netplay=%d)\n",
net_cfg.enabled ? 1 : 0);
@@ -42,4 +42,78 @@ for trusted_api in (
assert MAIN.index("g_auto_skip_fmv = 0;") < MAIN.index("mod_runtime_activate_plugins();")
# 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
# offline rematch; the rest of the list is defensive. The first-call capture
# itself is exercised by mod_session_baseline_test (behavioural); this guard
# pins main.cpp to that helper and to the call sites.
helper_start = MAIN.index("static void reset_mod_owned_presentation(void) {")
helper = MAIN[helper_start:MAIN.index("\n}\n", helper_start)]
assert '#include "mod_session_baseline.h"' in MAIN
assert "psx_mod_session_baseline_apply(" in helper, \
"reset must go through the tested first-call capture helper"
for reset in (
"g_video_vsync = live.video_vsync;",
"g_frame_interpolation = live.frame_interpolation;",
"g_frame_interpolation_fps = live.frame_interpolation_fps;",
"g_auto_skip_fmv = live.auto_skip_fmv;",
"g_guest_frame_period_ms = live.guest_frame_period_ms;",
"g_frame_period_ms = live.frame_period_ms;",
"g_mod_native_vblank_rate = false;",
"g_ws_adaptive_view = false;",
"psx_mod_set_world_scene_predicate(nullptr);",
"psx_mod_set_retained_scene_predicate(nullptr);",
"psx_mod_set_adaptive_backdrop_preload(0);",
"g_bezel_path.clear();",
"g_frame_interpolation_blend = g_frame_interpolation_blend_default;",
):
assert reset in helper, f"mod-owned session reset is missing: {reset}"
# Later calls only (the first call changes nothing that is not already at its
# initial value): the 8 MiB RAM request, which memory_init() re-latches at
# session_reboot, and the texture-bank resolver/batching flag.
later = helper[helper.index("if (!first) {"):]
later = later[:later.index("}")]
for reset in (
"psx_ram_reset_size_request();",
"psx_mod_set_texture_bank_resolver(nullptr);",
"psx_mod_set_texture_bank_batching(0);",
):
assert reset in later, f"later-session reset is missing: {reset}"
# 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"
# Soft return (rematch) re-enters below that block via `goto session_reboot`,
# so the rematch path must reset too, after its commit / netplay clear.
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 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.
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"
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:"), \
"the launcher's aspect must be applied before the rematch re-clamp"
print("mod-owned PSX display controls guard passed")
+111
View File
@@ -0,0 +1,111 @@
/* Behaviour of the first-call capture in reset_mod_owned_presentation()
* (main.cpp), through the helper it calls (mod_session_baseline.h).
*
* The default-off guarantee rests on the first call: it runs immediately
* before the first activation, after settings.toml, PSX_VSYNC and the launcher
* have been applied, and must leave every one of those values alone. Later
* calls (the lobby rematch) must put them back after a plugin changed them.
* Each scenario starts from values that differ from the struct's zero state
* and from the runtime's compiled-in defaults, so a first call that restored
* instead of capturing would be caught. */
#include "mod_session_baseline.h"
#include <stdio.h>
static int failures;
static PSXModSessionBaseline k_fresh; /* zero: not yet captured */
static void check(int ok, const char* what) {
if (!ok) {
fprintf(stderr, "FAIL: %s\n", what);
++failures;
}
}
static int same(const PSXModSessionScalars* a, const PSXModSessionScalars* b) {
return a->video_vsync == b->video_vsync &&
a->frame_interpolation == b->frame_interpolation &&
a->frame_interpolation_fps == b->frame_interpolation_fps &&
a->auto_skip_fmv == b->auto_skip_fmv &&
a->guest_frame_period_ms == b->guest_frame_period_ms &&
a->frame_period_ms == b->frame_period_ms;
}
/* A plugin's activation in the session that just ended. */
static void plugin_session(PSXModSessionScalars* live) {
live->video_vsync = 0; /* _frame_interpolation / _native_vblank_rate */
live->frame_interpolation = 1;
live->frame_interpolation_fps = 120;
live->auto_skip_fmv = 1;
live->guest_frame_period_ms = 1000.0 / 120.0;
live->frame_period_ms = 1000.0 / 120.0;
}
static void first_session_keeps(const char* name, PSXModSessionScalars configured) {
char what[160];
PSXModSessionBaseline base = k_fresh;
PSXModSessionScalars live = configured;
snprintf(what, sizeof what, "%s: first call reports a capture", name);
check(psx_mod_session_baseline_apply(&base, &live, 0) == 1, what);
snprintf(what, sizeof what, "%s: first call leaves every setting alone", name);
check(same(&live, &configured), what);
/* A first call that sees native pacing already forced still writes
* nothing: there is no earlier value to go back to. */
{
PSXModSessionBaseline b2 = k_fresh;
PSXModSessionScalars l2 = configured;
snprintf(what, sizeof what, "%s: first call with native pacing", name);
check(psx_mod_session_baseline_apply(&b2, &l2, 1) == 1 &&
same(&l2, &configured), what);
}
/* Rematch after a plugin session: everything comes back. */
plugin_session(&live);
snprintf(what, sizeof what, "%s: later call reports a restore", name);
check(psx_mod_session_baseline_apply(&base, &live, 1) == 0, what);
snprintf(what, sizeof what, "%s: later call restores the configured values", name);
check(same(&live, &configured), what);
/* Without native pacing the frame periods belong to the video-standard
* follower (a PAL/NTSC switch); only the other scalars are restored. */
plugin_session(&live);
live.guest_frame_period_ms = 20.0;
live.frame_period_ms = 20.0;
check(psx_mod_session_baseline_apply(&base, &live, 0) == 0,
"restore without native pacing");
snprintf(what, sizeof what, "%s: periods untouched without native pacing", name);
check(live.guest_frame_period_ms == 20.0 && live.frame_period_ms == 20.0, what);
snprintf(what, sizeof what, "%s: scalars restored without native pacing", name);
check(live.video_vsync == configured.video_vsync &&
live.frame_interpolation == configured.frame_interpolation &&
live.frame_interpolation_fps == configured.frame_interpolation_fps &&
live.auto_skip_fmv == configured.auto_skip_fmv, what);
/* The baseline is the first session's, not the previous session's. */
plugin_session(&live);
(void)psx_mod_session_baseline_apply(&base, &live, 1);
plugin_session(&live);
(void)psx_mod_session_baseline_apply(&base, &live, 1);
snprintf(what, sizeof what, "%s: third session still restores the first baseline", name);
check(same(&live, &configured), what);
}
int main(void) {
/* settings.toml / PSX_VSYNC=1, stock NTSC pacing. */
const PSXModSessionScalars vsync_on = {1, 0, 0, 0, 1000.0 / 59.94, 1000.0 / 59.94};
/* PSX_VSYNC=0, interpolation and Skip FMVs from settings.toml, PAL pacing
* synced to a 50 Hz panel. */
const PSXModSessionScalars vsync_off = {0, 1, 90, 1, 20.0, 20.0};
first_session_keeps("vsync on", vsync_on);
first_session_keeps("vsync off", vsync_off);
if (failures) {
fprintf(stderr, "mod_session_baseline_test: %d failure(s)\n", failures);
return 1;
}
printf("mod_session_baseline_test passed\n");
return 0;
}