mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
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:
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(). */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user