From 7c6419cbcb98d08fbbc77ebf21a3717e675810f6 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:15:09 -0700 Subject: [PATCH 1/9] mods: game-owned netplay function hooks and filters Trusted game code can register entry hooks and consuming filters that run only in a netplay session (psx_game_register_netplay_function_entry / _filter). They live outside the mod package plan, which every online match clears, so a title's netplay support survives it. Offline they are inert unless PSX_GAME_FILTER_OFFLINE_PROBE=1. Reimplemented on master's entry-filter path from the R4 Link Battle branch. Co-Authored-By: Claude Opus 5.5 --- runtime/include/mod_plugins.h | 9 ++++ runtime/src/mod_runtime.cpp | 76 +++++++++++++++++++++++++++++- runtime/tests/test_mod_runtime.cpp | 48 +++++++++++++++++++ 3 files changed, 131 insertions(+), 2 deletions(-) diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index bf4b15b7..975efc9e 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -60,6 +60,15 @@ extern uint32_t g_psx_mod_instruction_hooks; int psx_mod_register_function_filter_plugin( const char* id, uint32_t address, PSXModFunctionFilterCallback callback); int psx_mod_function_entry(struct CPUState* cpu, uint32_t address); +/* Trusted game code (not a mod package) may hook guest functions for netplay + * only. These are independent of the mod package plan, which every online + * match clears, and run only while netplay is active; offline execution stays + * stock. A filter returning nonzero consumes the whole call (PC <- $ra); it + * must supply any guest-visible side effects itself. */ +int psx_game_register_netplay_function_entry( + uint32_t address, PSXModFunctionEntryCallback callback); +int psx_game_register_netplay_function_filter( + uint32_t address, PSXModFunctionFilterCallback filter); /* Complete a guest function from its trusted entry callback after supplying * its full result. Valid only for that callback's CPU. Publishes pc=$ra and * prevents the original body from executing. Nested callbacks have separate diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 8a6315f9..41c7c797 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -5,6 +5,7 @@ #include "iso_reader.h" #include "mod_packages.h" #include "mod_plugins.h" +#include "psx_netplay.h" #include "gpu.h" #include "psx_memory.h" #include "render_pass_projection.h" @@ -163,9 +164,22 @@ inline uint32_t function_entry_key(uint32_t address) { return address & 0x1FFFFFFFu; } +/* Game-owned netplay hooks are compiled into the executable. Keep them apart + * from the user mod plan, which every online match clears, so clearing that + * plan cannot disable the game's own netplay support. */ +std::vector& game_netplay_entry_hooks() { + static std::vector value; + return value; +} + +void update_function_entry_count() { + g_psx_mod_function_entry_hooks = (uint32_t)( + active_function_entry_hooks().size() + game_netplay_entry_hooks().size()); +} + void clear_function_entry_hooks() { active_function_entry_hooks().clear(); - g_psx_mod_function_entry_hooks = 0; + update_function_entry_count(); active_guest_functions().clear(); g_psx_mod_guest_functions = 0; active_instruction_hooks().clear(); @@ -184,7 +198,7 @@ void build_function_entry_hooks(const RuntimeMods& s) { std::stable_sort(table.begin(), table.end(), [](const ActiveFunctionEntryHook& a, const ActiveFunctionEntryHook& b) { return a.key < b.key; }); - g_psx_mod_function_entry_hooks = (uint32_t)table.size(); + update_function_entry_count(); auto& functions = active_guest_functions(); for (const auto& plugin : s.plan.plugins) for (const auto& function : mod_guest_functions(plugin.id)) @@ -1983,6 +1997,8 @@ extern "C" void psx_mod_instruction(CPUState* cpu, uint32_t address, uint32_t in } } +static int game_netplay_function_entry(CPUState* cpu, uint32_t address); + extern "C" int psx_mod_function_entry(CPUState* cpu, uint32_t address) { using namespace PSXRecompV4; if (!g_psx_mod_function_entry_hooks || !cpu) return 0; @@ -2006,6 +2022,62 @@ extern "C" int psx_mod_function_entry(CPUState* cpu, uint32_t address) { return 1; } } + return game_netplay_function_entry(cpu, address); +} + +static int register_game_netplay_hook(uint32_t address, + PSXModFunctionEntryCallback callback, + PSXModFunctionFilterCallback filter) { + using namespace PSXRecompV4; + if (!address || (!callback && !filter)) return 0; + auto& table = game_netplay_entry_hooks(); + const uint32_t key = function_entry_key(address); + for (const auto& hook : table) + if (hook.key == key && hook.callback == callback && hook.filter == filter) return 1; + table.push_back({key, callback, filter, nullptr}); + std::stable_sort(table.begin(), table.end(), + [](const ActiveFunctionEntryHook& a, + const ActiveFunctionEntryHook& b) { return a.key < b.key; }); + update_function_entry_count(); + return 1; +} + +extern "C" int psx_game_register_netplay_function_entry( + uint32_t address, PSXModFunctionEntryCallback callback) { + return callback ? register_game_netplay_hook(address, callback, nullptr) : 0; +} + +extern "C" int psx_game_register_netplay_function_filter( + uint32_t address, PSXModFunctionFilterCallback filter) { + return filter ? register_game_netplay_hook(address, nullptr, filter) : 0; +} + +/* Game netplay hooks run only in a netplay session, after the mod plan's + * hooks (which a match clears). PSX_GAME_FILTER_OFFLINE_PROBE=1 runs them + * offline too, to test a title's netplay path without opening a session. */ +static int game_netplay_function_entry(CPUState* cpu, uint32_t address) { + using namespace PSXRecompV4; + const auto& table = game_netplay_entry_hooks(); + if (table.empty()) return 0; + if (!psx_netplay_active()) { + static int offline_probe = -1; + if (offline_probe < 0) { + const char* e = std::getenv("PSX_GAME_FILTER_OFFLINE_PROBE"); + offline_probe = (e && std::strcmp(e, "1") == 0) ? 1 : 0; + } + if (!offline_probe) return 0; + } + const uint32_t key = function_entry_key(address); + auto it = std::lower_bound( + table.begin(), table.end(), key, + [](const ActiveFunctionEntryHook& hook, uint32_t k) { return hook.key < k; }); + for (; it != table.end() && it->key == key; ++it) { + if (it->callback) it->callback(cpu, address); + if (it->filter && it->filter(cpu, address)) { + cpu->pc = cpu->gpr[31]; + return 1; + } + } return 0; } diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 59a9802a..bf584c79 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -23,6 +23,18 @@ static int failures; static int activation_calls; static int plugin_calls; static int restore_calls; +static int game_netplay_entry_hits; +static int game_netplay_filter_hits; +static int test_netplay_active; +extern "C" int psx_netplay_active(void) { return test_netplay_active; } +static void test_game_netplay_entry(CPUState*, uint32_t) { + game_netplay_entry_hits++; +} +static int test_game_netplay_filter(CPUState* cpu, uint32_t) { + game_netplay_filter_hits++; + cpu->gpr[2] = 0x1234u; + return 1; +} extern "C" uint8_t psx_read_byte(uint32_t address) { return ram[address & 0x1fffffu]; @@ -1141,6 +1153,42 @@ int main() { check(psx_mod_read_disc_file("S0/LEVEL.NSF", nullptr, 0, &bytes) && bytes == 69400576u, "files grown past 64 MiB keep their effective size"); } + /* A compiled game hook survives mod-plan clearing, but runs only online. */ + check(psx_game_register_netplay_function_entry( + 0x80004000u, test_game_netplay_entry) == 1, + "trusted game netplay entry must register"); + check(psx_game_register_netplay_function_entry( + 0x80004000u, test_game_netplay_entry) == 1, + "duplicate game netplay registration must be idempotent"); + check(psx_game_register_netplay_function_filter( + 0x80005000u, test_game_netplay_filter) == 1, + "trusted game netplay filter must register"); + CPUState game_cpu{}; + psx_mod_function_entry(&game_cpu, 0x80004000u); + game_cpu.gpr[31] = 0x80006000u; + check(!psx_mod_function_entry(&game_cpu, 0x80005000u), + "game netplay filter must not consume offline function"); + check(game_netplay_entry_hits == 0, + "game netplay hook must not run offline"); + check(PSXRecompV4::mod_runtime_clear_for_netplay(&error), error.c_str()); + test_netplay_active = 1; + psx_mod_function_entry(&game_cpu, 0xA0004000u); + check(game_netplay_entry_hits == 1 && g_psx_mod_function_entry_hooks == 2, + "game hook must survive netplay mod clear and match code aliases"); + check(psx_mod_function_entry(&game_cpu, 0xA0005000u) == 1 && + game_cpu.pc == 0x80006000u && game_cpu.gpr[2] == 0x1234u && + game_netplay_filter_hits == 1, + "game netplay filter must consume call and resume at return PC"); + psx_mod_function_entry(&game_cpu, 0x80004004u); + check(game_netplay_entry_hits == 1, + "game hook must ignore other addresses"); + test_netplay_active = 0; + psx_mod_function_entry(&game_cpu, 0x80004000u); + check(game_netplay_entry_hits == 1, + "game hook must disarm when netplay stops"); + check(!psx_mod_function_entry(&game_cpu, 0x80005000u) && + game_netplay_filter_hits == 1, + "game filter must disarm when netplay stops"); fs::remove_all(root, ec); if (failures) return 1; std::cout << "mod runtime tests passed\n"; From a2bbf961ec7736b12d96a8e212fcf715274a6e72 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:17:22 -0700 Subject: [PATCH 2/9] netplay: expose published seat pads and seat count; unconfirmed load barrier in both modes - psx_netplay_seat_count() and psx_netplay_sim_pad(seat, out): the pad row last published to SIO for each seat at the current simulation tick, following delay-sync publication and rollback's sealed overrides, for a game hook whose title reads more players than its SIO protocol exposes (R4 Link Battle). Host-only cache, never serialized; republished on every admitted or replayed tick. - Load barrier: while a slower peer is still applying a save, neither mode waits for confirmation from an already-frozen peer (rollback did this already); after mutual ready, both modes resume on a confirmed first tick instead of rollback inventing it, so peers cannot run different inputs from the identical restored state. From the R4 Link Battle branch; its headless input probe is dropped because master already honours debug input on headless peers. Co-Authored-By: Claude Opus 5.5 --- runtime/CMakeLists.txt | 4 ++ runtime/include/netplay_sim_pad_cache.h | 47 +++++++++++++ runtime/include/psx_netplay.h | 10 +++ runtime/src/psx_netplay.c | 77 +++++++++++++++------- runtime/tests/test_netplay_sim_pad_cache.c | 24 +++++++ 5 files changed, 139 insertions(+), 23 deletions(-) create mode 100644 runtime/include/netplay_sim_pad_cache.h create mode 100644 runtime/tests/test_netplay_sim_pad_cache.c diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 22b5d11f..09d8722d 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -43,6 +43,10 @@ if(BUILD_TESTING) src/netplay_exit_reason.c) target_include_directories(netplay_exit_reason_test PRIVATE include) add_test(NAME netplay_exit_reason_test COMMAND netplay_exit_reason_test) + add_executable(netplay_sim_pad_cache_test tests/test_netplay_sim_pad_cache.c) + target_include_directories(netplay_sim_pad_cache_test PRIVATE include) + target_compile_definitions(netplay_sim_pad_cache_test PRIVATE PSX_MAX_PLAYERS=4) + add_test(NAME netplay_sim_pad_cache_test COMMAND netplay_sim_pad_cache_test) add_executable(ws_scene_hold_test tests/test_ws_scene_hold.c) target_include_directories(ws_scene_hold_test PRIVATE include) add_test(NAME ws_scene_hold_test COMMAND ws_scene_hold_test) diff --git a/runtime/include/netplay_sim_pad_cache.h b/runtime/include/netplay_sim_pad_cache.h new file mode 100644 index 00000000..a7af819d --- /dev/null +++ b/runtime/include/netplay_sim_pad_cache.h @@ -0,0 +1,47 @@ +#ifndef NETPLAY_SIM_PAD_CACHE_H +#define NETPLAY_SIM_PAD_CACHE_H + +#include "psx_netplay.h" +#include + +#ifndef PSX_MAX_PLAYERS +#define PSX_MAX_PLAYERS 2 +#endif + +/* Host-only snapshot of the exact normalized seat rows published for one + * simulation tick. Rollback seal publication may overwrite an earlier + * predicted row for that same tick. Never serialize this cache: resimulation + * republishes all rows after loading the guest state. */ +typedef struct PsxNetplaySimPadCache { + PsxNetPad pads[PSX_MAX_PLAYERS + 1]; + uint32_t ticks[PSX_MAX_PLAYERS + 1]; + uint8_t valid[PSX_MAX_PLAYERS + 1]; +} PsxNetplaySimPadCache; + +static inline void psx_netplay_sim_pad_cache_reset(PsxNetplaySimPadCache *cache) +{ + if (cache) memset(cache->valid, 0, sizeof(cache->valid)); +} + +static inline void psx_netplay_sim_pad_cache_publish( + PsxNetplaySimPadCache *cache, int seat, uint32_t tick, + const PsxNetPad *pad) +{ + if (!cache || !pad || seat < 0 || seat > PSX_MAX_PLAYERS) return; + cache->pads[seat] = *pad; + cache->ticks[seat] = tick; + cache->valid[seat] = 1; +} + +static inline int psx_netplay_sim_pad_cache_read( + const PsxNetplaySimPadCache *cache, int seat, int seat_count, + uint32_t tick, PsxNetPad *out) +{ + if (!cache || !out || seat < 0 || seat >= seat_count || + seat > PSX_MAX_PLAYERS || !cache->valid[seat] || + cache->ticks[seat] != tick) return 0; + *out = cache->pads[seat]; + return 1; +} + +#endif diff --git a/runtime/include/psx_netplay.h b/runtime/include/psx_netplay.h index 124d4542..14004a8b 100644 --- a/runtime/include/psx_netplay.h +++ b/runtime/include/psx_netplay.h @@ -122,6 +122,16 @@ int psx_netplay_host_spectates(void); /* Resolved host player index used for local capture. */ int psx_netplay_input_player(void); uint32_t psx_netplay_sim_tick(void); +/* Session seats, including any host gallery seat; zero when offline. */ +int psx_netplay_seat_count(void); +/* Read the pad actually published for one session seat in the current + * simulation tick. This follows the delay-sync publisher and rollback's + * sealed-frame override of predicted history, so a trusted game plugin can + * use the same seat inputs that SIO sees when a title consumes more player + * commands than its SIO protocol exposes. Call on the emulation thread while + * guest code runs; returns 0 when netplay is off, the seat is absent, or no + * pad has been published for this tick. */ +int psx_netplay_sim_pad(int seat, PsxNetPad *out); /* * Snapshot for diagnostic dumps (starvation_dump.jsonl meta, etc.). diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index acbd8f45..d5ce7260 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -3,6 +3,7 @@ #endif #include "psx_netplay.h" +#include "netplay_sim_pad_cache.h" #include "host_time.h" #include "memcard.h" @@ -450,6 +451,13 @@ int psx_netplay_host_spectates(void) { return 0; } int psx_netplay_local_slot(void) { return -1; } int psx_netplay_input_player(void) { return 0; } uint32_t psx_netplay_sim_tick(void) { return 0; } +int psx_netplay_seat_count(void) { return 0; } +int psx_netplay_sim_pad(int seat, PsxNetPad *out) +{ + (void)seat; + (void)out; + return 0; +} int psx_netplay_start(const PsxNetplayConfig *cfg) { (void)cfg; @@ -2326,6 +2334,16 @@ void psx_netplay_pad_trace_dev(int card, int fallback, int sdl_start, } } +/* A game plugin reads the exact seat row last published to SIO for this tick. + * Keep this host-side cache outside savestates: each admitted/replayed tick + * publishes it again, including sealed rollback authority overwrites. */ +static PsxNetplaySimPadCache s_sim_pad_cache; + +static void note_sim_pad(int slot, uint32_t tick, const PsxNetPad *pad) +{ + psx_netplay_sim_pad_cache_publish(&s_sim_pad_cache, slot, tick, pad); +} + static void apply_pad_slot(int slot, const PsxNetPad *pad) { if (slot < 0 || slot >= g_np.slot_count || slot >= NP_SLOT_CAP || !pad) return; @@ -2429,7 +2447,6 @@ static void host_publish(rnet_u32 tick, const RNetInputSample *by_slot, int slot { int i; int n; - (void)tick; (void)ctx; if (!by_slot || slots <= 0) return; n = g_np.slot_count; @@ -2440,6 +2457,7 @@ static void host_publish(rnet_u32 tick, const RNetInputSample *by_slot, int slot PsxNetPad pad; decode_pad(&by_slot[i], &pad); apply_pad_slot(i, &pad); + note_sim_pad(i, tick, &pad); } } @@ -2466,6 +2484,7 @@ static void np_publish_hist_sio(uint32_t tick) continue; netplay_ih_frame_to_pad(&row, &pad); apply_pad_slot(i, &pad); + note_sim_pad(i, tick, &pad); } } @@ -2474,7 +2493,6 @@ static void np_rb_apply_frame_slot(int slot, uint32_t tick, uint16_t buttons, { RNetRbFrame row; PsxNetPad pad; - (void)tick; memset(&row, 0, sizeof(row)); row.tick = tick; row.buttons = buttons; @@ -2485,6 +2503,7 @@ static void np_rb_apply_frame_slot(int slot, uint32_t tick, uint16_t buttons, netplay_ih_frame_to_pad(&row, &pad); force_session_pads_connected(g_np.slot_count); apply_pad_slot(slot, &pad); + note_sim_pad(slot, tick, &pad); } static void np_rb_bind_and_start(void) @@ -3326,6 +3345,20 @@ uint32_t psx_netplay_sim_tick(void) return rnet_session_sim_tick(g_np.session); } +int psx_netplay_seat_count(void) +{ + return psx_netplay_active() ? g_np.slot_count : 0; +} + +int psx_netplay_sim_pad(int seat, PsxNetPad *out) +{ + if (!out || !psx_netplay_active() || seat < 0 || seat >= g_np.slot_count) + return 0; + return psx_netplay_sim_pad_cache_read( + &s_sim_pad_cache, seat, g_np.slot_count, + rnet_session_sim_tick(g_np.session), out); +} + void psx_netplay_stage_local(const PsxNetPad *pad) { /* Host in the gallery: its seat still publishes a row every tick (the @@ -3640,6 +3673,7 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) if (!cfg || !cfg->enabled) return -1; if (g_np.session) psx_netplay_shutdown(); + psx_netplay_sim_pad_cache_reset(&s_sim_pad_cache); slots = cfg->slot_count; if (slots < 2) slots = 2; @@ -4211,6 +4245,7 @@ void psx_netplay_cold_reset(void) } } g_local_pad_prev = 0xFFFFu; + psx_netplay_sim_pad_cache_reset(&s_sim_pad_cache); g_local_pad_have = 0; g_live_trace_prev = 0xFFFFu; g_live_trace_have = 0; @@ -4712,13 +4747,13 @@ static int np_try_admit_gameplay(void) } /* - * §95: rollback LOAD barrier admit — tip + hold-last invent, no INPUT_CONFIRM. - * Delay-sync try_admit wait_confirm hung the slower peer after the faster one - * applied and froze (guest LOADED, host stuck load_applying+wait_confirm). + * LOAD barrier admit — tip + hold-last invent, no INPUT_CONFIRM. + * Confirmed admission can hang the slower peer after the faster one applies + * and freezes, including when a graphical peer is paced behind headless peers. * Load will hard_resync at mutual ready; pads here only need guest cycles for * savestate_poll / one resume tick. */ -static int np_try_admit_load_barrier_rb(void) +static int np_try_admit_load_barrier_unconfirmed(void) { rnet_u32 sim = rnet_session_sim_tick(g_np.session); rnet_u32 wire; @@ -4800,31 +4835,27 @@ int psx_netplay_poll_admit(void) /* Staged load must run guest cycles — bypass starvation latch. ICE xfer * often leaves lead=D-1 and would otherwise block try_admit forever. - * §95: rollback must not use delay-sync confirm here. */ + * Neither netplay mode can wait for confirmation from an already-frozen + * peer during the apply barrier. */ if (g_np.xfer == NP_XFER_LOAD_APPLYING && savestate_pending()) { if (g_np.needs_advance) return 1; - if (g_np.rollback) - return np_try_admit_load_barrier_rb(); - return np_try_admit_gameplay(); + return np_try_admit_load_barrier_unconfirmed(); } - /* Both peers: after mutual ready + sync, stay in LOAD_READY until admit - * succeeds. Dropping the barrier early on the host let it spin on confirm - * with FPS/present already "live". §95: rollback exits via tip invent. */ + /* After mutual ready + sync, stay in LOAD_READY until a confirmed first + * tick is available. Rollback's invented rows are only needed while a + * slower peer is still applying the save; inventing the first resumed + * tick let peers run different inputs from the identical restored state. */ if (g_np.xfer == NP_XFER_LOAD_READY) { if (g_np.load_sync_done && g_np.load_ready_replied && !g_np.needs_advance) { int admitted; - if (g_np.rollback) - admitted = np_try_admit_load_barrier_rb(); - else { - sim = rnet_session_sim_tick(g_np.session); - admitted = rnet_session_try_admit(g_np.session, sim); - if (admitted) - g_np.needs_advance = 1; - else - force_session_pads_connected(g_np.slot_count); - } + sim = rnet_session_sim_tick(g_np.session); + admitted = rnet_session_try_admit(g_np.session, sim); + if (admitted) + g_np.needs_advance = 1; + else + force_session_pads_connected(g_np.slot_count); if (admitted) { g_np.xfer = NP_XFER_NONE; g_np.load_applied_local = 0; diff --git a/runtime/tests/test_netplay_sim_pad_cache.c b/runtime/tests/test_netplay_sim_pad_cache.c new file mode 100644 index 00000000..a0195da5 --- /dev/null +++ b/runtime/tests/test_netplay_sim_pad_cache.c @@ -0,0 +1,24 @@ +#include "netplay_sim_pad_cache.h" +#include +#include + +int main(void) +{ + PsxNetplaySimPadCache cache = {0}; + PsxNetPad predicted = {0xFFFEu, 0x80, 0x80, 0x80, 0x80, 0, 1}; + PsxNetPad sealed = {0xFFF7u, 0x81, 0x80, 0x80, 0x80, 1, 1}; + PsxNetPad out; + memset(&out, 0x5a, sizeof(out)); + assert(!psx_netplay_sim_pad_cache_read(&cache, 2, 4, 17, &out)); + psx_netplay_sim_pad_cache_publish(&cache, 2, 17, &predicted); + assert(psx_netplay_sim_pad_cache_read(&cache, 2, 4, 17, &out)); + assert(memcmp(&out, &predicted, sizeof(out)) == 0); + psx_netplay_sim_pad_cache_publish(&cache, 2, 17, &sealed); + assert(psx_netplay_sim_pad_cache_read(&cache, 2, 4, 17, &out)); + assert(memcmp(&out, &sealed, sizeof(out)) == 0); + assert(!psx_netplay_sim_pad_cache_read(&cache, 2, 4, 18, &out)); + assert(!psx_netplay_sim_pad_cache_read(&cache, 4, 4, 17, &out)); + psx_netplay_sim_pad_cache_reset(&cache); + assert(!psx_netplay_sim_pad_cache_read(&cache, 2, 4, 17, &out)); + return 0; +} From 1db61e768b1ca3106a2d1b9e48e8ed9ef828069c Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:19:47 -0700 Subject: [PATCH 3/9] config: a title default for the offline multitap [controller] multitap = false in game.toml keeps offline play at two standalone pads for a title whose game has no multitap support but whose netplay seats more players (players still sets the netplay seat count). settings.toml [controller] multitap and the launcher toggle still override it. Without the key nothing changes. Co-Authored-By: Claude Opus 5.5 --- recompiler/src/config_loader.cpp | 4 ++++ recompiler/src/config_loader.h | 8 ++++++++ recompiler/tests/recompiler_patch_test.cpp | 11 +++++++++++ runtime/src/main.cpp | 2 ++ 4 files changed, 25 insertions(+) diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 40f2e106..af1136aa 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -896,6 +896,10 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& rt.multitap_port = static_cast(n); rt.has_multitap_port = true; } + if (ct.contains("multitap")) { + rt.multitap_default = toml::find(ct, "multitap"); + rt.has_multitap_default = true; + } if (ct.contains("multitap_analog")) { rt.multitap_analog = toml::find(ct, "multitap_analog"); rt.has_multitap_analog = true; diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index aa7b3e26..3433800e 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -712,6 +712,14 @@ struct RuntimeConfig { bool has_multitap_analog = false; bool multitap_analog = true; + // multitap: title default for the offline multitap (players >= 3). false + // keeps offline play at two standalone pads for a title whose local game + // has no multitap support but whose netplay seats more players (players + // still sets the netplay seat ceiling). settings.toml [controller] + // multitap / the launcher toggle override it. + bool has_multitap_default = false; + bool multitap_default = true; + // legacy_pad_config: per-game pad-protocol compatibility opt-in. false (default) // = the modern DualShock config state machine (proper 0x43 enter/exit, config id // 0xF3 only while in config) — required by MMX6 and the correct default for every diff --git a/recompiler/tests/recompiler_patch_test.cpp b/recompiler/tests/recompiler_patch_test.cpp index bfde820b..563b66f8 100644 --- a/recompiler/tests/recompiler_patch_test.cpp +++ b/recompiler/tests/recompiler_patch_test.cpp @@ -157,6 +157,17 @@ p2_mode = "digital" controller_defaults_config.runtime.default_p2_mode == PSXRecompV4::PAD_MODE_DIGITAL, "parser preserves per-game controller device defaults"); + check(!controller_defaults_config.runtime.has_multitap_default, + "offline multitap keeps the framework default when unset"); + + const auto no_multitap = write_config(root, "no-multitap", R"toml( +[controller] +multitap = false +)toml"); + const auto no_multitap_config = PSXRecompV4::load_game_config(no_multitap); + check(no_multitap_config.runtime.has_multitap_default && + !no_multitap_config.runtime.multitap_default, + "parser preserves a title's offline multitap default"); const auto netplay_viewport = write_config(root, "netplay-viewport", R"toml( [runtime] diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index c78729b9..535ff482 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -14033,6 +14033,8 @@ int main(int argc, char** argv) { game_id = gc.id; game_region = gc.region; game_players = gc.players; + if (gc.runtime.has_multitap_default) + multitap_enabled = gc.runtime.multitap_default; apply_offline_pad_count(game_players, multitap_enabled); game_has_disc_crc = gc.has_disc_crc; game_disc_crc = gc.disc_crc; From 1c43f638bd01b03622e97e9850b71ff17415caab Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:28:32 -0700 Subject: [PATCH 4/9] Regenerate the OpenBIOS emitter stamp Co-Authored-By: Claude Opus 5.5 --- generated/OpenBIOS.emitter.sha | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index 8c2ec8ca..634ce3f9 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -ce9236c0e4e157e673c66063e9327f214ae88286e23fc9714af1b243d4334046 +a1aed31feb7b9107d563141382cdcd840c9a433150bd7e4023fcdceef4a4f688 From c4215396e3dc10a27de3d4761f6b6ce8f808358b Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:05:14 -0700 Subject: [PATCH 5/9] netplay: present a title-chosen local view for each peer A title whose netplay mode draws every seat's view into one frame (so all peers keep identical guest state) can now ask the present path to show only this peer's rectangle of the display: psx_netplay_present_local_view(x, y, w, h), usually picked from psx_netplay_local_slot(). The GL dual-raster present shows it from the internal-resolution surface, the software present crops its staged frame, both at 4:3, and the headless present-image ring records the same rectangle. The request is host-only (never serialized or guest-visible), lapses PSX_NETPLAY_LOCAL_VIEW_HOLD ticks after its last renewal so menus fall back to the full frame, survives rollback replays without moving its stamp backwards, and is ignored offline. It wins over [netplay] local_viewport. Co-Authored-By: Claude Opus 5.5 --- docs/NETPLAY.md | 21 +++++++ runtime/CMakeLists.txt | 3 + runtime/include/netplay_local_view.h | 74 +++++++++++++++++++++++++ runtime/include/psx_netplay.h | 16 ++++++ runtime/src/main.cpp | 69 ++++++++++++++++++++--- runtime/src/psx_netplay.c | 46 +++++++++++++++ runtime/tests/test_netplay_local_view.c | 48 ++++++++++++++++ 7 files changed, 269 insertions(+), 8 deletions(-) create mode 100644 runtime/include/netplay_local_view.h create mode 100644 runtime/tests/test_netplay_local_view.c diff --git a/docs/NETPLAY.md b/docs/NETPLAY.md index a0338656..1f1ea85f 100644 --- a/docs/NETPLAY.md +++ b/docs/NETPLAY.md @@ -141,6 +141,27 @@ at Native is a supported match. Only the GL present surface changes; the 1× software authority, the digests and the rollback snapshots are the same on every peer. Widescreen and frame-rate mods, by contrast, are cleared for netplay. +### Each peer's own view (presentation only) + +Some titles draw every player's view into one frame (split screen, or a link +mode that renders all seats). Online, every peer still renders and digests +that identical frame; only what its window shows changes: + +- `game.toml [netplay] local_viewport = "vertical_split"`: during netplay, seat + 0 or 1 presents its own half of a detected vertical split. +- `psx_netplay_present_local_view(x, y, w, h)` (`psx_netplay.h`): a trusted + game plugin names this peer's rectangle of the display area (guest pixels, + relative to the GP1(05h) display start), usually from + `psx_netplay_local_slot()`. The present path shows that rectangle alone, + scaled to the window at 4:3, from the GL internal-resolution surface (or the + software frame). The request lasts a few simulation ticks + (`PSX_NETPLAY_LOCAL_VIEW_HOLD`), so the plugin renews it every frame its + multi-view screen is up and menus fall back to the full frame by + themselves. It wins over `local_viewport`, never reaches the guest or a + savestate, and is ignored offline. The headless present-image ring records + the same rectangle, which is how a harness checks it. Vulkan, which netplay + replaces with the software present, ignores it. + --- ## Connectivity: ICE, TURN, SFU, LAN diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 09d8722d..c8a15154 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -47,6 +47,9 @@ if(BUILD_TESTING) target_include_directories(netplay_sim_pad_cache_test PRIVATE include) target_compile_definitions(netplay_sim_pad_cache_test PRIVATE PSX_MAX_PLAYERS=4) add_test(NAME netplay_sim_pad_cache_test COMMAND netplay_sim_pad_cache_test) + add_executable(netplay_local_view_test tests/test_netplay_local_view.c) + target_include_directories(netplay_local_view_test PRIVATE include) + add_test(NAME netplay_local_view_test COMMAND netplay_local_view_test) add_executable(ws_scene_hold_test tests/test_ws_scene_hold.c) target_include_directories(ws_scene_hold_test PRIVATE include) add_test(NAME ws_scene_hold_test COMMAND ws_scene_hold_test) diff --git a/runtime/include/netplay_local_view.h b/runtime/include/netplay_local_view.h new file mode 100644 index 00000000..6d433197 --- /dev/null +++ b/runtime/include/netplay_local_view.h @@ -0,0 +1,74 @@ +#ifndef NETPLAY_LOCAL_VIEW_H +#define NETPLAY_LOCAL_VIEW_H + +#include +#include + +/* Host-only record of the display sub-rectangle a title asked the present + * path to show for the local netplay seat (psx_netplay_present_local_view). + * Never serialized and never visible to the guest: every peer still renders + * and digests the identical full frame. A request covers the simulation tick + * it was made on plus PSX_NETPLAY_LOCAL_VIEW_HOLD later ticks, so a title only + * has to renew it while its multi-view screen is up; menus fall back to the + * full frame by themselves. Rollback resimulation repeats ticks up to + * PSX_NETPLAY_LOCAL_VIEW_REPLAY behind the newest request; those never move + * the stamp backwards. A larger step back (a load) restarts it. */ +#define PSX_NETPLAY_LOCAL_VIEW_HOLD 8u +#define PSX_NETPLAY_LOCAL_VIEW_REPLAY 64u + +typedef struct PsxNetplayLocalView { + uint32_t x, y, w, h; + uint32_t tick; + uint8_t valid; +} PsxNetplayLocalView; + +static inline void psx_netplay_local_view_reset(PsxNetplayLocalView *view) +{ + if (view) memset(view, 0, sizeof(*view)); +} + +/* Rejects empty rectangles and anything outside 1024x512 VRAM space. */ +static inline int psx_netplay_local_view_set(PsxNetplayLocalView *view, + uint32_t tick, uint32_t x, + uint32_t y, uint32_t w, + uint32_t h) +{ + if (!view || w == 0u || h == 0u || x >= 1024u || y >= 512u || + w > 1024u - x || h > 512u - y) + return 0; + if (view->valid && (int32_t)(tick - view->tick) < 0 && + view->tick - tick <= PSX_NETPLAY_LOCAL_VIEW_REPLAY) + tick = view->tick; + view->x = x; + view->y = y; + view->w = w; + view->h = h; + view->tick = tick; + view->valid = 1u; + return 1; +} + +/* 1 and the rectangle when a request is still current at `now` and fits a + * display of display_w x display_h; 0 otherwise. */ +static inline int psx_netplay_local_view_get(const PsxNetplayLocalView *view, + uint32_t now, uint32_t display_w, + uint32_t display_h, uint32_t *x, + uint32_t *y, uint32_t *w, + uint32_t *h) +{ + if (!view || !view->valid) + return 0; + const int32_t age = (int32_t)(now - view->tick); + if (age < 0 ? view->tick - now > PSX_NETPLAY_LOCAL_VIEW_REPLAY + : (uint32_t)age > PSX_NETPLAY_LOCAL_VIEW_HOLD) + return 0; + if (view->x + view->w > display_w || view->y + view->h > display_h) + return 0; + if (x) *x = view->x; + if (y) *y = view->y; + if (w) *w = view->w; + if (h) *h = view->h; + return 1; +} + +#endif diff --git a/runtime/include/psx_netplay.h b/runtime/include/psx_netplay.h index 14004a8b..cd078622 100644 --- a/runtime/include/psx_netplay.h +++ b/runtime/include/psx_netplay.h @@ -133,6 +133,22 @@ int psx_netplay_seat_count(void); * pad has been published for this tick. */ int psx_netplay_sim_pad(int seat, PsxNetPad *out); +/* Presentation-only local view. A title whose netplay mode draws every seat's + * view into one frame on every peer (so guest state stays identical) asks the + * present path to show only this peer's view: a rectangle of the display area + * in guest pixels, relative to the GP1(05h) display start. The present path + * scales it to the window at 4:3. Host state only: never serialized, never + * visible to the guest. A request lapses a few simulation ticks after the + * last renewal, so the title renews it every frame its multi-view screen is + * up. Ignored while netplay is off. */ +void psx_netplay_present_local_view(uint32_t x, uint32_t y, + uint32_t w, uint32_t h); +/* 1 and the rectangle while a current request fits a display of + * display_w x display_h. */ +int psx_netplay_local_view(uint32_t display_w, uint32_t display_h, + uint32_t *x, uint32_t *y, + uint32_t *w, uint32_t *h); + /* * Snapshot for diagnostic dumps (starvation_dump.jsonl meta, etc.). * arch_out: "off" | "p2p" | "host_relay" | "server_relay" (never NULL when diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 535ff482..173622d6 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -6050,6 +6050,29 @@ static int crop_present_to_netplay_local_viewport(uint32_t* pixels, return 1; } +/* Title-requested local view (psx_netplay_present_local_view): a seat + * rectangle of the display this peer presents alone, at 4:3. Wins over the + * game.toml vertical-split local viewport. */ +static bool netplay_game_local_view(const GpuDisplayInfo& di, uint32_t* x, + uint32_t* y, uint32_t* w, uint32_t* h) { + if (di.disabled || di.depth24 || di.width == 0 || di.height == 0) + return false; + return psx_netplay_local_view(di.width, di.height, x, y, w, h) != 0; +} + +/* Crop a staged ARGB frame (pitch src_w) to the scaled local-view rectangle + * in place; the result has pitch w * scale. */ +static void crop_present_to_game_local_view(uint32_t* pixels, int src_w, + int scale, uint32_t x, uint32_t y, + uint32_t w, uint32_t h) { + const int cx = (int)x * scale, cy = (int)y * scale; + const int cw = (int)w * scale, ch = (int)h * scale; + for (int row = 0; row < ch; ++row) + memmove(pixels + (size_t)row * (size_t)cw, + pixels + (size_t)(cy + row) * (size_t)src_w + cx, + (size_t)cw * sizeof(uint32_t)); +} + static void netplay_present_gap_stats(uint32_t *p95_out, uint32_t *max_out) { unsigned n = s_present_gaps_n; unsigned idx; @@ -7263,7 +7286,10 @@ static void headless_present_image_ring_capture(void) { const bool fmv_frame = !g_ws_engaged || gpu_ws_present_native_43() != 0; static std::vector buf; int w = 0, h = 0; - if (!fmv_frame && ws_native_wide_active() && gr_wide_supported()) { + uint32_t vx = 0, vy = 0, vw = di.width, vh = di.height; + const bool game_view = netplay_game_local_view(di, &vx, &vy, &vw, &vh); + if (!game_view && !fmv_frame && ws_native_wide_active() && + gr_wide_supported()) { buf.resize((size_t)1024 * 4 * 512 * 4); if (gr_wide_dump_full(buf.data(), (int)buf.size(), &w, &h, (int)di.display_x) > 0 && h >= 512) { @@ -7277,12 +7303,12 @@ static void headless_present_image_ring_capture(void) { } } } - w = (int)di.width; h = (int)di.height; + w = (int)vw; h = (int)vh; buf.resize((size_t)w * h); for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) { - const uint16_t p = gpu_vram_peek((int)(di.display_x + x), - (int)(di.display_y + y)); + const uint16_t p = gpu_vram_peek((int)(di.display_x + vx + x), + (int)(di.display_y + vy + y)); buf[(size_t)y * w + x] = 0xFF000000u | ((uint32_t)(p & 31) << 19) | ((uint32_t)((p >> 5) & 31) << 11) | ((uint32_t)((p >> 10) & 31) << 3); @@ -8046,6 +8072,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { game frame that could not present wide) */ bool depth24_frame = false; bool local_viewport_crop_applied = false; + bool game_view_applied = false; + uint32_t gv_x = 0, gv_y = 0, gv_w = 0, gv_h = 0; if (s_force_present_after_load && g_gl_active) gl_renderer_flush_cpu_uploads(); { @@ -8102,7 +8130,10 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { if (g_gl_active) gl_renderer_set_interpolation_suspended( fmv_frame || mdec_recently_active(2)); - const int local_viewport_slot = netplay_local_viewport_slot(); + const bool game_view = + netplay_game_local_view(di, &gv_x, &gv_y, &gv_w, &gv_h); + const int local_viewport_slot = + game_view ? -1 : netplay_local_viewport_slot(); const bool local_viewport_crop = local_viewport_slot >= 0; bool local_viewport_wide = local_viewport_crop && g_ws_engaged && ws_native_wide_active() && @@ -8118,6 +8149,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * from the displayed buffer's surface. FMV/menu frames stay 4:3. */ bool wide_present = (!fmv_frame && !di.depth24 && g_ws_engaged && ws_native_wide_active() && gr_wide_supported() && + !game_view && (!local_viewport_crop || local_viewport_wide)); if (wide_present) { present_w = local_viewport_wide @@ -8160,6 +8192,14 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * present this peer's half straight from the high-resolution FBO. * The CPU crop below reads the 1x canonical frame, which throws away * internal resolution and smears the proportion-corrected HUD. */ + if (g_gl_active && g_gl_fbo_present && game_view) { + gl_renderer_present_vram((int)(di.display_x + gv_x), + (int)(di.display_y + gv_y), + (int)gv_w, (int)gv_h, + g_video_aa ? 1 : 0, 1); + netplay_note_present(); + return ep; + } if (g_gl_active && g_gl_fbo_present && !di.depth24 && local_viewport_crop && !local_viewport_wide) { const int half = (int)w / 2; @@ -8171,7 +8211,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { return ep; } if (g_gl_active && g_gl_fbo_present && !di.depth24 && - !local_viewport_crop) { + !local_viewport_crop && !game_view) { if (wide_present) { /* GPU-direct native-wide present: blit the displayed buffer's * wide FBO straight to the window (GPU-side, like the canonical @@ -8296,7 +8336,15 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { int present_px_w = (int)present_w * active_scale; int present_px_h = (int)present_h * active_scale; - if (!local_viewport_wide && + if (game_view && !wide_present) { + crop_present_to_game_local_view(sdl_pixel_buf, present_px_w, + active_scale, gv_x, gv_y, + gv_w, gv_h); + present_px_w = (int)gv_w * active_scale; + present_px_h = (int)gv_h * active_scale; + pin_43 = true; + game_view_applied = true; + } else if (!local_viewport_wide && crop_present_to_netplay_local_viewport(sdl_pixel_buf, &present_px_w, present_px_h)) { @@ -8331,7 +8379,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { !g_smooth_60fps.load(std::memory_order_acquire)) { static uint32_t prev_buf[640 * 512]; static uint32_t prev_px = 0; - const uint32_t npx = local_viewport_crop_applied + const uint32_t npx = (local_viewport_crop_applied || + game_view_applied) ? (uint32_t)(present_px_w * present_px_h) : present_w * h; if (npx <= (uint32_t)(640 * 512)) { @@ -8363,6 +8412,10 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { int src_h = (int)present_h * active_scale; if (local_viewport_crop_applied && src_w >= 2) src_w /= 2; + if (game_view_applied) { + src_w = (int)gv_w * active_scale; + src_h = (int)gv_h * active_scale; + } if (g_gl_active) { /* OpenGL present: upload the active display rect and draw a full-screen * quad. Either SwapWindow vsync OR the wall-clock pacer owns timing, diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index d5ce7260..2e86fe17 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -4,6 +4,7 @@ #include "psx_netplay.h" #include "netplay_sim_pad_cache.h" +#include "netplay_local_view.h" #include "host_time.h" #include "memcard.h" @@ -458,6 +459,26 @@ int psx_netplay_sim_pad(int seat, PsxNetPad *out) (void)out; return 0; } +void psx_netplay_present_local_view(uint32_t x, uint32_t y, + uint32_t w, uint32_t h) +{ + (void)x; + (void)y; + (void)w; + (void)h; +} +int psx_netplay_local_view(uint32_t display_w, uint32_t display_h, + uint32_t *x, uint32_t *y, + uint32_t *w, uint32_t *h) +{ + (void)display_w; + (void)display_h; + (void)x; + (void)y; + (void)w; + (void)h; + return 0; +} int psx_netplay_start(const PsxNetplayConfig *cfg) { (void)cfg; @@ -2338,6 +2359,8 @@ void psx_netplay_pad_trace_dev(int card, int fallback, int sdl_start, * Keep this host-side cache outside savestates: each admitted/replayed tick * publishes it again, including sealed rollback authority overwrites. */ static PsxNetplaySimPadCache s_sim_pad_cache; +/* Host-only presentation request; see psx_netplay_present_local_view. */ +static PsxNetplayLocalView s_local_view; static void note_sim_pad(int slot, uint32_t tick, const PsxNetPad *pad) { @@ -3359,6 +3382,27 @@ int psx_netplay_sim_pad(int seat, PsxNetPad *out) rnet_session_sim_tick(g_np.session), out); } +void psx_netplay_present_local_view(uint32_t x, uint32_t y, + uint32_t w, uint32_t h) +{ + if (!psx_netplay_active()) + return; + (void)psx_netplay_local_view_set(&s_local_view, + rnet_session_sim_tick(g_np.session), + x, y, w, h); +} + +int psx_netplay_local_view(uint32_t display_w, uint32_t display_h, + uint32_t *x, uint32_t *y, + uint32_t *w, uint32_t *h) +{ + if (!psx_netplay_active()) + return 0; + return psx_netplay_local_view_get(&s_local_view, + rnet_session_sim_tick(g_np.session), + display_w, display_h, x, y, w, h); +} + void psx_netplay_stage_local(const PsxNetPad *pad) { /* Host in the gallery: its seat still publishes a row every tick (the @@ -3674,6 +3718,7 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) if (!cfg || !cfg->enabled) return -1; if (g_np.session) psx_netplay_shutdown(); psx_netplay_sim_pad_cache_reset(&s_sim_pad_cache); + psx_netplay_local_view_reset(&s_local_view); slots = cfg->slot_count; if (slots < 2) slots = 2; @@ -4246,6 +4291,7 @@ void psx_netplay_cold_reset(void) } g_local_pad_prev = 0xFFFFu; psx_netplay_sim_pad_cache_reset(&s_sim_pad_cache); + psx_netplay_local_view_reset(&s_local_view); g_local_pad_have = 0; g_live_trace_prev = 0xFFFFu; g_live_trace_have = 0; diff --git a/runtime/tests/test_netplay_local_view.c b/runtime/tests/test_netplay_local_view.c new file mode 100644 index 00000000..b5315400 --- /dev/null +++ b/runtime/tests/test_netplay_local_view.c @@ -0,0 +1,48 @@ +#include "netplay_local_view.h" +#undef NDEBUG +#include + +int main(void) +{ + PsxNetplayLocalView view; + uint32_t x = 0, y = 0, w = 0, h = 0; + psx_netplay_local_view_reset(&view); + assert(!psx_netplay_local_view_get(&view, 10, 320, 240, &x, &y, &w, &h)); + + /* Empty and out-of-VRAM rectangles are refused. */ + assert(!psx_netplay_local_view_set(&view, 10, 0, 0, 0, 120)); + assert(!psx_netplay_local_view_set(&view, 10, 1000, 0, 160, 120)); + assert(!view.valid); + + assert(psx_netplay_local_view_set(&view, 100, 160, 120, 160, 120)); + assert(psx_netplay_local_view_get(&view, 100, 320, 240, &x, &y, &w, &h)); + assert(x == 160 && y == 120 && w == 160 && h == 120); + /* Held for PSX_NETPLAY_LOCAL_VIEW_HOLD ticks, then lapses. */ + assert(psx_netplay_local_view_get(&view, 100 + PSX_NETPLAY_LOCAL_VIEW_HOLD, + 320, 240, NULL, NULL, NULL, NULL)); + assert(!psx_netplay_local_view_get(&view, + 101 + PSX_NETPLAY_LOCAL_VIEW_HOLD, + 320, 240, NULL, NULL, NULL, NULL)); + /* A display smaller than the rectangle presents the full frame. */ + assert(!psx_netplay_local_view_get(&view, 100, 256, 240, + NULL, NULL, NULL, NULL)); + + /* Resimulating an older tick renews the rectangle, not the stamp. */ + assert(psx_netplay_local_view_set(&view, 96, 0, 0, 160, 120)); + assert(view.tick == 100); + assert(psx_netplay_local_view_get(&view, 104, 320, 240, &x, &y, &w, &h)); + assert(x == 0 && y == 0); + /* A present slightly behind a resimulated request still uses it. */ + assert(psx_netplay_local_view_get(&view, 98, 320, 240, + NULL, NULL, NULL, NULL)); + /* A load far behind restarts the stamp. */ + assert(psx_netplay_local_view_set(&view, 10, 0, 0, 160, 120)); + assert(view.tick == 10); + assert(!psx_netplay_local_view_get(&view, 200, 320, 240, + NULL, NULL, NULL, NULL)); + + psx_netplay_local_view_reset(&view); + assert(!psx_netplay_local_view_get(&view, 10, 320, 240, + NULL, NULL, NULL, NULL)); + return 0; +} From f379771c93affec0e9d29a59d69efee047193ea4 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:24:17 -0700 Subject: [PATCH 6/9] netplay: let a title present its own sandboxed local view psx_mod_render_local_view() runs a title's draw code in the render-pass sandbox on a forward netplay frame and keeps the drawn display rect in the OpenGL presenter's surface only. The authoritative CPU VRAM, guest RAM, rollback snapshots and digests keep the canonical frame; the presenter shows this peer's own image. A committed image cancels any local-view crop. Co-Authored-By: Claude Opus 5.5 --- docs/NETPLAY.md | 10 ++++ docs/RENDER_PASSES.md | 31 ++++++++++ runtime/include/gpu_gl_renderer.h | 9 +++ runtime/include/mod_plugins.h | 28 +++++++++ runtime/include/psx_netplay.h | 4 ++ runtime/include/render_pass.h | 2 + runtime/src/debug_server.c | 5 ++ runtime/src/gpu_gl_renderer.c | 69 ++++++++++++++++++---- runtime/src/psx_netplay.c | 6 ++ runtime/src/render_pass.c | 68 +++++++++++++++++----- runtime/tests/test_render_pass_abort.c | 6 ++ runtime/tests/test_render_pass_sandbox.c | 74 +++++++++++++++++++++++- 12 files changed, 286 insertions(+), 26 deletions(-) diff --git a/docs/NETPLAY.md b/docs/NETPLAY.md index 1f1ea85f..a6b6229b 100644 --- a/docs/NETPLAY.md +++ b/docs/NETPLAY.md @@ -161,6 +161,16 @@ that identical frame; only what its window shows changes: savestate, and is ignored offline. The headless present-image ring records the same rectangle, which is how a harness checks it. Vulkan, which netplay replaces with the software present, ignores it. +- `psx_mod_render_local_view(cpu, rect, fn, user)` (`mod_plugins.h`): this + peer draws its *own* image of a display rect with the game's code, for + example its seat's single full-screen view, inside the render-pass sandbox + (docs/RENDER_PASSES.md, "Netplay local view"). The machine is restored + afterwards, so the canonical frame stays in the authoritative VRAM, rollback + snapshots and digests; only the OpenGL presenter's surface keeps the image. + A committed image cancels any `psx_netplay_present_local_view` crop + (`psx_netplay_local_view_clear`). Forward netplay frames only; a title falls + back to the crop when `psx_mod_render_local_view_status()` is not ready + (software present, Vulkan, resimulation). --- diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index eb0a1430..bebf1f42 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -221,6 +221,37 @@ the interpreter's active/phase/precise flags, resume latch and pending load its callback's normal return resets. After 8 faults (watchdog or refused writes) passes stay off for the session. +## Netplay local view + +```c +int psx_mod_render_local_view(struct CPUState *cpu, const PSXModRenderPass *rect, + PSXModRenderPassFn fn, void *user); +uint32_t psx_mod_render_local_view_status(void); +``` + +A title whose netplay frame draws every seat's view (so guest state stays +identical on every peer) can give each player their own seat's single view: +at the point where the next flip will show `rect` and the guest has finished +drawing it, `fn` redraws that rect with the game's code (alpha 0) in the same +sandbox as a pass. Everything a pass restores is restored; the difference is +the product. The OpenGL presenter's surface keeps the rect's colour (the +internal-resolution surface and the native-wide band) until the guest draws +there again, while the stencil, raw mirror, CPU VRAM rows, out-of-rect journal +and coherency state are put back. Its draws skip the software raster, so the +authoritative CPU VRAM never sees them. A committed image cancels any +`psx_netplay_present_local_view()` crop. + +It needs a netplay session on a forward frame and the OpenGL dual-raster +presenter (a surface separate from the authoritative VRAM); it does not need +frame interpolation. The status is `SESSION` offline, while resimulating, in +rewind or lockstep replay; `BACKEND` without dual raster or in the +high-resolution window; `NO_PRESENTER` without an OpenGL surface or with +24-bit scanout; plus the usual `FAST_FORWARD`, `DISABLED` and `BUSY`. +`render_pass_stats` counts `local_attempts` and `local_views` and reports +`local_status`. `PSX_RENDER_PASS_VERIFY=1` checks the guest state as for a +pass (the kept rect is the product, so its VRAM comparison is skipped). +R4's online Link Battle (`src/mods/r4_link_netplay.c`) is the worked example. + ## Gates The plan returns 0 in netplay, rollback resimulation, self-check, rewind, diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 0af25dc2..08f4199e 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -103,6 +103,15 @@ uint64_t gl_renderer_pass_backups_reused(void); uint32_t gl_renderer_pass_image_textures(uint64_t *bytes); /* Debug: dump the images of the next `generations` shown frames as PNGs. */ void gl_renderer_pass_dump_arm(const char *dir, int generations); +/* Netplay local view (psx_mod_render_local_view): the VRAM transaction with + * no generation. Available only with a presenter surface separate from the + * authoritative CPU VRAM (dual raster). While it is open, draws reach only + * that surface. end(keep=1) keeps the rect's presented colour (hr surface and + * native-wide band) and restores everything else (stencil, raw mirror, CPU + * rows, out-of-rect journal, coherency state); keep=0 restores all. */ +uint32_t gl_renderer_local_view_unavailable(void); +int gl_renderer_local_view_begin(int x, int y, int w, int h); +int gl_renderer_local_view_end(int keep); /* Stereo shares the VRAM transaction, not the temporal generation/schedule. */ uint32_t gl_renderer_stereo_unavailable(void); int gl_renderer_stereo_begin(int x, int y, int w, int h, int reuse_backup); diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 975efc9e..3edd22cb 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -500,6 +500,34 @@ enum { PSX_MOD_RENDER_PASS_BUSY = 6 }; uint32_t psx_mod_render_pass_status(void); +/* + * Netplay local view: this peer's own image of a display rect, drawn by the + * game's code inside the render-pass sandbox, replaces what the presenter + * shows of that rect. For a title whose netplay frame draws every seat's view + * (so guest state stays identical on every peer) but whose players should + * each see their own seat's single full-screen view. + * + * `fn` runs as a render pass does (frozen guest time, sandboxed stores, the + * watchdog; CPU with the GTE, RAM, scratchpad, devices and the authoritative + * VRAM restored afterwards) and draws into rect->x/y/w/h; alpha_q16 is 0. When + * it returns nonzero the presenter's own copy of the rect keeps the image + * until the guest draws there again: the canonical frame stays in the + * authoritative VRAM, savestates, rollback snapshots and digests. A committed + * image also cancels any psx_netplay_present_local_view() crop. Call it where + * the next flip will show rect and the guest has finished drawing it. + * + * Only in a netplay session on forward frames, with the OpenGL presenter + * keeping a surface separate from the authoritative VRAM (dual raster); never + * while resimulating, in rewind, lockstep replay, fast-forward, inside an + * exception or a pass. psx_mod_render_local_view_status() says why not, with + * the PSX_MOD_RENDER_PASS_* reasons; a title then shows its canonical frame + * (for example its own view's part of it through + * psx_netplay_present_local_view). Returns 1 when the image was committed. + */ +int psx_mod_render_local_view(struct CPUState* cpu, + const PSXModRenderPass* rect, + PSXModRenderPassFn fn, void* user); +uint32_t psx_mod_render_local_view_status(void); /* Simultaneous stereo capture, independent of temporal interpolation. Each * eye starts from the same guest state; CPU/RAM/devices/VRAM are restored * before the other eye and on failure. Publish only after both succeed. diff --git a/runtime/include/psx_netplay.h b/runtime/include/psx_netplay.h index cd078622..223ee4e4 100644 --- a/runtime/include/psx_netplay.h +++ b/runtime/include/psx_netplay.h @@ -143,6 +143,10 @@ int psx_netplay_sim_pad(int seat, PsxNetPad *out); * up. Ignored while netplay is off. */ void psx_netplay_present_local_view(uint32_t x, uint32_t y, uint32_t w, uint32_t h); +/* Drop any current request at once (the full frame is presented again). + * A committed psx_mod_render_local_view() image calls it: the peer's own + * image of the display supersedes a crop of the canonical frame. */ +void psx_netplay_local_view_clear(void); /* 1 and the rectangle while a current request fits a display of * display_w x display_h. */ int psx_netplay_local_view(uint32_t display_w, uint32_t display_h, diff --git a/runtime/include/render_pass.h b/runtime/include/render_pass.h index 5cbb5ffe..7408d968 100644 --- a/runtime/include/render_pass.h +++ b/runtime/include/render_pass.h @@ -43,6 +43,8 @@ typedef struct RenderPassStats { uint64_t argument_refused, status_refused, begin_refused, checkpoint_refused; RenderPassFailure last_failure; /* latched across successes; session reset clears */ uint64_t passes; /* passes run to completion and presented */ + uint64_t local_views; /* psx_mod_render_local_view images committed */ + uint64_t local_attempts; /* psx_mod_render_local_view calls */ uint64_t aborted; /* passes rolled back by a fault */ uint64_t discarded; /* passes whose plugin declined the image */ uint64_t watchdog; /* of which: guest-cycle watchdog overruns */ diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index e4266db4..7573fdcf 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -8282,6 +8282,8 @@ static void handle_render_pass_stats(int id, const char *json) "\"argument_refused\":%llu,\"status_refused\":%llu," "\"begin_refused\":%llu,\"checkpoint_refused\":%llu," "\"spans\":%llu,\"span_failures\":%llu," + "\"local_views\":%llu,\"local_attempts\":%llu," + "\"local_status\":%u," "\"last_failure\":%s,\"last_abort_detail\":\"%s\"," "\"span_fail\":{\"reason\":%u,\"pc\":\"0x%08X\",\"start\":\"0x%08X\"," "\"stop\":\"0x%08X\",\"ra\":\"0x%08X\",\"after\":\"0x%08X\"," @@ -8316,6 +8318,9 @@ static void handle_render_pass_stats(int id, const char *json) (unsigned long long)st.argument_refused, (unsigned long long)st.status_refused, (unsigned long long)st.begin_refused, (unsigned long long)st.checkpoint_refused, (unsigned long long)st.spans, (unsigned long long)st.span_failures, + (unsigned long long)st.local_views, + (unsigned long long)st.local_attempts, + psx_mod_render_local_view_status(), failure_json, abort_detail_json, (unsigned)sf.reason, (unsigned)sf.pc, (unsigned)sf.start_pc, (unsigned)sf.stop_pc, (unsigned)sf.ra, (unsigned)sf.after, diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 8a37e461..b4a89364 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -500,6 +500,11 @@ static uint64_t s_idle_ticks_accum_fwd(uint64_t add); * open, GPU writes are confined to its rect: the scissor is intersected with * it and writes that bypass the scissor are refused and counted. */ static int s_pass_active = 0; +/* The open transaction is a netplay local view (gl_renderer_local_view_*): + * its draws reach only the presenter surface, never the authoritative CPU + * VRAM, and end(keep) keeps the rect's presented colour. */ +static int s_pass_local = 0; +static uint64_t s_local_commits = 0; /* Textures/framebuffers made for passes (pass_make_color_fbo, pass image * slots), and the count when the current pass began: a pass that allocated * is left out of the pass-cost average (render_pass_cost_add). */ @@ -3755,6 +3760,11 @@ static void glb_set_draw_offset(int x,int y) { flush_flat_batch(); flush_tex_bat static void depth24_upload_policy(void); static int cpu_raster_required(void) { depth24_upload_policy(); + /* A netplay local view draws a presentation-only image: the CPU mirror + * keeps the canonical frame, so its draws skip the software raster + * (depth24 scanout still presents from the CPU mirror and keeps it). */ + if (s_pass_active && s_pass_local && s_cpu_auth_dual && !s_depth24_skip_up) + return !s_raster_ok; return !s_raster_ok || cpu_vram_authoritative(); } /* The sub-pixel / perspective override describes exactly one triangle; drop it @@ -6429,13 +6439,16 @@ static void pass_journal_rollback(void) { render_pass_journal_rollback(&s_pj_cpu, s_vram, VRAM_W); } +/* kind: 0 temporal pass, 1 stereo eye, 2 netplay local view. */ static int transaction_begin(int x, int y, int w, int h, int open_gen, - uint32_t period_vblanks, int reuse_backup, int stereo) { + uint32_t period_vblanks, int reuse_backup, int kind) { + const int stereo = kind == 1, local = kind == 2; int S = s_hr_scale, gi, wide, tw, th; PassGen *g; memset(&s_pass_begin_diag, 0, sizeof s_pass_begin_diag); - s_pass_begin_diag.status = stereo ? gl_renderer_stereo_unavailable() - : gl_renderer_pass_unavailable(); + s_pass_begin_diag.status = local ? gl_renderer_local_view_unavailable() + : stereo ? gl_renderer_stereo_unavailable() + : gl_renderer_pass_unavailable(); s_pass_begin_diag.active = s_pass_active; s_pass_begin_diag.open_gen = open_gen; s_pass_begin_diag.hr_scale = S; @@ -6471,7 +6484,9 @@ static int transaction_begin(int x, int y, int w, int h, int open_gen, s_pass_begin_diag.requested_h = th; gi = 1 - s_pgen_cur; g = &s_pgen[gi]; - if (stereo) { + if (local) { + /* No generation: the image stays in the presenter surface. */ + } else if (stereo) { StereoPair *pair = &s_stereo_pair[1 - s_stereo_current]; pair->x = x; pair->y = y; pair->w = w; pair->h = h; } else if (open_gen) { @@ -6558,24 +6573,27 @@ backed_up: if (s_pass_verify) pass_verify_read(&s_pv_hr, &s_pv_hr_cap, &s_pv_raw, &s_pv_raw_cap); s_pj_cpu.n = 0; s_pass_active = 1; + s_pass_local = local; return 1; } -static void transaction_restore(void) { +/* keep_color: a committed netplay local view keeps the rect's presented + * colour (hr surface, native-wide band); everything else is put back. */ +static void transaction_restore_ex(int keep_color) { int S = s_hr_scale, gi = 1 - s_pgen_cur; + const GLbitfield hr_bits = keep_color ? GL_STENCIL_BUFFER_BIT + : GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT; (void)gi; if (!s_pass_active) return; /* Roll the journal, then the rect back. */ pass_journal_rollback(); pass_blit(s_pb_hr_fbo, s_hr_fbo, 0, 0, s_pass_x * S, s_pass_y * S, - s_pass_w * S, s_pass_h * S, - GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + s_pass_w * S, s_pass_h * S, hr_bits); pass_blit(s_pb_raw_fbo, s_raw_fbo, 0, 0, s_pass_x, s_pass_y, s_pass_w, s_pass_h, GL_COLOR_BUFFER_BIT); if (s_pb_wide_src && s_pb_wide_src == pass_wide_fbo_for(s_pass_x)) pass_blit(s_pb_wide_fbo, s_pb_wide_src, 0, 0, 0, s_pass_y * S, - g_wide_w * S, s_pass_h * S, - GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + g_wide_w * S, s_pass_h * S, hr_bits); for (int row = 0; row < s_pass_h; row++) memcpy(s_vram + (size_t)(s_pass_y + row) * VRAM_W + s_pass_x, s_pb_cpu + (size_t)row * s_pass_w, @@ -6592,7 +6610,15 @@ static void transaction_restore(void) { s_last_dx = s_pb_last_dx; s_last_dy = s_pb_last_dy; s_last_dw = s_pb_last_dw; s_last_dh = s_pb_last_dh; s_pass_active = 0; - s_pb_valid = 1; + s_pass_local = 0; + s_pb_valid = !keep_color; + if (keep_color) { + /* The rect now differs from the backup and from the last present. */ + present_dirty_rect(s_pass_x, s_pass_y, s_pass_x + s_pass_w - 1, + s_pass_y + s_pass_h - 1, 1); + s_pv_ok = 1; /* the kept rect is the product, not a leak */ + return; + } if (s_pass_verify) { static uint8_t *after_hr = NULL, *after_raw = NULL; @@ -6606,6 +6632,29 @@ static void transaction_restore(void) { } } +static void transaction_restore(void) { transaction_restore_ex(0); } + +uint32_t gl_renderer_local_view_unavailable(void) { + pass_refusal_init(); + if (!s_ctx || !s_raster_ok || !s_hr_fbo || gpu_display_is_depth24()) + return PSX_MOD_RENDER_PASS_NO_PRESENTER; + /* Only a presenter surface separate from the authoritative VRAM can hold + * a peer's own image; the high-resolution window presents other tiles. */ + if (!s_cpu_auth_dual || s_hiw || s_pass_force_refuse > 0) + return PSX_MOD_RENDER_PASS_BACKEND; + return PSX_MOD_RENDER_PASS_READY; +} +int gl_renderer_local_view_begin(int x, int y, int w, int h) { + return transaction_begin(x, y, w, h, 0, 0, 0, 2); +} +int gl_renderer_local_view_end(int keep) { + if (!s_pass_active || !s_pass_local) return 0; + flush_flat_batch(); flush_tex_batch(); flush_cpu_upload(); + transaction_restore_ex(keep ? 1 : 0); + if (keep) s_local_commits++; + return 1; +} + int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen, uint32_t period, int reuse) { return transaction_begin(x, y, w, h, open_gen, period, reuse, 0); diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index 2e86fe17..79087976 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -467,6 +467,7 @@ void psx_netplay_present_local_view(uint32_t x, uint32_t y, (void)w; (void)h; } +void psx_netplay_local_view_clear(void) {} int psx_netplay_local_view(uint32_t display_w, uint32_t display_h, uint32_t *x, uint32_t *y, uint32_t *w, uint32_t *h) @@ -3392,6 +3393,11 @@ void psx_netplay_present_local_view(uint32_t x, uint32_t y, x, y, w, h); } +void psx_netplay_local_view_clear(void) +{ + psx_netplay_local_view_reset(&s_local_view); +} + int psx_netplay_local_view(uint32_t display_w, uint32_t display_h, uint32_t *x, uint32_t *y, uint32_t *w, uint32_t *h) diff --git a/runtime/src/render_pass.c b/runtime/src/render_pass.c index c197cb6d..0e7eaffe 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -59,6 +59,7 @@ extern void psx_irq_refresh_cause_ip2(void); extern int psx_get_in_exception(void); extern int psx_netplay_active(void); extern int psx_netplay_is_resimulating(void); +extern void psx_netplay_local_view_clear(void); extern int psx_selfcheck_resim_active(void); extern int psx_rewind_is_open(void); extern int psx_presentation_fast_forward(void); @@ -328,17 +329,24 @@ int psx_mod_set_render_pass_flip(uint32_t mode) { return 1; } +/* Transaction kinds: what `eye` holds in render_transaction. */ +#define RP_EYE_PASS (-1) /* temporal pass */ +#define RP_EYE_LOCAL (-2) /* netplay local view */ + /* Everything that must hold before guest code may run frozen, as a - * PSX_MOD_RENDER_PASS_* reason (READY = all of it holds). */ -static uint32_t transaction_status(int stereo) { + * PSX_MOD_RENDER_PASS_* reason (READY = all of it holds). kind: 0 temporal + * pass, 1 stereo, 2 netplay local view (forward netplay frames only). */ +static uint32_t transaction_status(int kind) { uint32_t gl; if (s_stats.disabled) return PSX_MOD_RENDER_PASS_DISABLED; - if (psx_netplay_active() || psx_netplay_is_resimulating() || + if ((kind == 2 ? !psx_netplay_active() : psx_netplay_active()) || + psx_netplay_is_resimulating() || psx_selfcheck_resim_active() || psx_rewind_is_open() || g_ls_mode || g_ls_replay_active) return PSX_MOD_RENDER_PASS_SESSION; if (psx_presentation_fast_forward()) return PSX_MOD_RENDER_PASS_FAST_FORWARD; - gl = stereo ? gl_renderer_stereo_unavailable() : gl_renderer_pass_unavailable(); + gl = kind == 2 ? gl_renderer_local_view_unavailable() + : kind == 1 ? gl_renderer_stereo_unavailable() : gl_renderer_pass_unavailable(); if (gl != PSX_MOD_RENDER_PASS_READY) return gl; if (s_nesting || g_psx_render_pass_active || psx_get_in_exception() || dma_gpu_linked_list_active()) @@ -594,8 +602,13 @@ static void note_fault(const char *what) { } } +static int transaction_kind(int eye) { + return eye >= 0 ? 1 : eye == RP_EYE_LOCAL ? 2 : 0; +} + static int transaction_end(int eye, uint32_t alpha, int keep) { if (eye >= 0) return gl_renderer_stereo_end((uint32_t)eye, keep); + if (eye == RP_EYE_LOCAL) return gl_renderer_local_view_end(keep); gl_renderer_pass_end(alpha, keep); return 1; } @@ -623,12 +636,12 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass } if (!cpu || !pass || !fn || pass->struct_size < sizeof *pass || - pass->w == 0 || pass->h == 0 || (eye < 0 && + pass->w == 0 || pass->h == 0 || (eye == RP_EYE_PASS && (pass->alpha_q16 == 0 || pass->alpha_q16 >= 65536u))) { s_stats.argument_refused++; - return pass_refuse("arguments", transaction_status(eye >= 0)); + return pass_refuse("arguments", transaction_status(transaction_kind(eye))); } - status = transaction_status(eye >= 0); + status = transaction_status(transaction_kind(eye)); if (status != PSX_MOD_RENDER_PASS_READY) { s_stats.status_refused++; return pass_refuse("status", status); @@ -641,18 +654,21 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass s_attempt.guest_cycle = psx_cycle_count; t0 = gl_renderer_perf_ticks(); - open = eye < 0 ? s_open_generation : 0; + open = eye == RP_EYE_PASS ? s_open_generation : 0; { static double inv = 0.0; if (inv == 0.0) inv = 1000.0 / (double)gl_renderer_perf_frequency(); s_ms_per_tick = inv; } - reuse = !open && s_restored_valid && s_restored_plan == s_plan_serial && + reuse = !open && eye != RP_EYE_LOCAL && s_restored_valid && + s_restored_plan == s_plan_serial && s_restored_cycle == psx_cycle_count && s_restored_stores == g_guest_store_count; /* Frame N's own image is captured by the first pass after a plan. */ if (!(eye >= 0 ? gl_renderer_stereo_begin(pass->x, pass->y, pass->w, pass->h, reuse) + : eye == RP_EYE_LOCAL ? gl_renderer_local_view_begin(pass->x, pass->y, + pass->w, pass->h) : gl_renderer_pass_begin(pass->x, pass->y, pass->w, pass->h, open, s_plan_period, reuse))) { gl_renderer_pass_begin_diag(&s_attempt.gl); @@ -667,7 +683,7 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass gl_renderer_pass_begin_diag(&s_attempt.gl); s_stats.checkpoint_refused++; /* Capture the rejection status before rolling the GL transaction back. */ - status = transaction_status(eye >= 0); + status = transaction_status(transaction_kind(eye)); transaction_end(eye, 0, 0); return pass_refuse(s_checkpoint_failure ? s_checkpoint_failure : "checkpoint", status); } @@ -727,7 +743,9 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass checkpoint_restore(cpu); psx_cycle_freeze_end(&s_freeze); s_nesting = 0; - s_restored_valid = 1; + /* A committed local view changed the presented rect: no later pass may + * reuse this backup. */ + s_restored_valid = !(eye == RP_EYE_LOCAL && ok); s_restored_plan = s_plan_serial; s_restored_cycle = psx_cycle_count; s_restored_stores = g_guest_store_count; @@ -759,16 +777,38 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass s_stats.avg_pass_ms = s_stats.avg_pass_ms > 0.0 ? s_stats.avg_pass_ms * 0.9 + ms * 0.1 : ms; } - if (eye < 0) gl_renderer_pass_note_cost(t1 - t0); - if (ok) s_stats.passes++; + if (eye == RP_EYE_PASS) gl_renderer_pass_note_cost(t1 - t0); + if (ok && eye == RP_EYE_LOCAL) { + s_stats.local_views++; + /* The peer's own image supersedes a crop of the canonical frame. */ + psx_netplay_local_view_clear(); + } else if (ok) s_stats.passes++; else if (leaks) note_fault("VRAM write outside the pass rect"); else if (s_stats.watchdog_flag) note_fault("guest-cycle watchdog"); s_stats.watchdog_flag = 0; /* Present anything that fell due while the pass ran. */ - if (eye < 0) gl_renderer_pass_service_presents(); + if (eye == RP_EYE_PASS) gl_renderer_pass_service_presents(); return ok; } +int psx_mod_render_local_view(struct CPUState *cpu, const PSXModRenderPass *rect, + PSXModRenderPassFn fn, void *user) { + PSXModRenderPass r; + s_stats.local_attempts++; + if (!rect || rect->struct_size < sizeof r || + (uint32_t)rect->x + rect->w > 1024u || (uint32_t)rect->y + rect->h > 512u) { + s_stats.argument_refused++; + return pass_refuse("arguments", transaction_status(2)); + } + r = *rect; + r.alpha_q16 = 0; + return render_transaction(cpu, &r, fn, user, RP_EYE_LOCAL); +} + +uint32_t psx_mod_render_local_view_status(void) { + return transaction_status(2); +} + int psx_mod_render_pass(struct CPUState *cpu, const PSXModRenderPass *pass, PSXModRenderPassFn fn, void *user) { return render_transaction(cpu, pass, fn, user, -1); diff --git a/runtime/tests/test_render_pass_abort.c b/runtime/tests/test_render_pass_abort.c index fe416b9b..5e4edd48 100644 --- a/runtime/tests/test_render_pass_abort.c +++ b/runtime/tests/test_render_pass_abort.c @@ -105,6 +105,7 @@ void mdec_snapshot_write(uint8_t *p) { (void)p; } int psx_get_in_exception(void) { return 0; } int psx_netplay_active(void) { return 0; } int psx_netplay_is_resimulating(void) { return 0; } +void psx_netplay_local_view_clear(void) {} int psx_selfcheck_resim_active(void) { return 0; } int psx_selfcheck_enabled(void) { return 0; } int psx_rewind_is_open(void) { return 0; } @@ -192,6 +193,11 @@ int gl_renderer_pass_verify_vram(void) { return 1; } void gl_renderer_pass_note_cost(uint64_t t) { (void)t; } void gl_renderer_pass_service_presents(void) {} uint32_t g_psx_vblank_cycles = 564480u; +uint32_t gl_renderer_local_view_unavailable(void) { return 0; } +int gl_renderer_local_view_begin(int x, int y, int w, int h) { + return gl_renderer_pass_begin(x, y, w, h, 0, 0, 0); +} +int gl_renderer_local_view_end(int keep) { gl_renderer_pass_end(0, keep); return 1; } uint32_t gl_renderer_stereo_unavailable(void) { return 0; } int gl_renderer_stereo_begin(int x, int y, int w, int h, int reuse) { return gl_renderer_pass_begin(x, y, w, h, 0, 0, reuse); diff --git a/runtime/tests/test_render_pass_sandbox.c b/runtime/tests/test_render_pass_sandbox.c index 25422578..909bfd6a 100644 --- a/runtime/tests/test_render_pass_sandbox.c +++ b/runtime/tests/test_render_pass_sandbox.c @@ -154,8 +154,10 @@ uint32_t mdec_snapshot_bytes(void) { return 0; } void mdec_snapshot_write(uint8_t *p) { (void)p; } int psx_get_in_exception(void) { return 0; } -int psx_netplay_active(void) { return 0; } -int psx_netplay_is_resimulating(void) { return 0; } +static int s_netplay, s_resim, s_crop_clears; +int psx_netplay_active(void) { return s_netplay; } +int psx_netplay_is_resimulating(void) { return s_resim; } +void psx_netplay_local_view_clear(void) { s_crop_clears++; } int psx_selfcheck_resim_active(void) { return 0; } int psx_selfcheck_enabled(void) { return 0; } int psx_rewind_is_open(void) { return 0; } @@ -242,6 +244,17 @@ uint32_t gl_renderer_pass_leaks(void) { return 0; } int gl_renderer_pass_verify_vram(void) { return 1; } void gl_renderer_pass_note_cost(uint64_t t) { (void)t; } void gl_renderer_pass_service_presents(void) {} +static uint32_t s_local_status = PSX_MOD_RENDER_PASS_READY; +static int s_local_kept; +uint32_t gl_renderer_local_view_unavailable(void) { return s_local_status; } +int gl_renderer_local_view_begin(int x, int y, int w, int h) { + return gl_renderer_pass_begin(x, y, w, h, 0, 0, 0); +} +int gl_renderer_local_view_end(int keep) { + gl_renderer_pass_end(0, 0); + if (keep) s_local_kept++; + return 1; +} static uint32_t s_stereo_mask; static uint64_t s_stereo_published; static int s_capture_fail_eye = -1; @@ -717,6 +730,62 @@ static void test_stereo(void) { CHECK(!psx_mod_render_stereo(&cpu, &f, pair_draw, NULL), "invalid cadence refused"); } +static int local_draw(CPUState *cpu, void *user, uint32_t alpha) { + (void)user; + CHECK(alpha == 0, "a local view has no phase"); + cpu->gpr[8] = 0xBEEFu; + s_ram[0x456] = 0x11; /* the game's draw code writes RAM */ + psx_advance_cycles(5000u); + return 1; +} + +/* Netplay local view: forward netplay frames only; guest state restored + * exactly; a committed image cancels the crop request. */ +static void test_local_view(void) { + CPUState cpu = {0}; + PSXModRenderPass r; + RenderPassStats st; + uint64_t cycle; + render_pass_reset_session(); s_gl_status = 0; s_begin_ok = 1; + memset(&r, 0, sizeof r); + r.struct_size = sizeof r; + r.y = 240; r.w = 320; r.h = 240; + cpu.gpr[8] = 7; s_ram[0x456] = 0x77; + cycle = psx_cycle_count; + s_netplay = 0; + CHECK(psx_mod_render_local_view_status() == PSX_MOD_RENDER_PASS_SESSION, + "local view refused offline"); + CHECK(!psx_mod_render_local_view(&cpu, &r, never_fn, NULL), "no offline call"); + s_netplay = 1; + CHECK(psx_mod_render_pass_status() == PSX_MOD_RENDER_PASS_SESSION, + "temporal passes stay off in netplay"); + CHECK(psx_mod_render_local_view_status() == PSX_MOD_RENDER_PASS_READY, + "local view ready in netplay"); + s_resim = 1; + CHECK(psx_mod_render_local_view_status() == PSX_MOD_RENDER_PASS_SESSION && + !psx_mod_render_local_view(&cpu, &r, never_fn, NULL), + "no local view while resimulating"); + s_resim = 0; + s_local_status = PSX_MOD_RENDER_PASS_BACKEND; + CHECK(psx_mod_render_local_view_status() == PSX_MOD_RENDER_PASS_BACKEND && + !psx_mod_render_local_view(&cpu, &r, never_fn, NULL), + "no local view without a separate presenter surface"); + s_local_status = PSX_MOD_RENDER_PASS_READY; + s_crop_clears = 0; + CHECK(psx_mod_render_local_view(&cpu, &r, local_draw, NULL) == 1, + "local view committed"); + render_pass_get_stats(&st); + CHECK(st.local_views == 1 && s_local_kept == 1 && s_open_passes == 0 && + st.passes == 0, "one kept local image, transaction closed"); + CHECK(s_crop_clears == 1, "the committed image cancels the crop request"); + CHECK(cpu.gpr[8] == 7 && s_ram[0x456] == 0x77 && psx_cycle_count == cycle, + "guest state restored after the local view"); + r.x = 900; + CHECK(!psx_mod_render_local_view(&cpu, &r, never_fn, NULL), + "rect outside VRAM refused"); + s_netplay = 0; +} + static void test_texture_stream_journal(void) { RenderPassJournal j = {0}; memcpy(s_vram, s_vram0, sizeof s_vram); @@ -744,6 +813,7 @@ int main(void) { test_ram_8mb(); test_journal(); test_stereo(); + test_local_view(); test_texture_stream_journal(); CHECK(s_prec_open == 0, "precision checkpoints balanced"); printf(failures ? "FAILED (%d)\n" : "ALL PASS\n", failures); From 33b842e97f34a7d9fa5ec1d7fcecce4c09c26456 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:17:26 -0700 Subject: [PATCH 7/9] netplay: per-player own-view mods, widescreen only inside the local view A [[plugin]] may declare netplay = "local_view": it only changes what one player sees. A netplay session now keeps the player's own features whose every contribution is such a plugin (no writes, overlays or derived discs) instead of clearing them, without saving state.toml. They activate as usual, but their hooks run only inside psx_mod_render_local_view (psx_mod_local_view_scope) and their vblank/savestate callbacks not at all. With such a plan the widescreen cull margin is the stock 0 in the shared game and this peer's own margin only inside the sandboxed own-view draw; squash is not used. The canonical frame, snapshots and digests stay identical on every peer whatever each player chose, so nothing is negotiated, and each keeps their own aspect. Tests: netplay_view_mods_test (flag, plan filter, parked hooks, no vblank, state.toml untouched), ws_local_view_margin_test (margin scope), render-pass sandbox asserts the scope around a local view. Co-Authored-By: Claude Opus 5.5 --- docs/MOD_PACKAGES.md | 4 +- docs/NETPLAY.md | 43 ++++- runtime/CMakeLists.txt | 39 ++++ runtime/include/gpu.h | 5 + runtime/include/mod_packages.h | 15 ++ runtime/include/mod_plugins.h | 5 + runtime/include/mod_runtime.h | 12 ++ runtime/src/gpu.c | 11 ++ runtime/src/main.cpp | 15 +- runtime/src/mod_packages.cpp | 9 + runtime/src/mod_runtime.cpp | 135 +++++++++++-- runtime/src/render_pass.c | 12 ++ runtime/tests/test_mod_resident.cpp | 3 + runtime/tests/test_mod_runtime.cpp | 3 + runtime/tests/test_netplay_view_mods.cpp | 225 ++++++++++++++++++++++ runtime/tests/test_pgxp_session.cpp | 3 + runtime/tests/test_render_pass_abort.c | 1 + runtime/tests/test_render_pass_sandbox.c | 6 + runtime/tests/test_ws_local_view_margin.c | 38 ++++ 19 files changed, 561 insertions(+), 23 deletions(-) create mode 100644 runtime/tests/test_netplay_view_mods.cpp create mode 100644 runtime/tests/test_ws_local_view_margin.c diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index d025ed10..bfdb1921 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -534,7 +534,9 @@ in-process second session is the lobby rematch: a netplay match launched from the lobby returns to the lobby launcher when it ends, and the next launch from 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 plan cleared, so no plugin activated in it -- except own-view plugins +(`[[plugin]] netplay = "local_view"`, docs/NETPLAY.md "Own-view mods"), whose +setters the reset below covers like any other. 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). diff --git a/docs/NETPLAY.md b/docs/NETPLAY.md index a6b6229b..4d63b515 100644 --- a/docs/NETPLAY.md +++ b/docs/NETPLAY.md @@ -139,7 +139,8 @@ Internal resolution is not a mod, so it is not cleared for netplay: each peer keeps its own preset, clamped to its own GPU, and one peer at 4K next to another at Native is a supported match. Only the GL present surface changes; the 1× software authority, the digests and the rollback snapshots are the same on every -peer. Widescreen and frame-rate mods, by contrast, are cleared for netplay. +peer. Mods are cleared for netplay, except a player's own-view mods (below), +which stay per player and never touch the shared game. ### Each peer's own view (presentation only) @@ -329,9 +330,43 @@ Generate & rebuild / prepare flows should point at the **`.cue`**, not a lone netplay. - **VERSION / lobby match pin:** peers should run the same release pin so generated code and protocol stay compatible. -- **Mods:** disabled for all netplay sessions (lobby / LAN / direct / rematch). - Launcher `commit_netplay` and the runtime clear the in-session plan without - touching the user's offline mod selection. Synced mod plans are deferred. +- **Mods:** cleared for all netplay sessions (lobby / LAN / direct / rematch) + without touching the user's offline mod selection, except own-view mods. + +### Own-view mods (per player) + +The shared simulation stays stock on every peer; a mod that only changes +what one player sees runs for that player alone, inside the sandboxed own-view +render (`psx_mod_render_local_view`). A package opts in per plugin: + +```toml +[[plugin]] +feature = "widescreen" +id = "r4.widescreen" +netplay = "local_view" +``` + +At a netplay session start the runtime resolves the player's own selection +and keeps only features whose every contribution is such a plugin (no EXE or +disc write, overlay or derived disc; `mod_runtime_commit_netplay_view`). They +activate as usual (presentation state: aspect, scene predicates), but: + +- their function-entry, filter, guest-function and instruction hooks run only + while `psx_mod_local_view_scope()` is 1, i.e. inside a local-view draw, whose + guest-side effects the sandbox discards; vblank and savestate callbacks do + not run in a match; +- `gpu_ws_set_local_view_only(1)`: the widescreen cull margin + (`psx_ws_x_margin()`) is the stock 0 in the shared game and this peer's own + margin only inside the local-view draw; squash widescreen (which changes the + GTE projection) is not used. + +So the canonical frame, rollback snapshots and digests are identical on every +peer whatever each player chose, there is nothing to negotiate or hash, and +each player keeps their own aspect (Fit follows their own window). Nothing is +written to `mods/state.toml`. A title without an own view simply shows its +canonical frame. A plugin must not keep host state that feeds guest writes +outside the scope; anything that persists guest state across frames is not an +own-view mod. --- diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index c8a15154..63f9dd36 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -586,6 +586,20 @@ if(BUILD_TESTING) endif() add_test(NAME ws_shared_split_ui_exec_test COMMAND ws_shared_split_ui_exec_test) + add_executable(ws_local_view_margin_test + tests/test_ws_local_view_margin.c + src/ws_ui_group.c + src/psx_video_timing.c + src/gpu_timeline.c) + target_include_directories(ws_local_view_margin_test PRIVATE include src) + if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(ws_local_view_margin_test PRIVATE + -ffunction-sections -fdata-sections) + target_link_options(ws_local_view_margin_test PRIVATE + $,-Wl$-dead_strip,-Wl$--gc-sections>) + endif() + add_test(NAME ws_local_view_margin_test COMMAND ws_local_view_margin_test) + add_executable(ws_view_anchor_test tests/test_ws_view_anchor.c) add_executable(ws_view_anchor_gpu_test tests/test_ws_view_anchor_gpu.c src/psx_video_timing.c @@ -846,6 +860,31 @@ if(BUILD_TESTING) endif() add_test(NAME mod_runtime_test COMMAND mod_runtime_test) + add_executable(netplay_view_mods_test + tests/test_netplay_view_mods.cpp + src/iso_reader_c.cpp + src/mod_runtime.cpp + src/mod_packages.cpp + src/mod_media.cpp + src/disc_path.cpp + src/cue_sheet.cpp + src/iso_reader.cpp + src/crc32.c + src/psx_sha256.c) + target_include_directories(netplay_view_mods_test PRIVATE + include + ../recompiler/include + ../recompiler/lib/toml11) + target_link_libraries(netplay_view_mods_test PRIVATE chdr-static) + if(WIN32) + if(MSVC) + target_link_options(netplay_view_mods_test PRIVATE /STACK:16777216) + else() + target_link_options(netplay_view_mods_test PRIVATE -Wl,--stack,16777216) + endif() + endif() + add_test(NAME netplay_view_mods_test COMMAND netplay_view_mods_test) + add_executable(mod_resident_test tests/test_mod_resident.cpp src/mod_resident.cpp diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index c625b2ba..c88b7a8e 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -277,6 +277,11 @@ int gpu_ws_present_native_43(void); /* Per-side X cull-margin (screen/world units) emitted into the game's draw- * cull immediates by the recompiler ([widescreen.cull]); 0 unless stretching. */ int psx_ws_x_margin(void); +/* Netplay own-view mods: while on, psx_ws_x_margin() is 0 (the stock cull) + * except inside a sandboxed local-view render (render_pass.c sets the scope). */ +void gpu_ws_set_local_view_only(int on); +void gpu_ws_set_local_view_scope(int on); +int gpu_ws_local_view_only(void); void gpu_ws_set_cull_guard_pixels(int pixels); /* Bias/range activation-window margin. This may include an additional * resident-object lead while render/terrain paths retain psx_ws_x_margin(). */ diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 57ab8476..21bc9a5e 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -214,6 +214,11 @@ struct ModPlugin { std::string id; std::map when; int64_t order = 0; + /* [[plugin]] netplay = "local_view": the plugin only changes what this + * player sees. In a netplay match it stays on, per player, but its hooks + * run only inside the sandboxed own-view render + * (psx_mod_render_local_view), never in the shared simulation. */ + bool netplay_local_view = false; }; struct ModResource { @@ -348,6 +353,7 @@ struct ModResolution { std::string id; std::string package_id; std::string feature_id; + bool netplay_local_view = false; }; std::vector plugins; struct Resource { @@ -451,6 +457,15 @@ public: return packages_; } const std::map& selections() const { return selections_; } + /* Swap the in-memory selection for `next` and return the previous one. + * Never saved by itself: a session that must not run the player's whole + * choice (a netplay match) swaps a reduced selection in, resolves, and + * swaps the player's back before anything can save_state(). */ + std::map exchange_selections( + std::map next) { + selections_.swap(next); + return next; + } const ModPackage* selected_package(const std::string& id) const; const ModFeature* selected_feature(const std::string& package_id, const std::string& feature_id) const; diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 3edd22cb..12455fac 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -528,6 +528,11 @@ int psx_mod_render_local_view(struct CPUState* cpu, const PSXModRenderPass* rect, PSXModRenderPassFn fn, void* user); uint32_t psx_mod_render_local_view_status(void); +/* 1 while a psx_mod_render_local_view draw runs. In that scope a netplay + * match's own-view mods ([[plugin]] netplay = "local_view") run their hooks + * and the widescreen cull margin is this peer's; outside it neither touches + * the shared simulation. */ +int psx_mod_local_view_scope(void); /* Simultaneous stereo capture, independent of temporal interpolation. Each * eye starts from the same guest state; CPU/RAM/devices/VRAM are restored * before the other eye and on failure. Publish only after both succeed. diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index c350363b..772f2cc1 100644 --- a/runtime/include/mod_runtime.h +++ b/runtime/include/mod_runtime.h @@ -6,6 +6,7 @@ #include #include #include +#include "mod_packages.h" #if defined(RECOMP_LAUNCHER) #include "recomp_launcher.h" #endif @@ -23,6 +24,17 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path = {}, * user's persisted offline selection on disk. Netplay is always vanilla for * now (no synced mod plans). */ bool mod_runtime_clear_for_netplay(std::string* error = nullptr); +/* Netplay: clear the plan, then keep only the player's own features whose + * every contribution is a [[plugin]] with netplay = "local_view" (no writes, + * overlays or derived discs). They activate as usual, but their hooks run + * only inside psx_mod_render_local_view and their vblank/savestate callbacks + * not at all, so the shared simulation stays stock. Never saves state.toml. */ +bool mod_runtime_commit_netplay_view(const std::filesystem::path& disc_path, + std::string* error = nullptr); +/* 1 while the session plan is such an own-view plan. */ +bool mod_runtime_netplay_view_active(); +/* "package/feature" keys of `plan` that qualify as own-view features. */ +std::vector mod_runtime_netplay_view_features(const ModResolution& plan); const std::string& mod_runtime_fingerprint(); const std::filesystem::path& mod_runtime_effective_disc_path(); /* Read an effective-disc file as whole sectors (true end-of-file tail bytes diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 6277daab..1b546c66 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -1385,7 +1385,18 @@ int psx_ws_aspect_cone_site(CPUState *cpu, uint32_t pc, uint32_t instr, return 1; } +/* Netplay presentation-only widescreen (gpu_ws_set_local_view_only): the + * shared simulation keeps the stock 4:3 cull everywhere; the widened margin + * exists only while this peer's sandboxed own view is drawn, whose guest-side + * effects the sandbox discards. */ +static int ws_local_view_only = 0; +static int ws_local_view_scope = 0; +void gpu_ws_set_local_view_only(int on) { ws_local_view_only = on ? 1 : 0; } +void gpu_ws_set_local_view_scope(int on) { ws_local_view_scope = on ? 1 : 0; } +int gpu_ws_local_view_only(void) { return ws_local_view_only; } + int psx_ws_x_margin(void) { + if (ws_local_view_only && !ws_local_view_scope) return 0; if (ws_margin_override >= 0) return ws_margin_override; /* Native-wide: widen the world-space draw cull by the per-side reveal * (== the centering OFFSET in screen px) so the game SUBMITS the geometry diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 173622d6..8a1031f9 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1951,7 +1951,10 @@ static void refresh_widescreen_projection() { const bool native_wide = (g_netplay_local_viewport == 1) ? local_native_wide : (g_ws_native_wide != 0); - const int mode = wide ? (native_wide ? 2 : 1) : 0; + /* Squash changes the GTE projection, which the guest sees: never while + * widescreen is a netplay peer's own-view presentation. */ + const int mode = wide ? (native_wide ? 2 : (gpu_ws_local_view_only() ? 0 : 1)) + : 0; int proj_num = g_video_aspect_num; int proj_den = g_video_aspect_den; if (mode == 1) { @@ -16149,7 +16152,8 @@ int main(int argc, char** argv) { * from disk. Skip commit entirely when this session is netplay. */ std::string mod_error; if (net_cfg.enabled) { - if (!PSXRecompV4::mod_runtime_clear_for_netplay(&mod_error)) { + if (!PSXRecompV4::mod_runtime_commit_netplay_view(resolved_disc, + &mod_error)) { std::fprintf(stderr, "psxrecomp: cannot clear mods for netplay: %s\n", mod_error.c_str()); @@ -16191,6 +16195,10 @@ int main(int argc, char** argv) { "reset_mod_owned_presentation() would clobber a launcher " "setting; restore only when the feature is mod-owned"); reset_mod_owned_presentation(); + /* Netplay own-view mods: the widescreen margin follows them into the + * sandboxed own view only; the shared game keeps the stock cull. */ + gpu_ws_set_local_view_only( + netplay && PSXRecompV4::mod_runtime_netplay_view_active() ? 1 : 0); mod_runtime_activate_plugins(); apply_netplay_local_viewport_aspect(netplay); for (int i = 0; i < PSX_MAX_PLAYERS; ++i) { @@ -18235,7 +18243,8 @@ soft_return_lobby: { std::string mod_error; if (net_cfg.enabled) { - if (!PSXRecompV4::mod_runtime_clear_for_netplay(&mod_error)) { + if (!PSXRecompV4::mod_runtime_commit_netplay_view( + resolved_disc, &mod_error)) { std::fprintf(stderr, "psxrecomp: cannot clear mods for netplay " "rematch: %s\n", diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 83a6f414..1964a519 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -2537,6 +2537,14 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, throw std::runtime_error("invalid plugin id"); plugin.order = toml::find_or( v, "order", (int64_t)declaration_index); + { + const std::string netplay = + toml::find_or(v, "netplay", ""); + if (!netplay.empty() && netplay != "local_view") + throw std::runtime_error( + "plugin netplay must be \"local_view\""); + plugin.netplay_local_view = netplay == "local_view"; + } read_conditions(v, out.options, plugin.feature_id, plugin.when, "plugin"); out.plugins.push_back(std::move(plugin)); @@ -3794,6 +3802,7 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, resolved.id = plugin->id; resolved.package_id = package->id; resolved.feature_id = plugin->feature_id; + resolved.netplay_local_view = plugin->netplay_local_view; result.plugins.push_back(std::move(resolved)); } for (const ModResource& resource : package->resources) { diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 41c7c797..879ece05 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -88,6 +88,9 @@ struct RuntimeMods { bool main_applied = false; bool disc_enabled = false; bool disc_guard_failed = false; + /* The plan is a netplay match's own-view set: its hooks run only inside + * psx_mod_render_local_view, its vblank/savestate callbacks not at all. */ + bool netplay_view_plan = false; const ModResolution::Plugin* current_plugin = nullptr; CPUState* current_function_cpu = nullptr; bool current_function_finished = false; @@ -1309,24 +1312,28 @@ bool mod_runtime_clear_for_netplay(std::string* error) { s.main_applied = false; s.disc_enabled = false; s.disc_guard_failed = false; + s.netplay_view_plan = false; s.error.clear(); if (error) error->clear(); std::fprintf(stdout, "psxrecomp: mods cleared for netplay (vanilla session)\n"); return true; } -bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* error) { - RuntimeMods& s = state(); - if (!s.initialized) return true; - if (disc_path != s.disc_path) { - std::string hash_error; - std::string digest; - if (!sha256_file(disc_path, digest, &hash_error)) digest.clear(); - s.disc_path = disc_path; - s.disc_sha256 = std::move(digest); - } - ModResolution plan = - s.manager.resolve(s.game_id, s.exe_sha256, s.disc_sha256); +namespace { + +void note_disc(RuntimeMods& s, const std::filesystem::path& disc_path) { + if (disc_path == s.disc_path) return; + std::string hash_error; + std::string digest; + if (!sha256_file(disc_path, digest, &hash_error)) digest.clear(); + s.disc_path = disc_path; + s.disc_sha256 = std::move(digest); +} + +/* Validate a resolved plan against the disc, materialize its derived disc, + * optionally persist the player's selection, and make it the session plan. */ +bool install_plan(RuntimeMods& s, ModResolution plan, bool save, + std::string* error) { s.validation = plan; /* A derived activation is not in state.toml, so name it: a player (or a * test) reading the log can see why a hidden feature is running. */ @@ -1365,7 +1372,7 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err if (error) *error = s.error; return false; } - if (!s.manager.save_state(&s.error)) { + if (save && !s.manager.save_state(&s.error)) { if (error) *error = s.error; return false; } @@ -1382,6 +1389,93 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err return true; } +} // namespace + +bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* error) { + RuntimeMods& s = state(); + if (!s.initialized) return true; + s.netplay_view_plan = false; + note_disc(s, disc_path); + return install_plan( + s, s.manager.resolve(s.game_id, s.exe_sha256, s.disc_sha256), + /*save*/ true, error); +} + +std::vector mod_runtime_netplay_view_features(const ModResolution& plan) { + /* A feature stays on online only when everything it contributes is a + * local-view plugin: no EXE/disc write, overlay or derived disc (those + * change the shared game) and no plugin without netplay = "local_view". */ + std::map ok; + for (const ModResolution::Plugin& p : plan.plugins) { + const std::string key = p.package_id + "/" + p.feature_id; + auto it = ok.find(key); + const bool good = p.netplay_local_view; + ok[key] = it == ok.end() ? good : (it->second && good); + } + for (const ModResolution::Write& w : plan.writes) + ok[w.package_id + "/" + w.feature_id] = false; + for (const ModResolution::Overlay& o : plan.overlays) + ok[o.package_id + "/" + o.feature_id] = false; + for (const ModResolution::DerivedDisc& d : plan.derived_discs) + ok[d.package_id + "/*"] = false; + std::vector out; + for (const auto& [key, good] : ok) { + if (!good) continue; + const std::string pkg = key.substr(0, key.find('/')); + if (ok.count(pkg + "/*")) continue; + out.push_back(key); + } + return out; +} + +bool mod_runtime_netplay_view_active() { + const RuntimeMods& s = state(); + return s.netplay_view_plan && s.plan.ok && !s.plan.plugins.empty(); +} + +bool mod_runtime_commit_netplay_view(const std::filesystem::path& disc_path, + std::string* error) { + RuntimeMods& s = state(); + std::string why; + (void)mod_runtime_clear_for_netplay(&why); + if (!s.initialized) return true; + note_disc(s, disc_path); + const ModResolution full = + s.manager.resolve(s.game_id, s.exe_sha256, s.disc_sha256); + if (!full.ok) return true; /* the player's set does not resolve: vanilla */ + const std::vector keep = mod_runtime_netplay_view_features(full); + if (keep.empty()) return true; + /* The player's selection with every other feature off. */ + std::map reduced = s.manager.selections(); + for (const auto& [id, versions] : s.manager.packages()) { + (void)versions; + const ModPackage* package = s.manager.selected_package(id); + if (!package) continue; + for (const ModFeature& f : package->features) { + if (f.legacy) continue; + const bool on = std::find(keep.begin(), keep.end(), + id + "/" + f.id) != keep.end(); + ModFeatureSelection& fs = reduced[id].features[f.id]; + if (!on) { fs.enabled = false; fs.has_enabled = true; } + } + } + std::map player = + s.manager.exchange_selections(std::move(reduced)); + ModResolution plan = s.manager.resolve(s.game_id, s.exe_sha256, s.disc_sha256); + (void)s.manager.exchange_selections(std::move(player)); + if (!install_plan(s, std::move(plan), /*save*/ false, &why)) { + std::fprintf(stderr, "psxrecomp: netplay own-view mods unavailable (%s)\n", + why.c_str()); + return mod_runtime_clear_for_netplay(error); + } + s.netplay_view_plan = true; + for (const std::string& k : keep) + std::fprintf(stdout, "psxrecomp: netplay keeps %s for this player's own " + "view only\n", k.c_str()); + if (error) error->clear(); + return true; +} + const std::string& mod_runtime_fingerprint() { return state().plan.fingerprint; } @@ -1436,7 +1530,7 @@ extern "C" void mod_runtime_on_dispatch(uint32_t target) { extern "C" void mod_runtime_on_savestate_loaded(void) { using namespace PSXRecompV4; RuntimeMods& s = state(); - if (!s.initialized || !s.plan.ok) return; + if (!s.initialized || !s.plan.ok || s.netplay_view_plan) return; if (!s.main_applied) { uint32_t failed_at = 0; @@ -1722,7 +1816,8 @@ extern "C" void mod_runtime_on_vblank(void) { using namespace PSXRecompV4; ++g_mod_vblanks; RuntimeMods& s = state(); - if (!s.initialized || !s.plan.ok) return; + /* An own-view plan never touches the shared simulation. */ + if (!s.initialized || !s.plan.ok || s.netplay_view_plan) return; for (const ModResolution::Plugin& plugin : s.plan.plugins) { PluginCallbackScope scope(s, &plugin); mod_invoke_vblank_plugin(plugin.id); @@ -1961,9 +2056,17 @@ extern "C" int psx_mod_register_guest_function_plugin( return id && PSXRecompV4::mod_register_guest_function_plugin(id, address, callback); } +extern "C" int psx_mod_local_view_scope(void); +/* A netplay own-view plan's hooks wait for the sandboxed own-view render; the + * shared simulation never runs them. */ +static int mod_plan_hooks_parked(void) { + return PSXRecompV4::state().netplay_view_plan && !psx_mod_local_view_scope(); +} + extern "C" int psx_mod_dispatch_guest_function(CPUState* cpu, uint32_t address) { using namespace PSXRecompV4; if (!g_psx_mod_guest_functions || !cpu || address >= 0xC0000000u) return 0; + if (mod_plan_hooks_parked()) return 0; const auto& functions = active_guest_functions(); const uint32_t key = function_entry_key(address); auto it = std::lower_bound(functions.begin(), functions.end(), key, @@ -1983,6 +2086,7 @@ extern "C" int psx_mod_register_instruction_plugin(const char* id, uint32_t addr extern "C" void psx_mod_instruction(CPUState* cpu, uint32_t address, uint32_t instruction) { using namespace PSXRecompV4; if (!g_psx_mod_instruction_hooks || !cpu || address >= 0xC0000000u) return; + if (mod_plan_hooks_parked()) return; const auto& hooks = active_instruction_hooks(); const auto key = function_entry_key(address); auto it = std::lower_bound(hooks.begin(), hooks.end(), key, @@ -2003,6 +2107,7 @@ extern "C" int psx_mod_function_entry(CPUState* cpu, uint32_t address) { using namespace PSXRecompV4; if (!g_psx_mod_function_entry_hooks || !cpu) return 0; RuntimeMods& s = state(); + if (mod_plan_hooks_parked()) return game_netplay_function_entry(cpu, address); const auto& table = active_function_entry_hooks(); const uint32_t key = function_entry_key(address); auto it = std::lower_bound( diff --git a/runtime/src/render_pass.c b/runtime/src/render_pass.c index 0e7eaffe..54b64334 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -60,6 +60,7 @@ extern int psx_get_in_exception(void); extern int psx_netplay_active(void); extern int psx_netplay_is_resimulating(void); extern void psx_netplay_local_view_clear(void); +extern void gpu_ws_set_local_view_scope(int on); extern int psx_selfcheck_resim_active(void); extern int psx_rewind_is_open(void); extern int psx_presentation_fast_forward(void); @@ -602,6 +603,13 @@ static void note_fault(const char *what) { } } +static int s_local_scope = 0; +static void local_scope_set(int on) { + s_local_scope = on; + gpu_ws_set_local_view_scope(on); +} +int psx_mod_local_view_scope(void) { return s_local_scope; } + static int transaction_kind(int eye) { return eye >= 0 ? 1 : eye == RP_EYE_LOCAL ? 2 : 0; } @@ -698,6 +706,9 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass } (void)psx_cycle_freeze_begin(&s_freeze, watchdog_cycles(), watchdog_overrun); + /* A netplay local view is the one place a peer's presentation-only mods + * act on guest code (own-view plugins, widescreen cull margin). */ + if (eye == RP_EYE_LOCAL) local_scope_set(1); if (setjmp(s_abort_jmp) == 0) { s_pass_cpu = cpu; s_abort_armed = 1; @@ -740,6 +751,7 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass if (eye >= 0) s_stereo.last_failure = "eye_capture"; } te = gl_renderer_perf_ticks(); + local_scope_set(0); checkpoint_restore(cpu); psx_cycle_freeze_end(&s_freeze); s_nesting = 0; diff --git a/runtime/tests/test_mod_resident.cpp b/runtime/tests/test_mod_resident.cpp index 7a1d5729..5b6a2cb6 100644 --- a/runtime/tests/test_mod_resident.cpp +++ b/runtime/tests/test_mod_resident.cpp @@ -278,3 +278,6 @@ int main() { std::cout << "mod resident tests passed\n"; return 0; } + +/* render_pass.c: no sandboxed local view runs in this test. */ +extern "C" int psx_mod_local_view_scope(void) { return 0; } diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index bf584c79..b17bc93d 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -1194,3 +1194,6 @@ int main() { std::cout << "mod runtime tests passed\n"; return 0; } + +/* render_pass.c: no sandboxed local view runs in this test. */ +extern "C" int psx_mod_local_view_scope(void) { return 0; } diff --git a/runtime/tests/test_netplay_view_mods.cpp b/runtime/tests/test_netplay_view_mods.cpp new file mode 100644 index 00000000..46112674 --- /dev/null +++ b/runtime/tests/test_netplay_view_mods.cpp @@ -0,0 +1,225 @@ +#include "mod_runtime.h" +#include "mod_packages.h" +#include "mod_plugins.h" +#include "psx_sha256.h" + +#include "gpu.h" +#include "cpu_state.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +/* Netplay own-view mods (mod_runtime_commit_netplay_view): a feature whose + * only contribution is a [[plugin]] with netplay = "local_view" stays on in a + * match, per player; its hooks run only inside the sandboxed own-view render + * (psx_mod_local_view_scope), never in the shared simulation; everything + * else is cleared; state.toml is never rewritten. */ +static std::array ram; +static int failures; +static int test_netplay_active = 1; +static int test_scope; +extern "C" int psx_netplay_active(void) { return test_netplay_active; } +extern "C" int psx_mod_local_view_scope(void) { return test_scope; } +extern "C" uint8_t psx_read_byte(uint32_t address) { + return ram[address & 0x1fffffu]; +} + +extern "C" void psx_write_byte(uint32_t address, uint8_t value) { + ram[address & 0x1fffffu] = value; +} + +extern "C" uint16_t psx_read_half(uint32_t address) { + const uint32_t offset = address & 0x1fffffu; + return (uint16_t)(ram[offset] | ((uint16_t)ram[offset + 1] << 8)); +} + +extern "C" void psx_write_half(uint32_t address, uint16_t value) { + const uint32_t offset = address & 0x1fffffu; + ram[offset] = (uint8_t)value; + ram[offset + 1] = (uint8_t)(value >> 8); +} + +extern "C" uint32_t psx_read_word(uint32_t address) { + const uint32_t offset = address & 0x1fffffu; + return (uint32_t)ram[offset] | + ((uint32_t)ram[offset + 1] << 8) | + ((uint32_t)ram[offset + 2] << 16) | + ((uint32_t)ram[offset + 3] << 24); +} + +extern "C" void psx_write_word(uint32_t address, uint32_t value) { + const uint32_t offset = address & 0x1fffffu; + ram[offset] = (uint8_t)value; + ram[offset + 1] = (uint8_t)(value >> 8); + ram[offset + 2] = (uint8_t)(value >> 16); + ram[offset + 3] = (uint8_t)(value >> 24); +} + +/* Mod writes are host stores (memory.c psx_host_write_*). */ +extern "C" void psx_host_write_byte(uint32_t address, uint8_t value) { + psx_write_byte(address, value); +} + +extern "C" void psx_host_write_half(uint32_t address, uint16_t value) { + psx_write_half(address, value); +} + +extern "C" void psx_host_write_word(uint32_t address, uint32_t value) { + psx_write_word(address, value); +} + +extern "C" uint32_t psx_mod_memory_alloc(uint32_t, uint32_t) { return 0; } +extern "C" uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t, uint32_t) { + return 0; +} +extern "C" void psx_ram_reset_size_request(void) {} +extern "C" void psx_projection_reset_session(void) {} +extern "C" void gpu_ws_set_native_scene_predicate(int (*)(void)) {} +extern "C" int psx_ws_x_margin(void) { return 0; } + +/* Stand-in for the GPU's display geometry. psx_mod_display_width/height must + * report exactly what the presenter reports -- a plugin drawing an overlay + * uses this as the screen edge, so a substituted or rounded value would put + * HUD elements in the wrong place. */ +static GpuDisplayInfo g_test_display; +extern "C" void gpu_get_display_info(GpuDisplayInfo* out) { + *out = g_test_display; +} + +extern "C" void dirty_ram_mark_executable_range(uint32_t, uint32_t) {} +extern "C" int fntrace_is_game_started(void) { return 1; } +/* Widescreen tag passthroughs mod_runtime forwards to the GPU; unused here. */ +extern "C" void gpu_ws_tag_hud_primitive(uint32_t, int) {} +extern "C" void gpu_ws_tag_world_primitive(uint32_t, int) {} +extern "C" void gpu_ws_set_adaptive_backdrop_preload(int) {} +extern "C" int gpu_ws_configured_x_reveal(void) { return 0; } +extern "C" void gpu_ws_tag_hud_prim(uint32_t, int) {} +extern "C" void gpu_ws_tag_screen_mask_quad(uint32_t) {} +extern "C" void gpu_ws_tag_radial_screen_mask_quad(uint32_t, float) {} + +static void check(bool value, const char* message) { + if (!value) { + std::cerr << "FAIL: " << message << "\n"; + failures++; + } +} + +static void write_text(const fs::path& path, const std::string& text) { + fs::create_directories(path.parent_path()); + std::ofstream out(path); + out << text; +} + +static std::string read_text(const fs::path& path) { + std::ifstream in(path); + return std::string(std::istreambuf_iterator(in), {}); +} + +static int view_hits, sim_hits, view_activations, sim_activations, vblanks; +static void view_entry(CPUState*, uint32_t) { view_hits++; } +static void sim_entry(CPUState*, uint32_t) { sim_hits++; } +static void view_activate(void) { view_activations++; } +static void sim_activate(void) { sim_activations++; } +static void view_vblank(void) { vblanks++; } + +static std::string package(const std::string& id, const std::string& feature, + const std::string& plugin, const std::string& extra) { + return "format_version = 7\n" + "id = \"" + id + "\"\nversion = \"1.0.0\"\nname = \"T\"\n" + "[[target]]\ngame_id = \"SLUS-VIEW\"\n" + "[[feature]]\nid = \"" + feature + "\"\nname = \"F\"\n" + "[[plugin]]\nfeature = \"" + feature + "\"\nid = \"" + plugin + "\"\n" + + extra; +} + +int main() { + const fs::path root = fs::temp_directory_path() / "psx_netplay_view_mods_test"; + fs::remove_all(root); + write_text(root / "bundled/view.pkg/1.0.0/manifest.toml", + package("view.pkg", "wide", "view.wide", "netplay = \"local_view\"\n")); + write_text(root / "bundled/sim.pkg/1.0.0/manifest.toml", + package("sim.pkg", "cheat", "sim.cheat", "")); + write_text(root / "bundled/bad.pkg/1.0.0/manifest.toml", + package("bad.pkg", "x", "bad.x", "netplay = \"everywhere\"\n")); + const std::string state = + "format_version = 2\n" + "[[feature]]\npackage_id = \"view.pkg\"\nid = \"wide\"\nenabled = true\n" + "[[feature]]\npackage_id = \"sim.pkg\"\nid = \"cheat\"\nenabled = true\n"; + write_text(root / "state.toml", state); + + check(psx_mod_register_function_entry_plugin("view.wide", 0x80003000u, view_entry), + "register view hook"); + check(psx_mod_register_function_entry_plugin("sim.cheat", 0x80003100u, sim_entry), + "register sim hook"); + check(psx_mod_register_activation_plugin("view.wide", view_activate) && + psx_mod_register_activation_plugin("sim.cheat", sim_activate), + "register activations"); + check(psx_mod_register_vblank_plugin("view.wide", view_vblank), "register vblank"); + + std::string error; + check(PSXRecompV4::mod_runtime_initialize(root, "SLUS-VIEW", 0x80002000u, {}, &error), + error.c_str()); + + /* Offline: both run in the game. */ + check(PSXRecompV4::mod_runtime_commit({}, &error), error.c_str()); + check(!PSXRecompV4::mod_runtime_netplay_view_active(), "offline plan is not own-view"); + mod_runtime_activate_plugins(); + CPUState cpu{}; + psx_mod_function_entry(&cpu, 0x80003000u); + psx_mod_function_entry(&cpu, 0x80003100u); + check(view_hits == 1 && sim_hits == 1, "offline hooks run in the game"); + + /* Netplay: only the local-view feature remains, parked outside the scope. */ + view_hits = sim_hits = view_activations = sim_activations = vblanks = 0; + const std::string saved = read_text(root / "state.toml"); + check(PSXRecompV4::mod_runtime_commit_netplay_view({}, &error), error.c_str()); + check(PSXRecompV4::mod_runtime_netplay_view_active(), "own-view plan active"); + mod_runtime_activate_plugins(); + check(view_activations == 1 && sim_activations == 0, + "only the local-view plugin activates in a match"); + psx_mod_function_entry(&cpu, 0x80003000u); + psx_mod_function_entry(&cpu, 0x80003100u); + mod_runtime_on_vblank(); + check(view_hits == 0 && sim_hits == 0 && vblanks == 0, + "the shared simulation runs no mod hook or vblank"); + test_scope = 1; + psx_mod_function_entry(&cpu, 0x80003000u); + psx_mod_function_entry(&cpu, 0x80003100u); + test_scope = 0; + check(view_hits == 1 && sim_hits == 0, + "inside the own-view render only the local-view hook runs"); + check(read_text(root / "state.toml") == saved, "state.toml untouched by a match"); + + /* A plan whose own-view feature also writes the game does not qualify. */ + PSXRecompV4::ModResolution plan; + plan.plugins.push_back({"p", "a.pkg", "f", true}); + plan.plugins.push_back({"q", "b.pkg", "g", true}); + PSXRecompV4::ModResolution::Write w; + w.package_id = "b.pkg"; + w.feature_id = "g"; + plan.writes.push_back(w); + const auto keep = PSXRecompV4::mod_runtime_netplay_view_features(plan); + check(keep.size() == 1 && keep[0] == "a.pkg/f", + "a feature with a game write is not own-view"); + + check(PSXRecompV4::mod_runtime_clear_for_netplay(&error) && + !PSXRecompV4::mod_runtime_netplay_view_active(), + "a clear ends the own-view plan"); + + fs::remove_all(root); + if (failures) { + std::cerr << failures << " failure(s)\n"; + return 1; + } + std::cout << "netplay_view_mods_test: ok\n"; + return 0; +} diff --git a/runtime/tests/test_pgxp_session.cpp b/runtime/tests/test_pgxp_session.cpp index 44c2e6b5..3d6907e3 100644 --- a/runtime/tests/test_pgxp_session.cpp +++ b/runtime/tests/test_pgxp_session.cpp @@ -345,3 +345,6 @@ int main(void) { std::printf("test_pgxp_session: all checks passed\n"); return 0; } + +/* render_pass.c: no sandboxed local view runs in this test. */ +extern "C" int psx_mod_local_view_scope(void) { return 0; } diff --git a/runtime/tests/test_render_pass_abort.c b/runtime/tests/test_render_pass_abort.c index 5e4edd48..530bba56 100644 --- a/runtime/tests/test_render_pass_abort.c +++ b/runtime/tests/test_render_pass_abort.c @@ -106,6 +106,7 @@ int psx_get_in_exception(void) { return 0; } int psx_netplay_active(void) { return 0; } int psx_netplay_is_resimulating(void) { return 0; } void psx_netplay_local_view_clear(void) {} +void gpu_ws_set_local_view_scope(int on) { (void)on; } int psx_selfcheck_resim_active(void) { return 0; } int psx_selfcheck_enabled(void) { return 0; } int psx_rewind_is_open(void) { return 0; } diff --git a/runtime/tests/test_render_pass_sandbox.c b/runtime/tests/test_render_pass_sandbox.c index 909bfd6a..6cdaef5f 100644 --- a/runtime/tests/test_render_pass_sandbox.c +++ b/runtime/tests/test_render_pass_sandbox.c @@ -158,6 +158,8 @@ static int s_netplay, s_resim, s_crop_clears; int psx_netplay_active(void) { return s_netplay; } int psx_netplay_is_resimulating(void) { return s_resim; } void psx_netplay_local_view_clear(void) { s_crop_clears++; } +static int s_ws_scope; +void gpu_ws_set_local_view_scope(int on) { s_ws_scope = on; } int psx_selfcheck_resim_active(void) { return 0; } int psx_selfcheck_enabled(void) { return 0; } int psx_rewind_is_open(void) { return 0; } @@ -733,6 +735,8 @@ static void test_stereo(void) { static int local_draw(CPUState *cpu, void *user, uint32_t alpha) { (void)user; CHECK(alpha == 0, "a local view has no phase"); + CHECK(s_ws_scope == 1 && psx_mod_local_view_scope() == 1, + "own-view presentation scope open while the local view draws"); cpu->gpr[8] = 0xBEEFu; s_ram[0x456] = 0x11; /* the game's draw code writes RAM */ psx_advance_cycles(5000u); @@ -778,6 +782,8 @@ static void test_local_view(void) { CHECK(st.local_views == 1 && s_local_kept == 1 && s_open_passes == 0 && st.passes == 0, "one kept local image, transaction closed"); CHECK(s_crop_clears == 1, "the committed image cancels the crop request"); + CHECK(s_ws_scope == 0 && psx_mod_local_view_scope() == 0, + "own-view scope closed after the local view"); CHECK(cpu.gpr[8] == 7 && s_ram[0x456] == 0x77 && psx_cycle_count == cycle, "guest state restored after the local view"); r.x = 900; diff --git a/runtime/tests/test_ws_local_view_margin.c b/runtime/tests/test_ws_local_view_margin.c new file mode 100644 index 00000000..05305ba4 --- /dev/null +++ b/runtime/tests/test_ws_local_view_margin.c @@ -0,0 +1,38 @@ +#ifdef NDEBUG +#undef NDEBUG +#endif + +/* Netplay own-view widescreen: with gpu_ws_set_local_view_only(1) the cull + * margin the guest sees is the stock 0 everywhere except inside a sandboxed + * local-view render (gpu_ws_set_local_view_scope), where it is the margin the + * configured native-wide aspect implies. Off (the default) changes nothing. */ +#include +#include +#include +#include +#include "../src/gpu.c" +#include "gpu_exec_stubs.inc" + +int main(void) { + ws_mode = 2; + ws_cfg_num = 16; ws_cfg_den = 9; + hres1 = 1; hres2 = 0; video_mode = 0; /* 320 wide */ + h_display_x1 = 0x260; h_display_x2 = 0xC60; + v_display_y1 = 0x10; v_display_y2 = 0x100; + const int wide = psx_ws_x_margin(); + assert(wide > 0); + assert(!gpu_ws_local_view_only()); + + gpu_ws_set_local_view_only(1); + assert(psx_ws_x_margin() == 0); /* shared simulation: stock */ + assert(psx_ws_player_x_bound(160) == 160); + gpu_ws_set_local_view_scope(1); + assert(psx_ws_x_margin() == wide); /* own view: this peer's */ + gpu_ws_set_local_view_scope(0); + assert(psx_ws_x_margin() == 0); + + gpu_ws_set_local_view_only(0); + assert(psx_ws_x_margin() == wide); /* offline: unchanged */ + puts("ws_local_view_margin_test: ok"); + return 0; +} From d54bf48068699539154bcdffa03b80fd525de913 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:35:45 -0700 Subject: [PATCH 8/9] Pin recomp-net feat/rb-frame-full-pad (#28) Co-Authored-By: Claude Opus 5.5 --- lib/recomp-net | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/recomp-net b/lib/recomp-net index 0fa06873..52a45a58 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit 0fa0687310e1535b3fed9df38804809626c3ea92 +Subproject commit 52a45a580ce59a13e7d9ec1b57cdea863a247de3 From 6f62f560bf767634cd348c6db27ca733d4e14b62 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:37:35 -0700 Subject: [PATCH 9/9] netplay: input transforms stay per player online; rollback rows carry rx/ry Co-Authored-By: Claude Opus 5.5 --- runtime/include/mod_packages.h | 5 +++ runtime/include/mod_runtime.h | 3 ++ runtime/include/psx_netplay_rb.h | 3 +- runtime/src/debug_server.c | 18 ++++++++++ runtime/src/main.cpp | 58 ++++++++++++++++++++++++++++++++ runtime/src/mod_packages.cpp | 7 ++-- runtime/src/mod_runtime.cpp | 10 +++++- runtime/src/netplay_input_hist.c | 4 +++ runtime/src/psx_netplay.c | 22 +++++++++--- runtime/src/psx_netplay_rb.c | 5 +-- 10 files changed, 124 insertions(+), 11 deletions(-) diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 21bc9a5e..72798954 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -219,6 +219,10 @@ struct ModPlugin { * run only inside the sandboxed own-view render * (psx_mod_render_local_view), never in the shared simulation. */ bool netplay_local_view = false; + /* netplay = "input": the plugin only transforms this player's own pad + * before it is staged (psx_mod_set_pad_transform). Online it stays on per + * player: the post-transform pad is what every peer simulates. */ + bool netplay_input = false; }; struct ModResource { @@ -354,6 +358,7 @@ struct ModResolution { std::string package_id; std::string feature_id; bool netplay_local_view = false; + bool netplay_input = false; }; std::vector plugins; struct Resource { diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index 772f2cc1..5154896b 100644 --- a/runtime/include/mod_runtime.h +++ b/runtime/include/mod_runtime.h @@ -33,6 +33,9 @@ bool mod_runtime_commit_netplay_view(const std::filesystem::path& disc_path, std::string* error = nullptr); /* 1 while the session plan is such an own-view plan. */ bool mod_runtime_netplay_view_active(); +/* 1 while that plan holds a [[plugin]] netplay = "input" (a pad transform + * applied to this player's own pad at netplay staging). */ +bool mod_runtime_netplay_input_active(); /* "package/feature" keys of `plan` that qualify as own-view features. */ std::vector mod_runtime_netplay_view_features(const ModResolution& plan); const std::string& mod_runtime_fingerprint(); diff --git a/runtime/include/psx_netplay_rb.h b/runtime/include/psx_netplay_rb.h index c08f7438..e3780b12 100644 --- a/runtime/include/psx_netplay_rb.h +++ b/runtime/include/psx_netplay_rb.h @@ -32,7 +32,8 @@ typedef struct PsxNetplayRbBindings { int *input_delay; void (*publish_sio)(uint32_t tick); /* publish history or sealed rows to SIO */ void (*apply_frame_slot)(int slot, uint32_t tick, uint16_t buttons, - int8_t sx, int8_t sy, uint8_t analog); + int8_t sx, int8_t sy, uint8_t analog, + uint8_t rx, uint8_t ry); } PsxNetplayRbBindings; void psx_netplay_rb_bind(const PsxNetplayRbBindings *b); diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 68d66c5c..0c87c662 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -7826,6 +7826,8 @@ static void handle_unwatch(int id, const char *json) send_err(id, "watchpoint not found"); } +static int s_trigger_override; +static uint32_t s_trigger_lt, s_trigger_rt; static void handle_set_input(int id, const char *json) { char val_str[32]; @@ -7848,9 +7850,25 @@ static void handle_set_input(int id, const char *json) int v = ax[i] < 0 ? 0x80 : (ax[i] > 255 ? 255 : ax[i]); s_axis_st[i] = (uint8_t)v; } + /* Optional host triggers lt/rt (0..255) for a title pad transform, as a + * gamepad's analog triggers would give it. Absent -> released. */ + { + const int lt = json_get_int(json, "lt", -1), rt = json_get_int(json, "rt", -1); + s_trigger_override = lt >= 0 || rt >= 0; + s_trigger_lt = (uint32_t)(lt < 0 ? 0 : (lt > 255 ? 255 : lt)); + s_trigger_rt = (uint32_t)(rt < 0 ? 0 : (rt > 255 ? 255 : rt)); + } send_ok(id); } +int debug_server_get_trigger_override(uint32_t *lt, uint32_t *rt) +{ + if (!s_trigger_override) return 0; + *lt = s_trigger_lt; + *rt = s_trigger_rt; + return 1; +} + static void handle_press(int id, const char *json) { int buttons = json_get_int(json, "buttons", -1); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index a1755afa..51645475 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -6169,6 +6169,63 @@ static void netplay_present_gap_stats(uint32_t *p95_out, uint32_t *max_out) { /* Stage local pad + poll admit until published. Parks the guest fiber when * called from the vblank callback (or before scheduler entry). Latches one * sample per sim tick; stalls on INPUT_CONFIRM desync. */ +static void pad_ext_host_extras(void*, int s, uint32_t* flags, uint32_t* lt, + uint32_t* rt); +#ifndef PSX_NO_DEBUG_TOOLS +extern "C" int debug_server_get_trigger_override(uint32_t* lt, uint32_t* rt); +#endif + +/* Netplay input transform ([[plugin]] netplay = "input"): this player's own + * title pad transform runs on its local pad before staging, so the + * transformed pad (buttons, sticks, presented type, NeGcon I/II) is what every + * peer receives and simulates. Each peer transforms only its own seat. */ +static void netplay_transform_local_pad(PsxNetPad* pad, int override_active) { + if (!PSXRecompV4::mod_runtime_netplay_input_active() || + psx_netplay_is_spectator()) + return; + const int seat = psx_netplay_local_slot(); + if (seat < 0 || seat >= PSX_MAX_PLAYERS) return; + PSXModPadFrame f{}; + f.struct_size = sizeof f; + f.player = (uint32_t)seat; + f.buttons = pad->buttons; + f.lx = pad->lx; f.ly = pad->ly; f.rx = pad->rx; f.ry = pad->ry; + f.type = pad->analog; + if (override_active) { +#ifndef PSX_NO_DEBUG_TOOLS + uint32_t lt = 0, rt = 0; + if (debug_server_get_trigger_override(<, &rt)) { + f.host_flags = PSX_MOD_PAD_HOST_GAMEPAD | PSX_MOD_PAD_HOST_LT | + PSX_MOD_PAD_HOST_RT; + f.host_lt = lt; + f.host_rt = rt; + } +#endif + } else { + const int card = psx_netplay_input_player(); + pad_ext_host_extras(nullptr, card >= 0 ? card : 0, &f.host_flags, + &f.host_lt, &f.host_rt); + } + PSXModPadOutput stock{}, o{}; + stock.struct_size = sizeof stock; + stock.buttons = f.buttons; stock.type = f.type; + stock.lx = f.lx; stock.ly = f.ly; stock.rx = f.rx; stock.ry = f.ry; + if (!mod_pad_transform_run((uint32_t)seat, &f, &stock, &o)) return; + pad->buttons = (uint16_t)o.buttons; + pad->analog = (uint8_t)o.type; + if (o.type == PSX_MOD_PAD_NEGCON) { + pad->lx = (uint8_t)o.lx; + pad->ly = (uint8_t)o.negcon_l; + pad->rx = (uint8_t)o.negcon_i; + pad->ry = (uint8_t)o.negcon_ii; + } else if (o.type == PSX_MOD_PAD_DIGITAL) { + pad->lx = pad->ly = pad->rx = pad->ry = 0x80; + } else { + pad->lx = (uint8_t)o.lx; pad->ly = (uint8_t)o.ly; + pad->rx = (uint8_t)o.rx; pad->ry = (uint8_t)o.ry; + } +} + static void netplay_barrier_admit(int override) { if (!psx_netplay_active()) return; /* Launcher/game window teardown can leave a queued SDL_QUIT; draining it @@ -6333,6 +6390,7 @@ static void netplay_barrier_admit(int override) { } else { capture_local_human_pad(&local); } + netplay_transform_local_pad(&local, override >= 0); psx_netplay_stage_local(&local); } else if (psx_start_bisect_spin_log() && !g_headless) { /* Dense SDL-only samples while admit waits without capture. */ diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 1964a519..70748a79 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -2540,10 +2540,12 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, { const std::string netplay = toml::find_or(v, "netplay", ""); - if (!netplay.empty() && netplay != "local_view") + if (!netplay.empty() && netplay != "local_view" && + netplay != "input") throw std::runtime_error( - "plugin netplay must be \"local_view\""); + "plugin netplay must be \"local_view\" or \"input\""); plugin.netplay_local_view = netplay == "local_view"; + plugin.netplay_input = netplay == "input"; } read_conditions(v, out.options, plugin.feature_id, plugin.when, "plugin"); @@ -3803,6 +3805,7 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, resolved.package_id = package->id; resolved.feature_id = plugin->feature_id; resolved.netplay_local_view = plugin->netplay_local_view; + resolved.netplay_input = plugin->netplay_input; result.plugins.push_back(std::move(resolved)); } for (const ModResource& resource : package->resources) { diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 879ece05..fd24c037 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -1409,7 +1409,7 @@ std::vector mod_runtime_netplay_view_features(const ModResolution& for (const ModResolution::Plugin& p : plan.plugins) { const std::string key = p.package_id + "/" + p.feature_id; auto it = ok.find(key); - const bool good = p.netplay_local_view; + const bool good = p.netplay_local_view || p.netplay_input; ok[key] = it == ok.end() ? good : (it->second && good); } for (const ModResolution::Write& w : plan.writes) @@ -1428,6 +1428,14 @@ std::vector mod_runtime_netplay_view_features(const ModResolution& return out; } +bool mod_runtime_netplay_input_active() { + const RuntimeMods& s = state(); + if (!s.netplay_view_plan || !s.plan.ok) return false; + for (const ModResolution::Plugin& p : s.plan.plugins) + if (p.netplay_input) return true; + return false; +} + bool mod_runtime_netplay_view_active() { const RuntimeMods& s = state(); return s.netplay_view_plan && s.plan.ok && !s.plan.plugins.empty(); diff --git a/runtime/src/netplay_input_hist.c b/runtime/src/netplay_input_hist.c index 5917809d..e3c46782 100644 --- a/runtime/src/netplay_input_hist.c +++ b/runtime/src/netplay_input_hist.c @@ -41,6 +41,8 @@ void netplay_ih_pad_to_frame(const PsxNetPad *pad, uint32_t tick, uint8_t predic out->stick_x = u8_to_i8_stick(n.lx); out->stick_y = u8_to_i8_stick(n.ly); out->analog = n.analog; + out->rx = n.rx; /* the rest of the pad: right stick, NeGcon I/II */ + out->ry = n.ry; } void netplay_ih_frame_to_pad(const RNetRbFrame *frame, PsxNetPad *pad) @@ -56,6 +58,8 @@ void netplay_ih_frame_to_pad(const RNetRbFrame *frame, PsxNetPad *pad) pad->lx = i8_to_u8_stick(frame->stick_x); pad->ly = i8_to_u8_stick(frame->stick_y); pad->analog = frame->analog <= PSX_NETPAD_TYPE_MAX ? frame->analog : 0u; + pad->rx = frame->rx; + pad->ry = frame->ry; psx_netplay_normalize_pad(pad); } diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index 1b2b2785..a49ddd59 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -2501,7 +2501,8 @@ static void np_publish_hist_sio(uint32_t tick) } static void np_rb_apply_frame_slot(int slot, uint32_t tick, uint16_t buttons, - int8_t sx, int8_t sy, uint8_t analog) + int8_t sx, int8_t sy, uint8_t analog, + uint8_t rx, uint8_t ry) { RNetRbFrame row; PsxNetPad pad; @@ -2511,6 +2512,8 @@ static void np_rb_apply_frame_slot(int slot, uint32_t tick, uint16_t buttons, row.stick_x = sx; row.stick_y = sy; row.analog = analog <= PSX_NETPAD_TYPE_MAX ? analog : 0u; + row.rx = rx; + row.ry = ry; row.is_valid = 1; netplay_ih_frame_to_pad(&row, &pad); force_session_pads_connected(g_np.slot_count); @@ -2613,7 +2616,8 @@ static void np_scrub_ahead_predicted(int slot, rnet_u32 release_tick, continue; if (row.buttons == released->buttons && row.stick_x == released->stick_x && - row.stick_y == released->stick_y) + row.stick_y == released->stick_y && + row.rx == released->rx && row.ry == released->ry) continue; scrub = *released; scrub.tick = t; @@ -2674,7 +2678,8 @@ static void np_tip_hold_coalesce_ahead(void) netplay_ih_pad_to_frame(&pad, t, 0, &wire_frame); if (wire_frame.buttons == tip_row.buttons && wire_frame.stick_x == tip_row.stick_x && - wire_frame.stick_y == tip_row.stick_y) + wire_frame.stick_y == tip_row.stick_y && + wire_frame.rx == tip_row.rx && wire_frame.ry == tip_row.ry) continue; edge = t; break; @@ -2862,6 +2867,7 @@ static void np_rollback_reconcile_wire(void) rnet_u32 wire; int pads_differ; int buttons_differ; + int rest_differ; if (!netplay_ih_get(&g_np.ih, slot, t, &published)) continue; @@ -2877,7 +2883,11 @@ static void np_rollback_reconcile_wire(void) netplay_ih_frame_to_contract(&published, &pub_c); netplay_ih_frame_to_contract(&wire_frame, &wire_c); buttons_differ = (pub_c.buttons != wire_c.buttons); - pads_differ = buttons_differ || + /* The rest of the pad (right stick, NeGcon I/II) is not in the + * contract view; any difference there is a real mispredict. */ + rest_differ = published.rx != wire_frame.rx || + published.ry != wire_frame.ry; + pads_differ = buttons_differ || rest_differ || (pub_c.stick_x != wire_c.stick_x) || (pub_c.stick_y != wire_c.stick_y); completed = (sim > t) ? 1u : 0u; @@ -2888,7 +2898,7 @@ static void np_rollback_reconcile_wire(void) * release) without opening an episode. Distinct from the old * ungated menu soft-promote that forked RAM: HC fail-closed * means a real state-affecting press still rewinds. */ - if (buttons_differ && completed && + if (buttons_differ && !rest_differ && completed && np_hc_silent_promote_enabled() && netplay_hc_confirm_through(&g_np.hc, t)) { (void)netplay_ih_promote(&g_np.ih, slot, &wire_frame); @@ -2937,6 +2947,8 @@ static void np_rollback_reconcile_wire(void) gate_ctx.tick = t; d = rnet_input_contract_stick_replace_decide( &pub_c, &wire_c, completed, ¶ms, &gates); + if (rest_differ && completed) + d = nRNetInputContractRewind; if (rnet_input_contract_decision_is_rewind(d)) { /* FMV/settle only: soft-promote releases (skip must rewind on * press). Menu soft-promote + hold-last invent forked RAM diff --git a/runtime/src/psx_netplay_rb.c b/runtime/src/psx_netplay_rb.c index e08de2cc..a424dbff 100644 --- a/runtime/src/psx_netplay_rb.c +++ b/runtime/src/psx_netplay_rb.c @@ -1482,7 +1482,8 @@ static void dump_post_diverge_diag(const char *why) hist.is_predicted ? "P" : ""); if (have_s && (seal.buttons != hist.buttons || seal.stick_x != hist.stick_x || - seal.stick_y != hist.stick_y)) + seal.stick_y != hist.stick_y || + seal.rx != hist.rx || seal.ry != hist.ry)) fprintf(stderr, "!"); } else { fprintf(stderr, "/h=----"); @@ -4170,7 +4171,7 @@ static void publish_sealed_sio(uint32_t tick) if (!rnet_rb_get_sealed_frame(g_rb, slot, tick, &row) || !row.is_valid) continue; g_b.apply_frame_slot(slot, tick, row.buttons, row.stick_x, row.stick_y, - row.analog); + row.analog, row.rx, row.ry); } }