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 <noreply@anthropic.com>
This commit is contained in:
Shokunin
2026-10-06 14:17:26 -07:00
parent f379771c93
commit 33b842e97f
19 changed files with 561 additions and 23 deletions
+3 -1
View File
@@ -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).
+39 -4
View File
@@ -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.
---
+39
View File
@@ -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
$<IF:$<PLATFORM_ID:Darwin>,-Wl$<COMMA>-dead_strip,-Wl$<COMMA>--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
+5
View File
@@ -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(). */
+15
View File
@@ -214,6 +214,11 @@ struct ModPlugin {
std::string id;
std::map<std::string, std::string> 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<Plugin> plugins;
struct Resource {
@@ -451,6 +457,15 @@ public:
return packages_;
}
const std::map<std::string, ModSelection>& 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<std::string, ModSelection> exchange_selections(
std::map<std::string, ModSelection> 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;
+5
View File
@@ -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.
+12
View File
@@ -6,6 +6,7 @@
#include <filesystem>
#include <string>
#include <vector>
#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<std::string> 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
+11
View File
@@ -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
+12 -3
View File
@@ -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",
+9
View File
@@ -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<int64_t>(
v, "order", (int64_t)declaration_index);
{
const std::string netplay =
toml::find_or<std::string>(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) {
+120 -15
View File
@@ -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<std::string> 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<std::string, bool> 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<std::string> 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<std::string> keep = mod_runtime_netplay_view_features(full);
if (keep.empty()) return true;
/* The player's selection with every other feature off. */
std::map<std::string, ModSelection> 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<std::string, ModSelection> 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(
+12
View File
@@ -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;
+3
View File
@@ -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; }
+3
View File
@@ -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; }
+225
View File
@@ -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 <array>
#include <algorithm>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
#include <string>
#include <vector>
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<uint8_t, 2 * 1024 * 1024> 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<char>(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;
}
+3
View File
@@ -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; }
+1
View File
@@ -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; }
+6
View File
@@ -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;
+38
View File
@@ -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 <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#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;
}