diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index c6e5aedd..d3d8635f 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -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 diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index 3090994e..cc9d395f 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -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 diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 3c25c7cb..6af39221 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -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; diff --git a/runtime/include/psx_memory.h b/runtime/include/psx_memory.h index 07c9a7ae..0b44e020 100644 --- a/runtime/include/psx_memory.h +++ b/runtime/include/psx_memory.h @@ -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); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index af600119..9aace45a 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -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)) { diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index a269750a..00b35e25 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -1698,6 +1698,8 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, cfg.contains("resolver") ? toml::find(cfg, "resolver") : "declarative"; out.save_compatibility = cfg.contains("save_compatibility") ? toml::find(cfg, "save_compatibility") : "shared"; + out.netplay = cfg.contains("netplay") + ? toml::find(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:"); 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()); diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 2ad10627..c66a01b1 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -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 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 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]; diff --git a/runtime/src/mod_texture_banks.c b/runtime/src/mod_texture_banks.c index d5767db1..daf57904 100644 --- a/runtime/src/mod_texture_banks.c +++ b/runtime/src/mod_texture_banks.c @@ -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); diff --git a/runtime/src/psx_ram_geometry.c b/runtime/src/psx_ram_geometry.c index 3df4da38..e5c35321 100644 --- a/runtime/src/psx_ram_geometry.c +++ b/runtime/src/psx_ram_geometry.c @@ -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) { diff --git a/runtime/src/render_pass.c b/runtime/src/render_pass.c index 39547986..829f5573 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -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; diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 76869ec2..48139f54 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -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"; diff --git a/runtime/tests/test_mod_texture_banks.c b/runtime/tests/test_mod_texture_banks.c index 2bd255f5..5db9ee2b 100644 --- a/runtime/tests/test_mod_texture_banks.c +++ b/runtime/tests/test_mod_texture_banks.c @@ -5,6 +5,8 @@ #include #include #include +/* 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; diff --git a/runtime/tests/test_psx_ram_runtime_map.c b/runtime/tests/test_psx_ram_runtime_map.c index 320a586a..336106a0 100644 --- a/runtime/tests/test_psx_ram_runtime_map.c +++ b/runtime/tests/test_psx_ram_runtime_map.c @@ -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");