Netplay presentation packages: stay active in netplay, guarded; render passes on live netplay frames

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-10-02 16:17:13 -07:00
parent c638648b59
commit cd2566cdd9
13 changed files with 375 additions and 21 deletions
+34 -1
View File
@@ -525,7 +525,8 @@ What a plugin sets up should last only for a session whose resolved plan
activates it. Function-entry hooks follow that rule by construction: the commit
and the netplay clear empty the hook table, and only activation rebuilds it, so
a hook never runs in a session that did not activate its id (including a
netplay session, which clears the plan). The host state a plugin changes
netplay session, which clears the plan to its presentation packages; see
*Netplay presentation packages*). The host state a plugin changes
through the `psx_mod_*` setters is process-wide, so the runtime resets it at
every session start instead.
@@ -627,6 +628,38 @@ bounded instant scheduler. Because this changes interrupt timing, packages
should label it experimental and make it mutually exclusive with host-only
load acceleration (normally as choices in one default-off feature).
### Netplay presentation packages
Netplay runs the simulation vanilla on both peers: the netplay clear drops
every patch, overlay, derived disc and plugin. A package that only changes what
the local player sees (a frame-rate or presentation plugin) can declare that at
package level and stay active:
```toml
netplay = "presentation" # default "off"
```
Peers need not agree on these packages, so they can never change what either
peer simulates. That is enforced, not trusted:
- The manifest may carry plugins and resources, but no `[[patch]]`,
`[[overlay]]` or `[[derived_disc]]` (rejected at load).
- In a netplay session (`g_psx_mod_netplay_presentation`) the API refuses every
call that would change the simulation: `psx_mod_write_*`,
`psx_mod_dispatch_guest_function` (outside a render pass, whose sandbox
restores the machine), `psx_mod_write_code_word`, guest and GPU-DMA
allocation, disc extents, `psx_mod_finish_function` and function filters
(no skipping guest code), Skip FMVs, controller overrides and policies, disc
speed, load acceleration, the 8 MB map and the native VBlank rate. Each
refusal returns failure and counts `netplay.refused` (TCP `mod_counters`).
- Every plugin callback (activation, VBlank, function entry, instruction) runs
under a guard: a callback that leaves the CPU state changed is undone
(`netplay.cpu_restored`), and one that stored to guest memory anyway is
disabled for the rest of the session (`netplay.plugin_disabled`).
Render passes ([RENDER_PASSES.md](RENDER_PASSES.md)) are allowed on live
netplay frames, never during a rollback resimulation.
## Native operations
`main_exe` writes use PSX guest virtual addresses. Expected bytes are checked
+8 -5
View File
@@ -28,8 +28,10 @@ flip to frame n. For a PsyQ double-buffered loop that is the entry of the
Register that hook with `psx_mod_register_function_entry_plugin()` under the
plugin's manifest `[[plugin]]` id (see [MOD_PACKAGES.md](MOD_PACKAGES.md)):
it then runs only while the resolved mod plan activates the plugin, so never
while the package is disabled or in netplay. Register once, for example from
it then runs only while the resolved mod plan activates the plugin: never
while the package is disabled, and in netplay only for a package declared
`netplay = "presentation"` ([MOD_PACKAGES.md](MOD_PACKAGES.md), *Netplay
presentation packages*). Register once, for example from
the plugin's constructor; a repeated id and address returns 0. Entry hooks
also fire for the guest functions a pass itself calls, the plugin's own
included, so a hook that plans passes must ignore entries made inside one.
@@ -61,7 +63,7 @@ budget aside (an empty plan while it says `READY` was shed for time):
| `NO_PRESENTER` (1) | not OpenGL, interpolation off or suspended (FMV), or not the FLIP source | until the presenter changes |
| `BACKEND` (2) | the renderer declines passes in its current mode | while that mode lasts |
| `DISABLED` (3) | switched off after repeated faults | the session |
| `SESSION` (4) | netplay, rollback, rewind, load/save replay, self-check resimulation | transient |
| `SESSION` (4) | netplay rollback resimulation, rewind, load/save replay, self-check resimulation | transient |
| `FAST_FORWARD` (5) | manual fast-forward, turbo-through-loads, FMV auto-skip, TCP turbo | transient |
| `BUSY` (6) | inside an exception, a pass or a GPU DMA walk, or no frame captured since the presenter's history restarted (a display mode change) | transient |
@@ -223,8 +225,9 @@ After 8 faults (watchdog or refused writes) passes stay off for the session.
## Gates
The plan returns 0 in netplay, rollback resimulation, self-check, rewind,
lockstep, an exception, while a GPU DMA list is in flight, during manual
The plan returns 0 during a netplay rollback resimulation (live netplay
frames may have passes: a pass leaves both peers' simulation untouched),
self-check, rewind, lockstep, an exception, while a GPU DMA list is in flight, during manual
fast-forward, turbo-through-loads, FMV auto-skip and TCP turbo, on anything
but the OpenGL renderer with FLIP-source interpolation, while the presenter
is suspended (FMV), after repeated faults, and when no VBlank was presented
+9
View File
@@ -272,6 +272,15 @@ struct ModPackage {
std::string source_url;
std::string resolver = "declarative";
std::string save_compatibility = "shared";
/* "off" (default): netplay runs vanilla without this package.
* "presentation": the package only changes what the local player sees
* (frame rate, presentation), never the simulation, so it stays active in
* netplay. It may carry plugins and resources but no patches, overlays or
* derived discs, and in a netplay session its plugins run under the
* presentation guards (mod_runtime.cpp): guest writes and simulation
* settings are refused, and a callback that changes guest state anyway is
* undone or its plugin disabled. Peers need not agree on these packages. */
std::string netplay = "off";
/* Default channel for features that do not declare their own. */
ModChannel channel = ModChannel::Stable;
ModPackageOrigin origin = ModPackageOrigin::Installed;
+3
View File
@@ -49,6 +49,9 @@ extern uint32_t g_psx_ram_mask;
uint32_t memory_get_ram_bytes(void);
int psx_ram_8mb_active(void);
void psx_ram_reset_size_request(void);
/* Request the 8 MB map for the next memory_init() (0 = retail 2 MB);
* plugins use psx_mod_set_main_ram_8mb (mod_runtime.cpp). */
void psx_ram_request_8mb(int enabled);
/* memory_init(): latch the requested geometry for this boot. */
void psx_ram_apply_size_request(void);
+10
View File
@@ -1586,8 +1586,13 @@ static std::string session_disc_path(const std::filesystem::path& stock_disc) {
return mod_disc.string();
}
/* mod_runtime.cpp: refuse a simulation-affecting call in a netplay
* presentation session. */
extern "C" int psx_mod_netplay_refuse(const char* api, int outside_pass_only);
extern "C" int psx_mod_set_native_vblank_rate(
uint32_t frames_per_second) {
if (psx_mod_netplay_refuse("psx_mod_set_native_vblank_rate", 0)) return 0;
if (frames_per_second != 0 &&
(frames_per_second < 60 || frames_per_second > 1000)) {
std::fprintf(stderr,
@@ -1693,6 +1698,7 @@ extern "C" int psx_mod_set_frame_interpolation_source(uint32_t source) {
}
extern "C" int psx_mod_set_auto_skip_fmv(int enabled) {
if (psx_mod_netplay_refuse("psx_mod_set_auto_skip_fmv", 0)) return 0;
if (enabled != 0 && enabled != 1) {
std::fprintf(stderr,
"psxrecomp: mod rejected invalid auto-skip-FMV value %d\n",
@@ -1719,6 +1725,7 @@ extern "C" int psx_mod_set_bezel_artwork(const char* path) {
extern "C" int psx_mod_set_load_acceleration(
uint32_t wall_clock_multiplier, uint32_t release_frames) {
if (psx_mod_netplay_refuse("psx_mod_set_load_acceleration", 0)) return 0;
/* Host pacing only changes how fast wall-clock time is fed to a load; every
* guest frame, CD deadline, interrupt and callback still happens, so a high
* multiplier cannot desync the guest -- it just approaches "as fast as the
@@ -1738,6 +1745,7 @@ extern "C" int psx_mod_set_load_acceleration(
extern "C" int psx_mod_set_disc_speed(
uint32_t divisor, uint32_t instant_max_per_frame) {
if (psx_mod_netplay_refuse("psx_mod_set_disc_speed", 0)) return 0;
/* Any positive divisor is arithmetically safe: cdrom.c divides the sector
* delay by it and floors the result at CDROM_MIN_DELAY, and XA streaming
* keeps authentic timing regardless. The old 2-or-4 allowlist was policy,
@@ -1762,6 +1770,7 @@ extern "C" int psx_mod_set_disc_speed(
extern "C" int psx_mod_set_controller_mode_override(
uint32_t player, uint32_t controller_mode) {
if (psx_mod_netplay_refuse("psx_mod_set_controller_mode_override", 0)) return 0;
if (player >= PSX_MAX_PLAYERS ||
(controller_mode != (uint32_t)PSXRecompV4::PAD_MODE_ANALOG &&
controller_mode != (uint32_t)PSXRecompV4::PAD_MODE_DIGITAL)) {
@@ -1780,6 +1789,7 @@ extern "C" int psx_mod_set_controller_presentation_policy(
PSXModControllerPresentationCallback callback,
uint32_t initial_mode,
int config_capable) {
if (psx_mod_netplay_refuse("psx_mod_set_controller_presentation_policy", 0)) return 0;
if (player >= PSX_MAX_PLAYERS || !callback ||
(initial_mode != (uint32_t)PSXRecompV4::PAD_MODE_ANALOG &&
initial_mode != (uint32_t)PSXRecompV4::PAD_MODE_DIGITAL)) {
+12
View File
@@ -1698,6 +1698,8 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out,
cfg.contains("resolver") ? toml::find<std::string>(cfg, "resolver") : "declarative";
out.save_compatibility = cfg.contains("save_compatibility")
? toml::find<std::string>(cfg, "save_compatibility") : "shared";
out.netplay = cfg.contains("netplay")
? toml::find<std::string>(cfg, "netplay") : "off";
/* Accepted at every format version: v5 manifests already carried a
* package-level channel, enforced then only by a packaging grep over
* the manifest text. Now it parses, and it seeds the per-feature
@@ -1719,6 +1721,8 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out,
throw std::runtime_error("resolver must be declarative or builtin:<id>");
if (out.save_compatibility != "shared" && out.save_compatibility != "isolated")
throw std::runtime_error("save_compatibility must be shared or isolated");
if (out.netplay != "off" && out.netplay != "presentation")
throw std::runtime_error("netplay must be off or presentation");
if (cfg.contains("target")) {
for (const toml::value& v : toml::find(cfg, "target").as_array()) {
@@ -2564,6 +2568,14 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out,
out.derived_discs.push_back(std::move(derived));
}
}
/* A netplay presentation package must not be able to change what
* either peer simulates: code only, through the guarded plugin API. */
if (out.netplay == "presentation" &&
(!out.patches.empty() || !out.overlays.empty() ||
!out.derived_discs.empty()))
throw std::runtime_error(
"a netplay = \"presentation\" package may not carry patches, "
"overlays or derived discs");
return true;
} catch (const std::exception& ex) {
set_error(error, path.string() + ": " + ex.what());
+165 -7
View File
@@ -38,6 +38,12 @@
#endif
extern "C" uint8_t psx_read_byte(uint32_t addr);
extern "C" uint64_t g_guest_store_count;
extern "C" int g_psx_render_pass_active;
extern "C" int psx_mod_netplay_refuse(const char* api, int outside_pass_only);
/* 1 while the committed plan is a netplay presentation plan. */
extern "C" int g_psx_mod_netplay_presentation;
int g_psx_mod_netplay_presentation = 0;
extern "C" void psx_write_byte(uint32_t addr, uint8_t value);
extern "C" uint16_t psx_read_half(uint32_t addr);
extern "C" void psx_write_half(uint32_t addr, uint16_t value);
@@ -89,6 +95,9 @@ struct RuntimeMods {
const ModResolution::Plugin* current_plugin = nullptr;
CPUState* current_function_cpu = nullptr;
bool current_function_finished = false;
/* Plugins of netplay = "presentation" packages that broke the
* presentation contract this session (see NetplayPresentationGuard). */
std::vector<const ModResolution::Plugin*> netplay_disabled;
};
RuntimeMods& state() {
@@ -123,6 +132,60 @@ public:
PluginCallbackScope& operator=(const PluginCallbackScope&) = delete;
};
/* Netplay presentation sessions (mod_runtime_clear_for_netplay keeps the
* plugins of netplay = "presentation" packages). Each peer may run different
* presentation packages, so none of them may change what the peer simulates.
* The API refuses every simulation-affecting call (psx_mod_netplay_refuse),
* and this guard wraps every plugin callback: a callback that leaves the CPU
* changed is undone, and one that stored to guest memory (undoable only by
* the plugin itself) disables its plugin for the session. Render passes are
* sandboxed and restore both, so a callback that runs passes is unaffected. */
bool plugin_netplay_disabled(const RuntimeMods& s,
const ModResolution::Plugin* plugin) {
return std::find(s.netplay_disabled.begin(), s.netplay_disabled.end(),
plugin) != s.netplay_disabled.end();
}
class NetplayPresentationGuard {
RuntimeMods& runtime;
const ModResolution::Plugin* plugin;
CPUState* cpu;
bool active;
CPUState before{};
uint64_t stores = 0;
public:
NetplayPresentationGuard(RuntimeMods& s, const ModResolution::Plugin* p,
CPUState* c)
: runtime(s), plugin(p), cpu(c),
active(g_psx_mod_netplay_presentation != 0) {
if (!active) return;
if (cpu) before = *cpu;
stores = g_guest_store_count;
}
~NetplayPresentationGuard() {
if (!active) return;
if (cpu && std::memcmp(cpu, &before, sizeof before) != 0) {
*cpu = before;
psx_mod_counter_add("netplay.cpu_restored", 1);
std::fprintf(stderr,
"psxrecomp: netplay: presentation plugin %s changed "
"CPU state; undone\n",
plugin ? plugin->id.c_str() : "?");
}
if (g_guest_store_count != stores && plugin &&
!plugin_netplay_disabled(runtime, plugin)) {
runtime.netplay_disabled.push_back(plugin);
psx_mod_counter_add("netplay.plugin_disabled", 1);
std::fprintf(stderr,
"psxrecomp: netplay: presentation plugin %s wrote "
"guest memory; disabled for this session\n",
plugin->id.c_str());
}
}
NetplayPresentationGuard(const NetplayPresentationGuard&) = delete;
NetplayPresentationGuard& operator=(const NetplayPresentationGuard&) = delete;
};
/* Function-entry hooks of the ACTIVE plan, flattened at plugin activation into
* one table sorted by code key (address with the segment bits stripped, so a
* KUSEG or KSEG1 PC reaches the same hook as KSEG0). The interpreter consults
@@ -1226,6 +1289,8 @@ bool mod_runtime_initialize(const std::filesystem::path& root,
RuntimeMods& s = state();
s.manager.set_root({});
clear_function_entry_hooks();
s.netplay_disabled.clear();
g_psx_mod_netplay_presentation = 0;
s.disc_extents.clear();
s.extent_start = 0;
s.plan = {};
@@ -1294,12 +1359,71 @@ bool mod_runtime_clear_for_netplay(std::string* error) {
s.main_applied = false;
s.disc_enabled = false;
s.disc_guard_failed = false;
s.netplay_disabled.clear();
g_psx_mod_netplay_presentation = 0;
s.error.clear();
if (error) error->clear();
std::fprintf(stdout, "psxrecomp: mods cleared for netplay (vanilla session)\n");
/* The simulation stays vanilla on both peers. What survives is the
* plugins (and their resources) of enabled netplay = "presentation"
* packages: they change only what this player sees, so peers need not
* agree on them, and the presentation guards hold them to that. A plan
* that does not resolve keeps the session fully vanilla. */
ModResolution full =
s.manager.resolve(s.game_id, s.exe_sha256, s.disc_sha256);
ModResolution kept;
if (full.ok) {
auto presentation = [&](const std::string& package_id) {
const ModPackage* package = s.manager.selected_package(package_id);
return package && package->netplay == "presentation";
};
for (const ModResolution::Plugin& plugin : full.plugins)
if (presentation(plugin.package_id)) kept.plugins.push_back(plugin);
for (const ModResolution::Resource& resource : full.resources)
if (presentation(resource.package_id))
kept.resources.push_back(resource);
for (const auto& selection : full.selections)
if (presentation(selection.first)) kept.selections.insert(selection);
}
if (kept.plugins.empty()) {
std::fprintf(stdout,
"psxrecomp: mods cleared for netplay (vanilla session)\n");
return true;
}
kept.ok = true;
kept.fingerprint = "netplay-presentation";
s.plan = std::move(kept);
g_psx_mod_netplay_presentation = 1;
for (const ModResolution::Plugin& plugin : s.plan.plugins)
std::fprintf(stdout,
"psxrecomp: netplay keeps presentation plugin %s (%s/%s)\n",
plugin.id.c_str(), plugin.package_id.c_str(),
plugin.feature_id.c_str());
std::fprintf(stdout, "psxrecomp: mods cleared for netplay (vanilla "
"simulation; presentation plugins kept)\n");
return true;
}
/* A simulation-affecting mod API call in a netplay presentation session.
* `outside_pass_only`: guest memory access is sandboxed inside a render pass
* (restored afterwards), so it is refused only outside one; settings are
* never sandboxed and are always refused. */
extern "C" int psx_mod_netplay_refuse(const char* api, int outside_pass_only) {
if (!g_psx_mod_netplay_presentation) return 0;
if (outside_pass_only && g_psx_render_pass_active) return 0;
psx_mod_counter_add("netplay.refused", 1);
static std::vector<std::string> named;
const std::string name = api ? api : "?";
if (std::find(named.begin(), named.end(), name) == named.end()) {
named.push_back(name);
std::fprintf(stderr,
"psxrecomp: netplay: %s refused for a presentation "
"plugin (it would change the simulation)\n",
name.c_str());
}
return 1;
}
bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* error) {
RuntimeMods& s = state();
if (!s.initialized) return true;
@@ -1360,6 +1484,8 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err
s.disc_extents.clear();
s.extent_start = 0;
s.plan = std::move(plan);
s.netplay_disabled.clear();
g_psx_mod_netplay_presentation = 0;
build_disc_index(s);
s.effective_disc_path = std::move(effective_disc);
s.main_applied = false;
@@ -1453,6 +1579,7 @@ extern "C" void mod_runtime_enable_disc_patches(void) {
extern "C" int psx_mod_append_disc_extent(const char* resource_id,
uint64_t byte_offset, uint32_t sector_count, uint32_t* first_lba) {
if (psx_mod_netplay_refuse("psx_mod_append_disc_extent", 0)) return 0;
using namespace PSXRecompV4;
if (first_lba) *first_lba = 0;
auto& s = state();
@@ -1559,6 +1686,7 @@ extern "C" void mod_runtime_activate_plugins(void) {
s.activating = true;
for (const ModResolution::Plugin& plugin : s.plan.plugins) {
PluginCallbackScope scope(s, &plugin);
NetplayPresentationGuard guard(s, &plugin, nullptr);
mod_invoke_activation_plugin(plugin.id);
}
s.activating = false;
@@ -1571,7 +1699,9 @@ extern "C" void mod_runtime_on_vblank(void) {
RuntimeMods& s = state();
if (!s.initialized || !s.plan.ok) return;
for (const ModResolution::Plugin& plugin : s.plan.plugins) {
if (plugin_netplay_disabled(s, &plugin)) continue;
PluginCallbackScope scope(s, &plugin);
NetplayPresentationGuard guard(s, &plugin, nullptr);
mod_invoke_vblank_plugin(plugin.id);
}
}
@@ -1651,6 +1781,7 @@ extern "C" int psx_mod_current_resource_bytes(const char* resource_id,
}
extern "C" void psx_mod_write_byte(uint32_t address, uint8_t value) {
if (psx_mod_netplay_refuse("psx_mod_write_byte", 1)) return;
psx_host_write_byte(address, value);
}
@@ -1659,6 +1790,7 @@ extern "C" uint16_t psx_mod_read_half(uint32_t address) {
}
extern "C" void psx_mod_write_half(uint32_t address, uint16_t value) {
if (psx_mod_netplay_refuse("psx_mod_write_half", 1)) return;
psx_host_write_half(address, value);
}
@@ -1667,21 +1799,31 @@ extern "C" uint32_t psx_mod_read_word(uint32_t address) {
}
extern "C" void psx_mod_write_word(uint32_t address, uint32_t value) {
if (psx_mod_netplay_refuse("psx_mod_write_word", 1)) return;
psx_host_write_word(address, value);
}
extern "C" void psx_mod_write_code_word(uint32_t address, uint32_t value) {
if (psx_mod_netplay_refuse("psx_mod_write_code_word", 0)) return;
psx_host_write_word(address, value);
dirty_ram_mark_executable_range(address & 0x1FFFFFFFu, 4u);
}
extern "C" int psx_mod_set_main_ram_8mb(int enabled) {
if (psx_mod_netplay_refuse("psx_mod_set_main_ram_8mb", 0)) return 0;
psx_ram_request_8mb(enabled);
return 1;
}
extern "C" uint32_t psx_mod_alloc_guest_memory(uint32_t size,
uint32_t alignment) {
if (psx_mod_netplay_refuse("psx_mod_alloc_guest_memory", 0)) return 0;
return psx_mod_memory_alloc(size, alignment);
}
extern "C" uint32_t psx_mod_alloc_gpu_dma_memory(uint32_t size,
uint32_t alignment) {
if (psx_mod_netplay_refuse("psx_mod_alloc_gpu_dma_memory", 0)) return 0;
return psx_mod_gpu_dma_memory_alloc(size, alignment);
}
@@ -1784,6 +1926,7 @@ extern "C" int psx_mod_register_function_filter_plugin(
}
extern "C" int psx_mod_finish_function(CPUState* cpu) {
if (psx_mod_netplay_refuse("psx_mod_finish_function", 0)) return 0;
using namespace PSXRecompV4;
RuntimeMods& s = state();
if (!cpu || s.current_function_cpu != cpu || !s.current_plugin) return 0;
@@ -1797,6 +1940,7 @@ extern "C" int psx_mod_register_guest_function_plugin(
}
extern "C" int psx_mod_dispatch_guest_function(CPUState* cpu, uint32_t address) {
if (psx_mod_netplay_refuse("psx_mod_dispatch_guest_function", 1)) return 0;
using namespace PSXRecompV4;
if (!g_psx_mod_guest_functions || !cpu || address >= 0xC0000000u) return 0;
const auto& functions = active_guest_functions();
@@ -1824,9 +1968,13 @@ extern "C" void psx_mod_instruction(CPUState* cpu, uint32_t address, uint32_t in
[](const auto& h, uint32_t k) { return h.key < k; });
for (; it != hooks.end() && it->key == key; ++it) {
if (it->expected != instruction || psx_mod_read_word(address) != instruction) continue;
if (plugin_netplay_disabled(state(), it->plugin)) continue;
PluginCallbackScope scope(state(), it->plugin, nullptr);
const uint32_t pc = cpu->pc;
it->callback(cpu, address);
{
NetplayPresentationGuard guard(state(), it->plugin, cpu);
it->callback(cpu, address);
}
if (cpu->pc != pc) std::abort();
cpu->gpr[0] = 0;
}
@@ -1842,13 +1990,23 @@ extern "C" int psx_mod_function_entry(CPUState* cpu, uint32_t address) {
table.begin(), table.end(), key,
[](const ActiveFunctionEntryHook& hook, uint32_t k) { return hook.key < k; });
for (; it != table.end() && it->key == key; ++it) {
if (plugin_netplay_disabled(s, it->plugin)) continue;
PluginCallbackScope scope(s, it->plugin, cpu);
++function_entry_depth;
if (it->callback) it->callback(cpu, address);
/* Either completion form skips the body: an entry callback that
* called psx_mod_finish_function(), or a filter returning nonzero. */
const bool handled = s.current_function_finished ||
(it->filter && it->filter(cpu, address));
bool handled;
{
NetplayPresentationGuard guard(s, it->plugin, cpu);
if (it->callback) it->callback(cpu, address);
/* Either completion form skips the body: an entry callback that
* called psx_mod_finish_function(), or a filter returning
* nonzero. Neither may skip guest code in a netplay presentation
* session (psx_mod_finish_function refuses there). */
const bool filtered = it->filter && it->filter(cpu, address);
if (filtered && psx_mod_netplay_refuse("function filter", 0))
handled = false;
else
handled = s.current_function_finished || filtered;
}
--function_entry_depth;
if (handled) {
cpu->pc = cpu->gpr[31];
+3
View File
@@ -54,7 +54,10 @@ const uint16_t* mod_texture_bank_pixels(uint16_t id, uint32_t* w, uint32_t* h) {
return banks[id].pixels;
}
int psx_mod_netplay_refuse(const char *api, int outside_pass_only);
uint32_t psx_mod_alloc_texture_packet_memory(uint32_t size, uint32_t alignment) {
if (psx_mod_netplay_refuse("psx_mod_alloc_texture_packet_memory", 0)) return 0;
uint32_t addr;
if (arena_count == 16u || size < 40u) return 0;
addr = psx_mod_gpu_dma_memory_alloc(size, alignment);
+3 -2
View File
@@ -29,9 +29,10 @@ void psx_ram_reset_size_request(void) {
s_ram_8mb_requested = 0;
}
int psx_mod_set_main_ram_8mb(int enabled) {
/* The request behind psx_mod_set_main_ram_8mb (mod_runtime.cpp owns the mod
* API and its netplay guard). */
void psx_ram_request_8mb(int enabled) {
s_ram_8mb_requested = enabled ? 1 : 0;
return 1;
}
void psx_ram_apply_size_request(void) {
+9 -4
View File
@@ -11,9 +11,10 @@
* place (memory.c render_pass_store).
*
* Nothing here runs unless a trusted plugin calls the API, and the plan
* refuses in netplay, rollback, rewind, fast-forward, self-check
* refuses during rollback resimulation, rewind, fast-forward, self-check
* resimulation, or without the OpenGL presenter's flip-aware interpolation
* (psx_mod_render_pass_status says which). */
* (psx_mod_render_pass_status says which). Live netplay frames may have
* passes: they leave both peers' simulation untouched. */
#include "render_pass.h"
@@ -51,7 +52,6 @@ extern uint32_t interrupts_get_cycles_since_vblank(void);
extern void interrupts_set_cycles_since_vblank(uint32_t v);
extern void psx_irq_refresh_cause_ip2(void);
extern int psx_get_in_exception(void);
extern int psx_netplay_active(void);
extern int psx_netplay_is_resimulating(void);
extern int psx_selfcheck_resim_active(void);
extern int psx_rewind_is_open(void);
@@ -295,7 +295,12 @@ int psx_mod_set_render_pass_flip(uint32_t mode) {
static uint32_t pass_status(void) {
uint32_t gl;
if (s_stats.disabled) return PSX_MOD_RENDER_PASS_DISABLED;
if (psx_netplay_active() || psx_netplay_is_resimulating() ||
/* Netplay itself is fine: a pass restores the whole guest machine, so
* neither peer's simulation sees it, and its plugin runs under the
* netplay presentation guards (mod_runtime.cpp). A rollback
* resimulation replays ticks the player has already seen, so it never
* draws. */
if (psx_netplay_is_resimulating() ||
psx_selfcheck_resim_active() || psx_rewind_is_open() ||
g_ls_mode || g_ls_replay_active)
return PSX_MOD_RENDER_PASS_SESSION;
+115
View File
@@ -76,6 +76,10 @@ 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) {}
static int ram_8mb_requests;
extern "C" void psx_ram_request_8mb(int) { ram_8mb_requests++; }
extern "C" { int g_psx_render_pass_active; uint64_t g_guest_store_count;
extern int g_psx_mod_netplay_presentation; }
extern "C" int psx_ws_x_margin(void) { return 0; }
/* Stand-in for the GPU's display geometry. psx_mod_display_width/height must
@@ -177,6 +181,32 @@ static void test_guest_function(CPUState* cpu, uint32_t) {
if (cpu->gpr[31] != ra) failures++;
}
/* Netplay presentation package: what its plugin may and may not do. */
static int pres_activations, sim_activations, pres_entries, pres_ram_ok;
static uint32_t pres_alloc;
static int pres_mode;
static void test_pres_activation(void) {
pres_activations++;
pres_ram_ok = psx_mod_set_main_ram_8mb(1);
pres_alloc = psx_mod_alloc_guest_memory(64, 4);
}
static void test_sim_activation(void) { sim_activations++; }
static void test_pres_entry(CPUState* cpu, uint32_t) {
pres_entries++;
if (pres_mode == 0) { /* tries to change the simulation */
cpu->gpr[2] = 0xDEADBEEFu;
psx_mod_write_word(0x80001000u, 0x11111111u);
(void)psx_mod_finish_function(cpu);
} else if (pres_mode == 1) { /* sandboxed pass: writes allowed */
g_psx_render_pass_active = 1;
psx_mod_write_word(0x80001004u, 0x22222222u);
g_psx_render_pass_active = 0;
psx_mod_write_word(0x80001004u, 0u); /* the pass's restore */
} else { /* a guest store behind the API */
g_guest_store_count++;
}
}
static void test_activation_plugin(void) {
activation_calls++;
}
@@ -1027,6 +1057,91 @@ int main() {
check(iso_sector_count(mounted)==24&&iso_track_count(mounted)==1&&
!iso_read_raw_sector(mounted,24,sector.data(),sector.size()),"clearing plan removes all donor sectors and restores TOC");
iso_close(mounted);
/* Netplay presentation packages survive the netplay clear; everything
* else stays vanilla, and the presentation plugin cannot touch the
* simulation. */
{
const fs::path pres_root = root / "pres";
write_text(pres_root / "packages/pres.test/1.0.0/manifest.toml",
"format_version = 5\nid = \"pres.test\"\nversion = \"1.0.0\"\n"
"name = \"Presentation\"\nnetplay = \"presentation\"\n"
"[[target]]\ngame_id = \"PRES\"\n"
"[[feature]]\nid = \"on\"\nname = \"On\"\ndefault_enabled = true\n"
"[[plugin]]\nfeature = \"on\"\nid = \"pres.test.plugin\"\n");
write_text(pres_root / "packages/sim.test/1.0.0/manifest.toml",
"format_version = 5\nid = \"sim.test\"\nversion = \"1.0.0\"\n"
"name = \"Simulation\"\n"
"[[target]]\ngame_id = \"PRES\"\n"
"[[feature]]\nid = \"on\"\nname = \"On\"\ndefault_enabled = true\n"
"[[plugin]]\nfeature = \"on\"\nid = \"sim.test.plugin\"\n");
write_text(pres_root / "state.toml", "format_version = 2\n");
check(psx_mod_register_activation_plugin("pres.test.plugin",
test_pres_activation) &&
psx_mod_register_activation_plugin("sim.test.plugin",
test_sim_activation) &&
psx_mod_register_function_entry_plugin("pres.test.plugin",
0x80006000u, test_pres_entry) == 1,
"register presentation test plugins");
check(PSXRecompV4::mod_runtime_initialize(pres_root, "PRES", 0, {}, &error),
"presentation initialize");
check(PSXRecompV4::mod_runtime_clear_for_netplay(&error), error.c_str());
check(g_psx_mod_netplay_presentation == 1,
"a netplay session keeps an enabled presentation package");
ram_8mb_requests = 0;
mod_runtime_activate_plugins();
check(pres_activations == 1 && sim_activations == 0,
"netplay activates presentation plugins only");
check(!pres_ram_ok && ram_8mb_requests == 0 && pres_alloc == 0,
"netplay refuses simulation settings and guest allocation");
CPUState pc{};
pc.gpr[2] = 7; pc.gpr[31] = 0x80007000u; pc.pc = 0x80006000u;
g_psx_mod_netplay_presentation = 0; /* the test's own setup write */
psx_mod_write_word(0x80001000u, 0u);
g_psx_mod_netplay_presentation = 1;
pres_mode = 0;
check(!psx_mod_function_entry(&pc, 0x80006000u) && pres_entries == 1,
"a presentation hook cannot skip guest code in netplay");
check(pc.gpr[2] == 7 && pc.pc == 0x80006000u,
"a presentation hook's CPU change is undone in netplay");
check(psx_read_word(0x80001000u) == 0u,
"a presentation hook's guest write is refused outside a pass");
pres_mode = 1;
check(!psx_mod_function_entry(&pc, 0x80006000u) && pres_entries == 2,
"a presentation hook may run a sandboxed pass");
pres_mode = 2;
psx_mod_function_entry(&pc, 0x80006000u);
psx_mod_function_entry(&pc, 0x80006000u);
check(pres_entries == 3,
"a plugin that stores to guest memory is disabled for the session");
check(PSXRecompV4::mod_runtime_commit({}, &error), error.c_str());
check(g_psx_mod_netplay_presentation == 0,
"an offline commit ends the presentation session");
mod_runtime_activate_plugins();
check(sim_activations == 1 && pres_activations == 2 && pres_ram_ok == 1,
"offline, every enabled package activates with its full API");
psx_mod_function_entry(&pc, 0x80006000u);
check(pres_entries == 4, "offline, the disabled-in-netplay plugin runs again");
PSXRecompV4::ModPackage bad;
write_text(pres_root / "bad/manifest.toml",
"format_version = 5\nid = \"bad.test\"\nversion = \"1.0.0\"\n"
"name = \"Bad\"\nnetplay = \"presentation\"\n"
"[[target]]\ngame_id = \"PRES\"\n"
"[[feature]]\nid = \"on\"\nname = \"On\"\n"
"[[patch]]\nfeature = \"on\"\ntarget = \"main_exe\"\naddress = 2147549184\n"
"expected = \"00 00 00 00\"\nreplace = \"01 00 00 00\"\n");
check(!PSXRecompV4::ModPackageManager::read_manifest(
pres_root / "bad/manifest.toml", bad),
"a presentation package may not patch the game");
write_text(pres_root / "bad2/manifest.toml",
"format_version = 5\nid = \"bad.test\"\nversion = \"1.0.0\"\n"
"name = \"Bad\"\nnetplay = \"sometimes\"\n"
"[[target]]\ngame_id = \"PRES\"\n");
check(!PSXRecompV4::ModPackageManager::read_manifest(
pres_root / "bad2/manifest.toml", bad),
"netplay must be off or presentation");
}
fs::remove_all(root, ec);
if (failures) return 1;
std::cout << "mod runtime tests passed\n";
+2
View File
@@ -5,6 +5,8 @@
#include <stdlib.h>
#include <string.h>
#include <math.h>
/* Netplay presentation guard (mod_runtime.cpp): never in a netplay session here. */
int psx_mod_netplay_refuse(const char *api, int outside_pass_only) { (void)api; (void)outside_pass_only; return 0; }
static uint32_t memory[256], used, reads, resolves;
uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t n,uint32_t a) {
(void)a; if(n>sizeof memory-used)return 0;
+2 -2
View File
@@ -52,7 +52,7 @@ static int check(int cond, const char *label) {
static void set_geometry(int expanded) {
psx_ram_reset_size_request();
if (expanded) psx_mod_set_main_ram_8mb(1);
if (expanded) psx_ram_request_8mb(1);
psx_ram_apply_size_request();
}
@@ -277,7 +277,7 @@ int main(void) {
check_helpers_everywhere(0, "memory_init without the mod stays retail");
/* The request is inert until memory_init() applies it. */
psx_ram_reset_size_request();
check(psx_mod_set_main_ram_8mb(1) == 1, "8 MB request accepted");
psx_ram_request_8mb(1);
check_helpers_everywhere(0, "8 MB request is inert before memory_init");
psx_ram_apply_size_request();
check_helpers_everywhere(1, "8 MB request applies unique 8 MiB decode");