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; +}