diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 2cbf7107..4264deaa 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -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) diff --git a/runtime/include/mod_session_baseline.h b/runtime/include/mod_session_baseline.h new file mode 100644 index 00000000..ff00b873 --- /dev/null +++ b/runtime/include/mod_session_baseline.h @@ -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 diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 18eb5c97..716829f7 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -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); diff --git a/runtime/tests/test_mod_owned_display_controls.py b/runtime/tests/test_mod_owned_display_controls.py index 54dc59bb..369c5579 100644 --- a/runtime/tests/test_mod_owned_display_controls.py +++ b/runtime/tests/test_mod_owned_display_controls.py @@ -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") diff --git a/runtime/tests/test_mod_session_baseline.c b/runtime/tests/test_mod_session_baseline.c new file mode 100644 index 00000000..777a9045 --- /dev/null +++ b/runtime/tests/test_mod_session_baseline.c @@ -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 + +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; +}