diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index e24136b3..5541e44b 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -534,7 +534,9 @@ in-process second session is the lobby rematch: a netplay match launched from the lobby returns to the lobby launcher when it ends, and the next launch from there, netplay or offline, re-enters the emulator in the same process. The session before a rematch is therefore always a netplay match, which ran with -the plan cleared, so no plugin activated in it. +the plan cleared, so no plugin activated in it -- except own-view plugins +(`[[plugin]] netplay = "local_view"`, docs/NETPLAY.md "Own-view mods"), whose +setters the reset below covers like any other. The reset is one step of the session start that every session runs, immediately before activation (see *A rematch is a full session start* below). diff --git a/docs/NETPLAY.md b/docs/NETPLAY.md index 0b191872..2c3bf756 100644 --- a/docs/NETPLAY.md +++ b/docs/NETPLAY.md @@ -158,7 +158,39 @@ 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) + +Some titles draw every player's view into one frame (split screen, or a link +mode that renders all seats). Online, every peer still renders and digests +that identical frame; only what its window shows changes: + +- `game.toml [netplay] local_viewport = "vertical_split"`: during netplay, seat + 0 or 1 presents its own half of a detected vertical split. +- `psx_netplay_present_local_view(x, y, w, h)` (`psx_netplay.h`): a trusted + game plugin names this peer's rectangle of the display area (guest pixels, + relative to the GP1(05h) display start), usually from + `psx_netplay_local_slot()`. The present path shows that rectangle alone, + scaled to the window at 4:3, from the GL internal-resolution surface (or the + software frame). The request lasts a few simulation ticks + (`PSX_NETPLAY_LOCAL_VIEW_HOLD`), so the plugin renews it every frame its + multi-view screen is up and menus fall back to the full frame by + themselves. It wins over `local_viewport`, never reaches the guest or a + savestate, and is ignored offline. The headless present-image ring records + the same rectangle, which is how a harness checks it. Vulkan, which netplay + replaces with the software present, ignores it. +- `psx_mod_render_local_view(cpu, rect, fn, user)` (`mod_plugins.h`): this + peer draws its *own* image of a display rect with the game's code, for + example its seat's single full-screen view, inside the render-pass sandbox + (docs/RENDER_PASSES.md, "Netplay local view"). The machine is restored + afterwards, so the canonical frame stays in the authoritative VRAM, rollback + snapshots and digests; only the OpenGL presenter's surface keeps the image. + A committed image cancels any `psx_netplay_present_local_view` crop + (`psx_netplay_local_view_clear`). Forward netplay frames only; a title falls + back to the crop when `psx_mod_render_local_view_status()` is not ready + (software present, Vulkan, resimulation). --- @@ -323,6 +355,40 @@ Generate & rebuild / prepare flows should point at the **`.cue`**, not a lone fingerprint before entering guest execution. Direct launches retain local choices and require matching plans. No assets or local paths are transferred; the guest's saved offline selection survives the session. +### Own-view mods (per player) + +The shared simulation stays stock on every peer; a mod that only changes +what one player sees runs for that player alone, inside the sandboxed own-view +render (`psx_mod_render_local_view`). A package opts in per plugin: + +```toml +[[plugin]] +feature = "widescreen" +id = "r4.widescreen" +netplay = "local_view" +``` + +At a netplay session start the runtime resolves the player's own selection +and keeps only features whose every contribution is such a plugin (no EXE or +disc write, overlay or derived disc; `mod_runtime_commit_netplay_view`). They +activate as usual (presentation state: aspect, scene predicates), but: + +- their function-entry, filter, guest-function and instruction hooks run only + while `psx_mod_local_view_scope()` is 1, i.e. inside a local-view draw, whose + guest-side effects the sandbox discards; vblank and savestate callbacks do + not run in a match; +- `gpu_ws_set_local_view_only(1)`: the widescreen cull margin + (`psx_ws_x_margin()`) is the stock 0 in the shared game and this peer's own + margin only inside the local-view draw; squash widescreen (which changes the + GTE projection) is not used. + +So the canonical frame, rollback snapshots and digests are identical on every +peer whatever each player chose, there is nothing to negotiate or hash, and +each player keeps their own aspect (Fit follows their own window). Nothing is +written to `mods/state.toml`. A title without an own view simply shows its +canonical frame. A plugin must not keep host state that feeds guest writes +outside the scope; anything that persists guest state across frames is not an +own-view mod. --- diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index edc48fea..70dcd5ad 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -230,6 +230,37 @@ the interpreter's active/phase/precise flags, resume latch and pending load its callback's normal return resets. After 8 faults (watchdog or refused writes) passes stay off for the session. +## Netplay local view + +```c +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); +``` + +A title whose netplay frame draws every seat's view (so guest state stays +identical on every peer) can give each player their own seat's single view: +at the point where the next flip will show `rect` and the guest has finished +drawing it, `fn` redraws that rect with the game's code (alpha 0) in the same +sandbox as a pass. Everything a pass restores is restored; the difference is +the product. The OpenGL presenter's surface keeps the rect's colour (the +internal-resolution surface and the native-wide band) until the guest draws +there again, while the stencil, raw mirror, CPU VRAM rows, out-of-rect journal +and coherency state are put back. Its draws skip the software raster, so the +authoritative CPU VRAM never sees them. A committed image cancels any +`psx_netplay_present_local_view()` crop. + +It needs a netplay session on a forward frame and the OpenGL dual-raster +presenter (a surface separate from the authoritative VRAM); it does not need +frame interpolation. The status is `SESSION` offline, while resimulating, in +rewind or lockstep replay; `BACKEND` without dual raster or in the +high-resolution window; `NO_PRESENTER` without an OpenGL surface or with +24-bit scanout; plus the usual `FAST_FORWARD`, `DISABLED` and `BUSY`. +`render_pass_stats` counts `local_attempts` and `local_views` and reports +`local_status`. `PSX_RENDER_PASS_VERIFY=1` checks the guest state as for a +pass (the kept rect is the product, so its VRAM comparison is skipped). +R4's online Link Battle (`src/mods/r4_link_netplay.c`) is the worked example. + ## Gates The plan returns 0 in netplay, rollback resimulation, self-check, rewind, diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index b384c47f..56871c40 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -b55f31a1f8d7abe4b04c0322dc961731687343f4ea14d3e722acdc0979f5e183 +36e72e6875b4a8567d163199dff9ac9f840ef895a780db6ccd3126db4b8493a9 diff --git a/generated/SCPH1001.emitter.sha b/generated/SCPH1001.emitter.sha index 74380ac8..c9b1443c 100644 --- a/generated/SCPH1001.emitter.sha +++ b/generated/SCPH1001.emitter.sha @@ -1 +1 @@ -eaafcbd6af676339d2b3a8c29b7394b8729325ab0ff6c117261b4207d2619735 +c86327fcff32f4cabcb49c990da7e2b2b6af021d6de60877ec1a50f119b92dbc diff --git a/lib/recomp-net b/lib/recomp-net index 0fa06873..52a45a58 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit 0fa0687310e1535b3fed9df38804809626c3ea92 +Subproject commit 52a45a580ce59a13e7d9ec1b57cdea863a247de3 diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 467bf301..52435bba 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -899,6 +899,10 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& rt.multitap_port = static_cast(n); rt.has_multitap_port = true; } + if (ct.contains("multitap")) { + rt.multitap_default = toml::find(ct, "multitap"); + rt.has_multitap_default = true; + } if (ct.contains("multitap_analog")) { rt.multitap_analog = toml::find(ct, "multitap_analog"); rt.has_multitap_analog = true; diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 5ef11904..60abeb92 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -721,6 +721,14 @@ struct RuntimeConfig { bool has_multitap_analog = false; bool multitap_analog = true; + // multitap: title default for the offline multitap (players >= 3). false + // keeps offline play at two standalone pads for a title whose local game + // has no multitap support but whose netplay seats more players (players + // still sets the netplay seat ceiling). settings.toml [controller] + // multitap / the launcher toggle override it. + bool has_multitap_default = false; + bool multitap_default = true; + // legacy_pad_config: per-game pad-protocol compatibility opt-in. false (default) // = the modern DualShock config state machine (proper 0x43 enter/exit, config id // 0xF3 only while in config) — required by MMX6 and the correct default for every diff --git a/recompiler/tests/recompiler_patch_test.cpp b/recompiler/tests/recompiler_patch_test.cpp index bf7206c4..92385937 100644 --- a/recompiler/tests/recompiler_patch_test.cpp +++ b/recompiler/tests/recompiler_patch_test.cpp @@ -157,6 +157,17 @@ p2_mode = "digital" controller_defaults_config.runtime.default_p2_mode == PSXRecompV4::PAD_MODE_DIGITAL, "parser preserves per-game controller device defaults"); + check(!controller_defaults_config.runtime.has_multitap_default, + "offline multitap keeps the framework default when unset"); + + const auto no_multitap = write_config(root, "no-multitap", R"toml( +[controller] +multitap = false +)toml"); + const auto no_multitap_config = PSXRecompV4::load_game_config(no_multitap); + check(no_multitap_config.runtime.has_multitap_default && + !no_multitap_config.runtime.multitap_default, + "parser preserves a title's offline multitap default"); const auto netplay_viewport = write_config(root, "netplay-viewport", R"toml( [runtime] diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 39425e4d..dd00ee9d 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -43,6 +43,13 @@ if(BUILD_TESTING) src/netplay_exit_reason.c) target_include_directories(netplay_exit_reason_test PRIVATE include) add_test(NAME netplay_exit_reason_test COMMAND netplay_exit_reason_test) + add_executable(netplay_sim_pad_cache_test tests/test_netplay_sim_pad_cache.c) + target_include_directories(netplay_sim_pad_cache_test PRIVATE include) + target_compile_definitions(netplay_sim_pad_cache_test PRIVATE PSX_MAX_PLAYERS=4) + add_test(NAME netplay_sim_pad_cache_test COMMAND netplay_sim_pad_cache_test) + add_executable(netplay_local_view_test tests/test_netplay_local_view.c) + target_include_directories(netplay_local_view_test PRIVATE include) + add_test(NAME netplay_local_view_test COMMAND netplay_local_view_test) add_executable(ws_scene_hold_test tests/test_ws_scene_hold.c) target_include_directories(ws_scene_hold_test PRIVATE include) add_test(NAME ws_scene_hold_test COMMAND ws_scene_hold_test) @@ -661,6 +668,20 @@ if(BUILD_TESTING) endif() add_test(NAME ws_shared_split_ui_exec_test COMMAND ws_shared_split_ui_exec_test) + add_executable(ws_local_view_margin_test + tests/test_ws_local_view_margin.c + src/ws_ui_group.c + src/psx_video_timing.c + src/gpu_timeline.c) + target_include_directories(ws_local_view_margin_test PRIVATE include src) + if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(ws_local_view_margin_test PRIVATE + -ffunction-sections -fdata-sections) + target_link_options(ws_local_view_margin_test PRIVATE + $,-Wl$-dead_strip,-Wl$--gc-sections>) + endif() + add_test(NAME ws_local_view_margin_test COMMAND ws_local_view_margin_test) + add_executable(ws_view_anchor_test tests/test_ws_view_anchor.c) add_executable(ws_view_anchor_gpu_test tests/test_ws_view_anchor_gpu.c src/psx_video_timing.c @@ -925,6 +946,31 @@ if(BUILD_TESTING) endif() add_test(NAME mod_runtime_test COMMAND mod_runtime_test) + add_executable(netplay_view_mods_test + tests/test_netplay_view_mods.cpp + src/iso_reader_c.cpp + src/mod_runtime.cpp + src/mod_packages.cpp + src/mod_media.cpp + src/disc_path.cpp + src/cue_sheet.cpp + src/iso_reader.cpp + src/crc32.c + src/psx_sha256.c) + target_include_directories(netplay_view_mods_test PRIVATE + include + ../recompiler/include + ../recompiler/lib/toml11) + target_link_libraries(netplay_view_mods_test PRIVATE chdr-static) + if(WIN32) + if(MSVC) + target_link_options(netplay_view_mods_test PRIVATE /STACK:16777216) + else() + target_link_options(netplay_view_mods_test PRIVATE -Wl,--stack,16777216) + endif() + endif() + add_test(NAME netplay_view_mods_test COMMAND netplay_view_mods_test) + add_executable(mod_resident_test tests/test_mod_resident.cpp src/mod_resident.cpp diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index b596394d..23e4c123 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -282,6 +282,11 @@ int gpu_ws_present_native_43(void); /* Per-side X cull-margin (screen/world units) emitted into the game's draw- * cull immediates by the recompiler ([widescreen.cull]); 0 unless stretching. */ int psx_ws_x_margin(void); +/* Netplay own-view mods: while on, psx_ws_x_margin() is 0 (the stock cull) + * except inside a sandboxed local-view render (render_pass.c sets the scope). */ +void gpu_ws_set_local_view_only(int on); +void gpu_ws_set_local_view_scope(int on); +int gpu_ws_local_view_only(void); void gpu_ws_set_cull_guard_pixels(int pixels); /* Bias/range activation-window margin. This may include an additional * resident-object lead while render/terrain paths retain psx_ws_x_margin(). */ diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index aca7f5b7..de43312f 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -107,6 +107,15 @@ uint64_t gl_renderer_pass_backups_reused(void); uint32_t gl_renderer_pass_image_textures(uint64_t *bytes); /* Debug: dump the images of the next `generations` shown frames as PNGs. */ void gl_renderer_pass_dump_arm(const char *dir, int generations); +/* Netplay local view (psx_mod_render_local_view): the VRAM transaction with + * no generation. Available only with a presenter surface separate from the + * authoritative CPU VRAM (dual raster). While it is open, draws reach only + * that surface. end(keep=1) keeps the rect's presented colour (hr surface and + * native-wide band) and restores everything else (stencil, raw mirror, CPU + * rows, out-of-rect journal, coherency state); keep=0 restores all. */ +uint32_t gl_renderer_local_view_unavailable(void); +int gl_renderer_local_view_begin(int x, int y, int w, int h); +int gl_renderer_local_view_end(int keep); /* Stereo shares the VRAM transaction, not the temporal generation/schedule. */ uint32_t gl_renderer_stereo_unavailable(void); int gl_renderer_stereo_begin(int x, int y, int w, int h, int reuse_backup); diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index e9688acf..79621bc7 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -214,6 +214,15 @@ struct ModPlugin { std::string id; std::map when; int64_t order = 0; + /* [[plugin]] netplay = "local_view": the plugin only changes what this + * player sees. In a netplay match it stays on, per player, but its hooks + * run only inside the sandboxed own-view render + * (psx_mod_render_local_view), never in the shared simulation. */ + bool netplay_local_view = false; + /* netplay = "input": the plugin only transforms this player's own pad + * before it is staged (psx_mod_set_pad_transform). Online it stays on per + * player: the post-transform pad is what every peer simulates. */ + bool netplay_input = false; }; struct ModResource { @@ -367,6 +376,8 @@ struct ModResolution { std::string id; std::string package_id; std::string feature_id; + bool netplay_local_view = false; + bool netplay_input = false; }; std::vector plugins; struct Resource { @@ -474,6 +485,15 @@ public: return packages_; } const std::map& selections() const { return selections_; } + /* Swap the in-memory selection for `next` and return the previous one. + * Never saved by itself: a session that must not run the player's whole + * choice (a netplay match) swaps a reduced selection in, resolves, and + * swaps the player's back before anything can save_state(). */ + std::map exchange_selections( + std::map next) { + selections_.swap(next); + return next; + } const ModPackage* selected_package(const std::string& id) const; const ModFeature* selected_feature(const std::string& package_id, const std::string& feature_id) const; diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 2d7a0a93..045d6887 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -60,6 +60,15 @@ extern uint32_t g_psx_mod_instruction_hooks; int psx_mod_register_function_filter_plugin( const char* id, uint32_t address, PSXModFunctionFilterCallback callback); int psx_mod_function_entry(struct CPUState* cpu, uint32_t address); +/* Trusted game code (not a mod package) may hook guest functions for netplay + * only. These are independent of the mod package plan, which every online + * match clears, and run only while netplay is active; offline execution stays + * stock. A filter returning nonzero consumes the whole call (PC <- $ra); it + * must supply any guest-visible side effects itself. */ +int psx_game_register_netplay_function_entry( + uint32_t address, PSXModFunctionEntryCallback callback); +int psx_game_register_netplay_function_filter( + uint32_t address, PSXModFunctionFilterCallback filter); /* Complete a guest function from its trusted entry callback after supplying * its full result. Valid only for that callback's CPU. Publishes pc=$ra and * prevents the original body from executing. Nested callbacks have separate @@ -538,6 +547,39 @@ enum { PSX_MOD_RENDER_PASS_BUSY = 6 }; uint32_t psx_mod_render_pass_status(void); +/* + * Netplay local view: this peer's own image of a display rect, drawn by the + * game's code inside the render-pass sandbox, replaces what the presenter + * shows of that rect. For a title whose netplay frame draws every seat's view + * (so guest state stays identical on every peer) but whose players should + * each see their own seat's single full-screen view. + * + * `fn` runs as a render pass does (frozen guest time, sandboxed stores, the + * watchdog; CPU with the GTE, RAM, scratchpad, devices and the authoritative + * VRAM restored afterwards) and draws into rect->x/y/w/h; alpha_q16 is 0. When + * it returns nonzero the presenter's own copy of the rect keeps the image + * until the guest draws there again: the canonical frame stays in the + * authoritative VRAM, savestates, rollback snapshots and digests. A committed + * image also cancels any psx_netplay_present_local_view() crop. Call it where + * the next flip will show rect and the guest has finished drawing it. + * + * Only in a netplay session on forward frames, with the OpenGL presenter + * keeping a surface separate from the authoritative VRAM (dual raster); never + * while resimulating, in rewind, lockstep replay, fast-forward, inside an + * exception or a pass. psx_mod_render_local_view_status() says why not, with + * the PSX_MOD_RENDER_PASS_* reasons; a title then shows its canonical frame + * (for example its own view's part of it through + * psx_netplay_present_local_view). Returns 1 when the image was committed. + */ +int psx_mod_render_local_view(struct CPUState* cpu, + const PSXModRenderPass* rect, + PSXModRenderPassFn fn, void* user); +uint32_t psx_mod_render_local_view_status(void); +/* 1 while a psx_mod_render_local_view draw runs. In that scope a netplay + * match's own-view mods ([[plugin]] netplay = "local_view") run their hooks + * and the widescreen cull margin is this peer's; outside it neither touches + * the shared simulation. */ +int psx_mod_local_view_scope(void); /* Simultaneous stereo capture, independent of temporal interpolation. Each * eye starts from the same guest state; CPU/RAM/devices/VRAM are restored * before the other eye and on failure. Publish only after both succeed. diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index 8c82a630..1c965ced 100644 --- a/runtime/include/mod_runtime.h +++ b/runtime/include/mod_runtime.h @@ -6,6 +6,7 @@ #include #include #include +#include "mod_packages.h" #if defined(RECOMP_LAUNCHER) #include "recomp_launcher.h" #endif @@ -42,6 +43,20 @@ void mod_runtime_end_netplay(); void mod_runtime_set_session_plan_fp(const std::string& fp); const std::string& mod_runtime_session_plan_fp(); std::string mod_runtime_plan_fingerprint_portable(); +/* 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(); +/* 1 while that plan holds a [[plugin]] netplay = "input" (a pad transform + * applied to this player's own pad at netplay staging). */ +bool mod_runtime_netplay_input_active(); +/* "package/feature" keys of `plan` that qualify as own-view features. */ +std::vector mod_runtime_netplay_view_features(const ModResolution& plan); const std::string& mod_runtime_fingerprint(); const std::filesystem::path& mod_runtime_effective_disc_path(); /* Read an effective-disc file as whole sectors (true end-of-file tail bytes diff --git a/runtime/include/netplay_input_hist.h b/runtime/include/netplay_input_hist.h index 8c6aa4d8..1dbe5b11 100644 --- a/runtime/include/netplay_input_hist.h +++ b/runtime/include/netplay_input_hist.h @@ -29,7 +29,36 @@ typedef RNetInputHist NetplayInputHist; #define netplay_ih_invent_idle rnet_ih_invent_idle #define netplay_ih_promote rnet_ih_promote -/* PsxNetPad ↔ RNetRbFrame (LX/LY only; RX/RY stay on the pad blob path). */ +/* The portable stick contract deliberately omits controller type and RX/RY. + * Those fields still affect the guest pad protocol and must not be silently + * promoted on a completed tick or mistaken for a digital-only release. */ +static inline int netplay_ih_extra_pad_differ(const RNetRbFrame *a, + const RNetRbFrame *b) +{ + return a->analog != b->analog || a->rx != b->rx || a->ry != b->ry; +} + +static inline int netplay_ih_pad_payload_equal(const RNetRbFrame *a, + const RNetRbFrame *b) +{ + return a->buttons == b->buttons && a->stick_x == b->stick_x && + a->stick_y == b->stick_y && !netplay_ih_extra_pad_differ(a, b); +} + +static inline RNetInputContractDecision netplay_ih_pad_correction_decide( + const RNetRbFrame *published, const RNetRbFrame *wire, uint8_t completed, + const RNetInputContractParams *params, const RNetInputContractHostGates *gates) +{ + RNetInputContractFrame pub_c, wire_c; + if (completed && netplay_ih_extra_pad_differ(published, wire)) + return nRNetInputContractRewind; + netplay_ih_frame_to_contract(published, &pub_c); + netplay_ih_frame_to_contract(wire, &wire_c); + return rnet_input_contract_stick_replace_decide(&pub_c, &wire_c, + completed, params, gates); +} + +/* PsxNetPad ↔ RNetRbFrame: both sticks, controller type and NeGcon pressures. */ void netplay_ih_pad_to_frame(const PsxNetPad *pad, uint32_t tick, uint8_t predicted, RNetRbFrame *out); void netplay_ih_frame_to_pad(const RNetRbFrame *frame, PsxNetPad *pad); diff --git a/runtime/include/netplay_local_view.h b/runtime/include/netplay_local_view.h new file mode 100644 index 00000000..6d433197 --- /dev/null +++ b/runtime/include/netplay_local_view.h @@ -0,0 +1,74 @@ +#ifndef NETPLAY_LOCAL_VIEW_H +#define NETPLAY_LOCAL_VIEW_H + +#include +#include + +/* Host-only record of the display sub-rectangle a title asked the present + * path to show for the local netplay seat (psx_netplay_present_local_view). + * Never serialized and never visible to the guest: every peer still renders + * and digests the identical full frame. A request covers the simulation tick + * it was made on plus PSX_NETPLAY_LOCAL_VIEW_HOLD later ticks, so a title only + * has to renew it while its multi-view screen is up; menus fall back to the + * full frame by themselves. Rollback resimulation repeats ticks up to + * PSX_NETPLAY_LOCAL_VIEW_REPLAY behind the newest request; those never move + * the stamp backwards. A larger step back (a load) restarts it. */ +#define PSX_NETPLAY_LOCAL_VIEW_HOLD 8u +#define PSX_NETPLAY_LOCAL_VIEW_REPLAY 64u + +typedef struct PsxNetplayLocalView { + uint32_t x, y, w, h; + uint32_t tick; + uint8_t valid; +} PsxNetplayLocalView; + +static inline void psx_netplay_local_view_reset(PsxNetplayLocalView *view) +{ + if (view) memset(view, 0, sizeof(*view)); +} + +/* Rejects empty rectangles and anything outside 1024x512 VRAM space. */ +static inline int psx_netplay_local_view_set(PsxNetplayLocalView *view, + uint32_t tick, uint32_t x, + uint32_t y, uint32_t w, + uint32_t h) +{ + if (!view || w == 0u || h == 0u || x >= 1024u || y >= 512u || + w > 1024u - x || h > 512u - y) + return 0; + if (view->valid && (int32_t)(tick - view->tick) < 0 && + view->tick - tick <= PSX_NETPLAY_LOCAL_VIEW_REPLAY) + tick = view->tick; + view->x = x; + view->y = y; + view->w = w; + view->h = h; + view->tick = tick; + view->valid = 1u; + return 1; +} + +/* 1 and the rectangle when a request is still current at `now` and fits a + * display of display_w x display_h; 0 otherwise. */ +static inline int psx_netplay_local_view_get(const PsxNetplayLocalView *view, + uint32_t now, uint32_t display_w, + uint32_t display_h, uint32_t *x, + uint32_t *y, uint32_t *w, + uint32_t *h) +{ + if (!view || !view->valid) + return 0; + const int32_t age = (int32_t)(now - view->tick); + if (age < 0 ? view->tick - now > PSX_NETPLAY_LOCAL_VIEW_REPLAY + : (uint32_t)age > PSX_NETPLAY_LOCAL_VIEW_HOLD) + return 0; + if (view->x + view->w > display_w || view->y + view->h > display_h) + return 0; + if (x) *x = view->x; + if (y) *y = view->y; + if (w) *w = view->w; + if (h) *h = view->h; + return 1; +} + +#endif diff --git a/runtime/include/netplay_sim_pad_cache.h b/runtime/include/netplay_sim_pad_cache.h new file mode 100644 index 00000000..a7af819d --- /dev/null +++ b/runtime/include/netplay_sim_pad_cache.h @@ -0,0 +1,47 @@ +#ifndef NETPLAY_SIM_PAD_CACHE_H +#define NETPLAY_SIM_PAD_CACHE_H + +#include "psx_netplay.h" +#include + +#ifndef PSX_MAX_PLAYERS +#define PSX_MAX_PLAYERS 2 +#endif + +/* Host-only snapshot of the exact normalized seat rows published for one + * simulation tick. Rollback seal publication may overwrite an earlier + * predicted row for that same tick. Never serialize this cache: resimulation + * republishes all rows after loading the guest state. */ +typedef struct PsxNetplaySimPadCache { + PsxNetPad pads[PSX_MAX_PLAYERS + 1]; + uint32_t ticks[PSX_MAX_PLAYERS + 1]; + uint8_t valid[PSX_MAX_PLAYERS + 1]; +} PsxNetplaySimPadCache; + +static inline void psx_netplay_sim_pad_cache_reset(PsxNetplaySimPadCache *cache) +{ + if (cache) memset(cache->valid, 0, sizeof(cache->valid)); +} + +static inline void psx_netplay_sim_pad_cache_publish( + PsxNetplaySimPadCache *cache, int seat, uint32_t tick, + const PsxNetPad *pad) +{ + if (!cache || !pad || seat < 0 || seat > PSX_MAX_PLAYERS) return; + cache->pads[seat] = *pad; + cache->ticks[seat] = tick; + cache->valid[seat] = 1; +} + +static inline int psx_netplay_sim_pad_cache_read( + const PsxNetplaySimPadCache *cache, int seat, int seat_count, + uint32_t tick, PsxNetPad *out) +{ + if (!cache || !out || seat < 0 || seat >= seat_count || + seat > PSX_MAX_PLAYERS || !cache->valid[seat] || + cache->ticks[seat] != tick) return 0; + *out = cache->pads[seat]; + return 1; +} + +#endif diff --git a/runtime/include/psx_netplay.h b/runtime/include/psx_netplay.h index acfab6f9..4a80412d 100644 --- a/runtime/include/psx_netplay.h +++ b/runtime/include/psx_netplay.h @@ -145,6 +145,36 @@ int psx_netplay_host_spectates(void); /* Resolved host player index used for local capture. */ int psx_netplay_input_player(void); uint32_t psx_netplay_sim_tick(void); +/* Session seats, including any host gallery seat; zero when offline. */ +int psx_netplay_seat_count(void); +/* Read the pad actually published for one session seat in the current + * simulation tick. This follows the delay-sync publisher and rollback's + * sealed-frame override of predicted history, so a trusted game plugin can + * use the same seat inputs that SIO sees when a title consumes more player + * commands than its SIO protocol exposes. Call on the emulation thread while + * guest code runs; returns 0 when netplay is off, the seat is absent, or no + * pad has been published for this tick. */ +int psx_netplay_sim_pad(int seat, PsxNetPad *out); + +/* Presentation-only local view. A title whose netplay mode draws every seat's + * view into one frame on every peer (so guest state stays identical) asks the + * present path to show only this peer's view: a rectangle of the display area + * in guest pixels, relative to the GP1(05h) display start. The present path + * scales it to the window at 4:3. Host state only: never serialized, never + * visible to the guest. A request lapses a few simulation ticks after the + * last renewal, so the title renews it every frame its multi-view screen is + * up. Ignored while netplay is off. */ +void psx_netplay_present_local_view(uint32_t x, uint32_t y, + uint32_t w, uint32_t h); +/* Drop any current request at once (the full frame is presented again). + * A committed psx_mod_render_local_view() image calls it: the peer's own + * image of the display supersedes a crop of the canonical frame. */ +void psx_netplay_local_view_clear(void); +/* 1 and the rectangle while a current request fits a display of + * display_w x display_h. */ +int psx_netplay_local_view(uint32_t display_w, uint32_t display_h, + uint32_t *x, uint32_t *y, + uint32_t *w, uint32_t *h); /* * Snapshot for diagnostic dumps (starvation_dump.jsonl meta, etc.). diff --git a/runtime/include/psx_netplay_rb.h b/runtime/include/psx_netplay_rb.h index c08f7438..e3780b12 100644 --- a/runtime/include/psx_netplay_rb.h +++ b/runtime/include/psx_netplay_rb.h @@ -32,7 +32,8 @@ typedef struct PsxNetplayRbBindings { int *input_delay; void (*publish_sio)(uint32_t tick); /* publish history or sealed rows to SIO */ void (*apply_frame_slot)(int slot, uint32_t tick, uint16_t buttons, - int8_t sx, int8_t sy, uint8_t analog); + int8_t sx, int8_t sy, uint8_t analog, + uint8_t rx, uint8_t ry); } PsxNetplayRbBindings; void psx_netplay_rb_bind(const PsxNetplayRbBindings *b); diff --git a/runtime/include/render_pass.h b/runtime/include/render_pass.h index 6ac3a5d2..3ef28b8f 100644 --- a/runtime/include/render_pass.h +++ b/runtime/include/render_pass.h @@ -43,6 +43,8 @@ typedef struct RenderPassStats { uint64_t argument_refused, status_refused, begin_refused, checkpoint_refused; RenderPassFailure last_failure; /* latched across successes; session reset clears */ uint64_t passes; /* passes run to completion and presented */ + uint64_t local_views; /* psx_mod_render_local_view images committed */ + uint64_t local_attempts; /* psx_mod_render_local_view calls */ uint64_t aborted; /* passes rolled back by a fault */ uint64_t discarded; /* passes whose plugin declined the image */ uint64_t watchdog; /* of which: guest-cycle watchdog overruns */ diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 4c6e6b8c..5e71a246 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -7952,6 +7952,8 @@ static void handle_unwatch(int id, const char *json) send_err(id, "watchpoint not found"); } +static int s_trigger_override; +static uint32_t s_trigger_lt, s_trigger_rt; /* Host-pad layer (set_input {"layer":"host"}): a virtual P1 gamepad that * feeds stage 1 of the normal offline input path (and host shortcut * polling) instead of replacing it, so title transforms, direct shortcuts @@ -8004,9 +8006,25 @@ static void handle_set_input(int id, const char *json) int v = ax[i] < 0 ? 0x80 : (ax[i] > 255 ? 255 : ax[i]); s_axis_st[i] = (uint8_t)v; } + /* Optional host triggers lt/rt (0..255) for a title pad transform, as a + * gamepad's analog triggers would give it. Absent -> released. */ + { + const int lt = json_get_int(json, "lt", -1), rt = json_get_int(json, "rt", -1); + s_trigger_override = lt >= 0 || rt >= 0; + s_trigger_lt = (uint32_t)(lt < 0 ? 0 : (lt > 255 ? 255 : lt)); + s_trigger_rt = (uint32_t)(rt < 0 ? 0 : (rt > 255 ? 255 : rt)); + } send_ok(id); } +int debug_server_get_trigger_override(uint32_t *lt, uint32_t *rt) +{ + if (!s_trigger_override) return 0; + *lt = s_trigger_lt; + *rt = s_trigger_rt; + return 1; +} + static void handle_press(int id, const char *json) { int buttons = json_get_int(json, "buttons", -1); @@ -8544,6 +8562,8 @@ static void handle_render_pass_stats(int id, const char *json) "\"argument_refused\":%llu,\"status_refused\":%llu," "\"begin_refused\":%llu,\"checkpoint_refused\":%llu," "\"spans\":%llu,\"span_failures\":%llu," + "\"local_views\":%llu,\"local_attempts\":%llu," + "\"local_status\":%u," "\"last_failure\":%s,\"last_abort_detail\":\"%s\"," "\"span_fail\":{\"reason\":%u,\"pc\":\"0x%08X\",\"start\":\"0x%08X\"," "\"stop\":\"0x%08X\",\"ra\":\"0x%08X\",\"after\":\"0x%08X\"," @@ -8578,6 +8598,9 @@ static void handle_render_pass_stats(int id, const char *json) (unsigned long long)st.argument_refused, (unsigned long long)st.status_refused, (unsigned long long)st.begin_refused, (unsigned long long)st.checkpoint_refused, (unsigned long long)st.spans, (unsigned long long)st.span_failures, + (unsigned long long)st.local_views, + (unsigned long long)st.local_attempts, + psx_mod_render_local_view_status(), failure_json, abort_detail_json, (unsigned)sf.reason, (unsigned)sf.pc, (unsigned)sf.start_pc, (unsigned)sf.stop_pc, (unsigned)sf.ra, (unsigned)sf.after, diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index f351bfb2..f959b309 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -1417,7 +1417,18 @@ int psx_ws_aspect_cone_site(CPUState *cpu, uint32_t pc, uint32_t instr, return 1; } +/* Netplay presentation-only widescreen (gpu_ws_set_local_view_only): the + * shared simulation keeps the stock 4:3 cull everywhere; the widened margin + * exists only while this peer's sandboxed own view is drawn, whose guest-side + * effects the sandbox discards. */ +static int ws_local_view_only = 0; +static int ws_local_view_scope = 0; +void gpu_ws_set_local_view_only(int on) { ws_local_view_only = on ? 1 : 0; } +void gpu_ws_set_local_view_scope(int on) { ws_local_view_scope = on ? 1 : 0; } +int gpu_ws_local_view_only(void) { return ws_local_view_only; } + int psx_ws_x_margin(void) { + if (ws_local_view_only && !ws_local_view_scope) return 0; if (ws_margin_override >= 0) return ws_margin_override; /* Native-wide: widen the world-space draw cull by the per-side reveal * (== the centering OFFSET in screen px) so the game SUBMITS the geometry diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index e9c56bec..c642760c 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -505,6 +505,11 @@ static uint64_t s_idle_ticks_accum_fwd(uint64_t add); * open, GPU writes are confined to its rect: the scissor is intersected with * it and writes that bypass the scissor are refused and counted. */ static int s_pass_active = 0; +/* The open transaction is a netplay local view (gl_renderer_local_view_*): + * its draws reach only the presenter surface, never the authoritative CPU + * VRAM, and end(keep) keeps the rect's presented colour. */ +static int s_pass_local = 0; +static uint64_t s_local_commits = 0; /* Textures/framebuffers made for passes (pass_make_color_fbo, pass image * slots), and the count when the current pass began: a pass that allocated * is left out of the pass-cost average (render_pass_cost_add). */ @@ -6723,13 +6728,16 @@ static void pass_journal_rollback(void) { render_pass_journal_rollback(&s_pj_cpu, s_vram, VRAM_W); } +/* kind: 0 temporal pass, 1 stereo eye, 2 netplay local view. */ static int transaction_begin(int x, int y, int w, int h, int open_gen, - uint32_t period_vblanks, int reuse_backup, int stereo) { + uint32_t period_vblanks, int reuse_backup, int kind) { + const int stereo = kind == 1, local = kind == 2; int S = s_hr_scale, gi, wide, tw, th; PassGen *g; memset(&s_pass_begin_diag, 0, sizeof s_pass_begin_diag); - s_pass_begin_diag.status = stereo ? gl_renderer_stereo_unavailable() - : gl_renderer_pass_unavailable(); + s_pass_begin_diag.status = local ? gl_renderer_local_view_unavailable() + : stereo ? gl_renderer_stereo_unavailable() + : gl_renderer_pass_unavailable(); s_pass_begin_diag.active = s_pass_active; s_pass_begin_diag.open_gen = open_gen; s_pass_begin_diag.hr_scale = S; @@ -6765,7 +6773,9 @@ static int transaction_begin(int x, int y, int w, int h, int open_gen, s_pass_begin_diag.requested_h = th; gi = 1 - s_pgen_cur; g = &s_pgen[gi]; - if (stereo) { + if (local) { + /* No generation: the image stays in the presenter surface. */ + } else if (stereo) { StereoPair *pair = &s_stereo_pair[1 - s_stereo_current]; pair->x = x; pair->y = y; pair->w = w; pair->h = h; } else if (open_gen) { @@ -6852,24 +6862,27 @@ backed_up: if (s_pass_verify) pass_verify_read(&s_pv_hr, &s_pv_hr_cap, &s_pv_raw, &s_pv_raw_cap); s_pj_cpu.n = 0; s_pass_active = 1; + s_pass_local = local; return 1; } -static void transaction_restore(void) { +/* keep_color: a committed netplay local view keeps the rect's presented + * colour (hr surface, native-wide band); everything else is put back. */ +static void transaction_restore_ex(int keep_color) { int S = s_hr_scale, gi = 1 - s_pgen_cur; + const GLbitfield hr_bits = keep_color ? GL_STENCIL_BUFFER_BIT + : GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT; (void)gi; if (!s_pass_active) return; /* Roll the journal, then the rect back. */ pass_journal_rollback(); pass_blit(s_pb_hr_fbo, s_hr_fbo, 0, 0, s_pass_x * S, s_pass_y * S, - s_pass_w * S, s_pass_h * S, - GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + s_pass_w * S, s_pass_h * S, hr_bits); pass_blit(s_pb_raw_fbo, s_raw_fbo, 0, 0, s_pass_x, s_pass_y, s_pass_w, s_pass_h, GL_COLOR_BUFFER_BIT); if (s_pb_wide_src && s_pb_wide_src == pass_wide_fbo_for(s_pass_x)) pass_blit(s_pb_wide_fbo, s_pb_wide_src, 0, 0, 0, s_pass_y * S, - g_wide_w * S, s_pass_h * S, - GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + g_wide_w * S, s_pass_h * S, hr_bits); for (int row = 0; row < s_pass_h; row++) memcpy(s_vram + (size_t)(s_pass_y + row) * VRAM_W + s_pass_x, s_pb_cpu + (size_t)row * s_pass_w, @@ -6886,7 +6899,15 @@ static void transaction_restore(void) { s_last_dx = s_pb_last_dx; s_last_dy = s_pb_last_dy; s_last_dw = s_pb_last_dw; s_last_dh = s_pb_last_dh; s_pass_active = 0; - s_pb_valid = 1; + s_pass_local = 0; + s_pb_valid = !keep_color; + if (keep_color) { + /* The rect now differs from the backup and from the last present. */ + present_dirty_rect(s_pass_x, s_pass_y, s_pass_x + s_pass_w - 1, + s_pass_y + s_pass_h - 1, 1); + s_pv_ok = 1; /* the kept rect is the product, not a leak */ + return; + } if (s_pass_verify) { static uint8_t *after_hr = NULL, *after_raw = NULL; @@ -6900,6 +6921,29 @@ static void transaction_restore(void) { } } +static void transaction_restore(void) { transaction_restore_ex(0); } + +uint32_t gl_renderer_local_view_unavailable(void) { + pass_refusal_init(); + if (!s_ctx || !s_raster_ok || !s_hr_fbo || gpu_display_is_depth24()) + return PSX_MOD_RENDER_PASS_NO_PRESENTER; + /* Only a presenter surface separate from the authoritative VRAM can hold + * a peer's own image; the high-resolution window presents other tiles. */ + if (!s_cpu_auth_dual || s_hiw || s_pass_force_refuse > 0) + return PSX_MOD_RENDER_PASS_BACKEND; + return PSX_MOD_RENDER_PASS_READY; +} +int gl_renderer_local_view_begin(int x, int y, int w, int h) { + return transaction_begin(x, y, w, h, 0, 0, 0, 2); +} +int gl_renderer_local_view_end(int keep) { + if (!s_pass_active || !s_pass_local) return 0; + flush_flat_batch(); flush_tex_batch(); flush_cpu_upload(); + transaction_restore_ex(keep ? 1 : 0); + if (keep) s_local_commits++; + return 1; +} + int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen, uint32_t period, int reuse) { return transaction_begin(x, y, w, h, open_gen, period, reuse, 0); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index fc66de03..b16646bc 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1514,7 +1514,7 @@ static int g_netplay_content_negotiation = 0; static bool netplay_commit_mods(const std::filesystem::path& disc, std::string* error) { if (!g_netplay_content_negotiation) - return PSXRecompV4::mod_runtime_clear_for_netplay(error); + return PSXRecompV4::mod_runtime_commit_netplay_view(disc, error); const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); return (!caps || !caps->valid) ? PSXRecompV4::mod_runtime_commit_for_direct_netplay(disc, error) @@ -2006,7 +2006,10 @@ static void refresh_widescreen_projection() { (g_netplay_local_viewport_projection || psx_netplay_active())) ? 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) { @@ -6313,6 +6316,29 @@ static int crop_present_to_netplay_local_viewport(uint32_t* pixels, return 1; } +/* Title-requested local view (psx_netplay_present_local_view): a seat + * rectangle of the display this peer presents alone, at 4:3. Wins over the + * game.toml vertical-split local viewport. */ +static bool netplay_game_local_view(const GpuDisplayInfo& di, uint32_t* x, + uint32_t* y, uint32_t* w, uint32_t* h) { + if (di.disabled || di.depth24 || di.width == 0 || di.height == 0) + return false; + return psx_netplay_local_view(di.width, di.height, x, y, w, h) != 0; +} + +/* Crop a staged ARGB frame (pitch src_w) to the scaled local-view rectangle + * in place; the result has pitch w * scale. */ +static void crop_present_to_game_local_view(uint32_t* pixels, int src_w, + int scale, uint32_t x, uint32_t y, + uint32_t w, uint32_t h) { + const int cx = (int)x * scale, cy = (int)y * scale; + const int cw = (int)w * scale, ch = (int)h * scale; + for (int row = 0; row < ch; ++row) + memmove(pixels + (size_t)row * (size_t)cw, + pixels + (size_t)(cy + row) * (size_t)src_w + cx, + (size_t)cw * sizeof(uint32_t)); +} + static void netplay_present_gap_stats(uint32_t *p95_out, uint32_t *max_out) { unsigned n = s_present_gaps_n; unsigned idx; @@ -6330,6 +6356,63 @@ static void netplay_present_gap_stats(uint32_t *p95_out, uint32_t *max_out) { /* Stage local pad + poll admit until published. Parks the guest fiber when * called from the vblank callback (or before scheduler entry). Latches one * sample per sim tick; stalls on INPUT_CONFIRM desync. */ +static void pad_ext_host_extras(void*, int s, uint32_t* flags, uint32_t* lt, + uint32_t* rt); +#ifndef PSX_NO_DEBUG_TOOLS +extern "C" int debug_server_get_trigger_override(uint32_t* lt, uint32_t* rt); +#endif + +/* Netplay input transform ([[plugin]] netplay = "input"): this player's own + * title pad transform runs on its local pad before staging, so the + * transformed pad (buttons, sticks, presented type, NeGcon I/II) is what every + * peer receives and simulates. Each peer transforms only its own seat. */ +static void netplay_transform_local_pad(PsxNetPad* pad, int override_active) { + if (!PSXRecompV4::mod_runtime_netplay_input_active() || + psx_netplay_is_spectator()) + return; + const int seat = psx_netplay_local_slot(); + if (seat < 0 || seat >= PSX_MAX_PLAYERS) return; + PSXModPadFrame f{}; + f.struct_size = sizeof f; + f.player = (uint32_t)seat; + f.buttons = pad->buttons; + f.lx = pad->lx; f.ly = pad->ly; f.rx = pad->rx; f.ry = pad->ry; + f.type = pad->analog; + if (override_active) { +#ifndef PSX_NO_DEBUG_TOOLS + uint32_t lt = 0, rt = 0; + if (debug_server_get_trigger_override(<, &rt)) { + f.host_flags = PSX_MOD_PAD_HOST_GAMEPAD | PSX_MOD_PAD_HOST_LT | + PSX_MOD_PAD_HOST_RT; + f.host_lt = lt; + f.host_rt = rt; + } +#endif + } else { + const int card = psx_netplay_input_player(); + pad_ext_host_extras(nullptr, card >= 0 ? card : 0, &f.host_flags, + &f.host_lt, &f.host_rt); + } + PSXModPadOutput stock{}, o{}; + stock.struct_size = sizeof stock; + stock.buttons = f.buttons; stock.type = f.type; + stock.lx = f.lx; stock.ly = f.ly; stock.rx = f.rx; stock.ry = f.ry; + if (!mod_pad_transform_run((uint32_t)seat, &f, &stock, &o)) return; + pad->buttons = (uint16_t)o.buttons; + pad->analog = (uint8_t)o.type; + if (o.type == PSX_MOD_PAD_NEGCON) { + pad->lx = (uint8_t)o.lx; + pad->ly = (uint8_t)o.negcon_l; + pad->rx = (uint8_t)o.negcon_i; + pad->ry = (uint8_t)o.negcon_ii; + } else if (o.type == PSX_MOD_PAD_DIGITAL) { + pad->lx = pad->ly = pad->rx = pad->ry = 0x80; + } else { + pad->lx = (uint8_t)o.lx; pad->ly = (uint8_t)o.ly; + pad->rx = (uint8_t)o.rx; pad->ry = (uint8_t)o.ry; + } +} + static void netplay_barrier_admit(int override) { if (!psx_netplay_active()) return; /* Launcher/game window teardown can leave a queued SDL_QUIT; draining it @@ -6497,6 +6580,7 @@ static void netplay_barrier_admit(int override) { } else { capture_local_human_pad(&local); } + netplay_transform_local_pad(&local, override >= 0); psx_netplay_stage_local(&local); } else if (psx_start_bisect_spin_log() && !g_headless) { /* Dense SDL-only samples while admit waits without capture. */ @@ -7680,7 +7764,10 @@ static void headless_present_image_ring_capture(void) { const bool fmv_frame = !g_ws_engaged || gpu_ws_present_native_43() != 0; static std::vector buf; int w = 0, h = 0; - if (!fmv_frame && ws_native_wide_active() && gr_wide_supported()) { + uint32_t vx = 0, vy = 0, vw = di.width, vh = di.height; + const bool game_view = netplay_game_local_view(di, &vx, &vy, &vw, &vh); + if (!game_view && !fmv_frame && ws_native_wide_active() && + gr_wide_supported()) { buf.resize((size_t)1024 * 4 * 512 * 4); if (gr_wide_dump_full(buf.data(), (int)buf.size(), &w, &h, (int)di.display_x) > 0 && h >= 512) { @@ -7697,23 +7784,24 @@ static void headless_present_image_ring_capture(void) { /* Capture GL's real FBO, including the selected projection half, without * feeding readback into canonical netplay VRAM or its state hashes. */ if (g_headless_opengl) { - int slot = netplay_local_viewport_slot(); - int cw = slot >= 0 ? (int)di.width / 2 : (int)di.width; - int cx = (int)di.display_x + (slot == 1 ? (int)di.width - cw : 0); + int slot = game_view ? -1 : netplay_local_viewport_slot(); + int cw = slot >= 0 ? (int)di.width / 2 : (int)vw; + int cx = (int)(di.display_x + vx) + (slot == 1 ? (int)di.width - cw : 0); + int cy = (int)(di.display_y + vy); int scale = gr_scale(); - w = cw * scale; h = (int)di.height * scale; + w = cw * scale; h = (int)vh * scale; buf.resize((size_t)w * h); - if (gl_renderer_capture_display_hires(buf.data(), w * 4, cx, (int)di.display_y, - cw, (int)di.height) != w * h) return; + if (gl_renderer_capture_display_hires(buf.data(), w * 4, cx, cy, + cw, (int)vh) != w * h) return; present_image_ring_push_argb((uint32_t)s_frame_count, buf.data(), w, h, w); return; } - w = (int)di.width; h = (int)di.height; + w = (int)vw; h = (int)vh; buf.resize((size_t)w * h); for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) { - const uint16_t p = gpu_vram_peek((int)(di.display_x + x), - (int)(di.display_y + y)); + const uint16_t p = gpu_vram_peek((int)(di.display_x + vx + x), + (int)(di.display_y + vy + y)); buf[(size_t)y * w + x] = 0xFF000000u | ((uint32_t)(p & 31) << 19) | ((uint32_t)((p >> 5) & 31) << 11) | ((uint32_t)((p >> 10) & 31) << 3); @@ -8489,6 +8577,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { game frame that could not present wide) */ bool depth24_frame = false; bool local_viewport_crop_applied = false; + bool game_view_applied = false; + uint32_t gv_x = 0, gv_y = 0, gv_w = 0, gv_h = 0; if (s_force_present_after_load && g_gl_active) gl_renderer_flush_cpu_uploads(); { @@ -8545,7 +8635,10 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { if (g_gl_active) gl_renderer_set_interpolation_suspended( fmv_frame || mdec_recently_active(2)); - const int local_viewport_slot = netplay_local_viewport_slot(); + const bool game_view = + netplay_game_local_view(di, &gv_x, &gv_y, &gv_w, &gv_h); + const int local_viewport_slot = + game_view ? -1 : netplay_local_viewport_slot(); const bool local_viewport_crop = local_viewport_slot >= 0; bool local_viewport_wide = local_viewport_crop && g_ws_engaged && ws_native_wide_active() && @@ -8561,6 +8654,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * from the displayed buffer's surface. FMV/menu frames stay 4:3. */ bool wide_present = (!fmv_frame && !di.depth24 && g_ws_engaged && ws_native_wide_active() && gr_wide_supported() && + !game_view && (!local_viewport_crop || local_viewport_wide)); if (wide_present) { present_w = local_viewport_wide @@ -8605,6 +8699,14 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * present this peer's half straight from the high-resolution FBO. * The CPU crop below reads the 1x canonical frame, which throws away * internal resolution and smears the proportion-corrected HUD. */ + if (g_gl_active && g_gl_fbo_present && game_view) { + gl_renderer_present_vram((int)(di.display_x + gv_x), + (int)(di.display_y + gv_y), + (int)gv_w, (int)gv_h, + g_video_aa ? 1 : 0, 1); + netplay_note_present(); + return ep; + } if (g_gl_active && g_gl_fbo_present && !di.depth24 && local_viewport_crop && !local_viewport_wide) { const int half = (int)w / 2; @@ -8616,7 +8718,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { return ep; } if (g_gl_active && g_gl_fbo_present && !di.depth24 && - (!local_viewport_crop || local_viewport_wide)) { + (!local_viewport_crop || local_viewport_wide) && !game_view) { if (wide_present) { /* GPU-direct native-wide present: blit the displayed buffer's * wide FBO straight to the window (GPU-side, like the canonical @@ -8743,7 +8845,15 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { int present_px_w = (int)present_w * active_scale; int present_px_h = (int)present_h * active_scale; - if (!local_viewport_wide && + if (game_view && !wide_present) { + crop_present_to_game_local_view(sdl_pixel_buf, present_px_w, + active_scale, gv_x, gv_y, + gv_w, gv_h); + present_px_w = (int)gv_w * active_scale; + present_px_h = (int)gv_h * active_scale; + pin_43 = true; + game_view_applied = true; + } else if (!local_viewport_wide && crop_present_to_netplay_local_viewport(sdl_pixel_buf, &present_px_w, present_px_h)) { @@ -8778,7 +8888,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { !g_smooth_60fps.load(std::memory_order_acquire)) { static uint32_t prev_buf[640 * 512]; static uint32_t prev_px = 0; - const uint32_t npx = local_viewport_crop_applied + const uint32_t npx = (local_viewport_crop_applied || + game_view_applied) ? (uint32_t)(present_px_w * present_px_h) : present_w * h; if (npx <= (uint32_t)(640 * 512)) { @@ -8810,6 +8921,10 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { int src_h = (int)present_h * active_scale; if (local_viewport_crop_applied && src_w >= 2) src_w /= 2; + if (game_view_applied) { + src_w = (int)gv_w * active_scale; + src_h = (int)gv_h * active_scale; + } if (g_gl_active) { /* OpenGL present: upload the active display rect and draw a full-screen * quad. Either SwapWindow vsync OR the wall-clock pacer owns timing, @@ -14951,6 +15066,8 @@ int main(int argc, char** argv) { game_id = gc.id; game_region = gc.region; game_players = gc.players; + if (gc.runtime.has_multitap_default) + multitap_enabled = gc.runtime.multitap_default; apply_offline_pad_count(game_players, multitap_enabled); game_has_disc_crc = gc.has_disc_crc; game_disc_crc = gc.disc_crc; @@ -17070,6 +17187,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) { diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index bbd68238..a7293b56 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -2559,6 +2559,16 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, throw std::runtime_error("invalid plugin id"); plugin.order = toml::find_or( v, "order", (int64_t)declaration_index); + { + const std::string netplay = + toml::find_or(v, "netplay", ""); + if (!netplay.empty() && netplay != "local_view" && + netplay != "input") + throw std::runtime_error( + "plugin netplay must be \"local_view\" or \"input\""); + plugin.netplay_local_view = netplay == "local_view"; + plugin.netplay_input = netplay == "input"; + } read_conditions(v, out.options, plugin.feature_id, plugin.when, "plugin"); out.plugins.push_back(std::move(plugin)); @@ -3891,6 +3901,8 @@ 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; + resolved.netplay_input = plugin->netplay_input; result.plugins.push_back(std::move(resolved)); } for (const ModResource& resource : package->resources) { diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 8efa62b1..4892f3a5 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -5,6 +5,7 @@ #include "iso_reader.h" #include "mod_packages.h" #include "mod_plugins.h" +#include "psx_netplay.h" #include "gpu.h" #include "gpu_hd_textures.h" #include "psx_memory.h" @@ -92,6 +93,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; @@ -168,9 +172,22 @@ inline uint32_t function_entry_key(uint32_t address) { return address & 0x1FFFFFFFu; } +/* Game-owned netplay hooks are compiled into the executable. Keep them apart + * from the user mod plan, which every online match clears, so clearing that + * plan cannot disable the game's own netplay support. */ +std::vector& game_netplay_entry_hooks() { + static std::vector value; + return value; +} + +void update_function_entry_count() { + g_psx_mod_function_entry_hooks = (uint32_t)( + active_function_entry_hooks().size() + game_netplay_entry_hooks().size()); +} + void clear_function_entry_hooks() { active_function_entry_hooks().clear(); - g_psx_mod_function_entry_hooks = 0; + update_function_entry_count(); active_guest_functions().clear(); g_psx_mod_guest_functions = 0; active_instruction_hooks().clear(); @@ -189,7 +206,7 @@ void build_function_entry_hooks(const RuntimeMods& s) { std::stable_sort(table.begin(), table.end(), [](const ActiveFunctionEntryHook& a, const ActiveFunctionEntryHook& b) { return a.key < b.key; }); - g_psx_mod_function_entry_hooks = (uint32_t)table.size(); + update_function_entry_count(); auto& functions = active_guest_functions(); for (const auto& plugin : s.plan.plugins) for (const auto& function : mod_guest_functions(plugin.id)) @@ -1355,6 +1372,7 @@ 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"); @@ -1589,18 +1607,17 @@ bool mod_runtime_prepare_resources(const std::filesystem::path& disc_path, std:: bool mod_runtime_verify_session_plan_fp(std::string* error); -bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* error, bool save_selection) { - RuntimeMods& s = state(); - if (!s.initialized) return true; - if (disc_path != s.disc_path) { - std::string hash_error, digest; - if (!sha256_file(disc_path, digest, &hash_error)) digest.clear(); - s.disc_path = disc_path; - s.disc_sha256 = std::move(digest); - } - if (!mod_runtime_prepare_resources(disc_path, error)) return false; - ModResolution plan = - s.manager.resolve(s.game_id, s.exe_sha256, s.disc_sha256); +static void note_disc(RuntimeMods& s, const std::filesystem::path& disc_path) { + if (disc_path == s.disc_path) return; + std::string hash_error, digest; + if (!sha256_file(disc_path, digest, &hash_error)) digest.clear(); + s.disc_path = disc_path; + s.disc_sha256 = std::move(digest); +} + +static bool install_plan(RuntimeMods& s, ModResolution plan, + bool save_selection, bool verify_netplay, + 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. */ @@ -1620,7 +1637,7 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err if (error) *error = s.error; return false; } - if (!save_selection && (!netplay_resources_verified(plan, &s.error) || + if (verify_netplay && (!netplay_resources_verified(plan, &s.error) || !mod_runtime_verify_session_plan_fp(&s.error))) { if (error) *error = s.error; return false; @@ -1662,6 +1679,22 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err return true; } +bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* error, + bool save_selection) { + RuntimeMods& s = state(); + if (!s.initialized) return true; + s.netplay_view_plan = false; + if (disc_path != s.disc_path) { + std::string hash_error, digest; + if (!sha256_file(disc_path, digest, &hash_error)) digest.clear(); + s.disc_path = disc_path; + s.disc_sha256 = std::move(digest); + } + if (!mod_runtime_prepare_resources(disc_path, error)) return false; + return install_plan(s, s.manager.resolve(s.game_id, s.exe_sha256, s.disc_sha256), + save_selection, !save_selection, error); +} + void mod_runtime_set_session_plan_fp(const std::string& fp) { session_plan_fp() = fp; } @@ -1759,6 +1792,88 @@ void mod_runtime_end_netplay() { s.validation = s.manager.resolve(s.game_id, s.exe_sha256, s.disc_sha256, true); } +std::vector mod_runtime_netplay_view_features(const ModResolution& plan) { + /* A feature stays on online only when everything it contributes is a + * local-view plugin: no EXE/disc write, overlay or derived disc (those + * change the shared game) and no plugin without netplay = "local_view". */ + std::map ok; + for (const ModResolution::Plugin& p : plan.plugins) { + const std::string key = p.package_id + "/" + p.feature_id; + auto it = ok.find(key); + const bool good = p.netplay_local_view || p.netplay_input; + ok[key] = it == ok.end() ? good : (it->second && good); + } + for (const ModResolution::Write& w : plan.writes) + ok[w.package_id + "/" + w.feature_id] = false; + for (const ModResolution::Overlay& o : plan.overlays) + ok[o.package_id + "/" + o.feature_id] = false; + for (const ModResolution::DerivedDisc& d : plan.derived_discs) + ok[d.package_id + "/*"] = false; + std::vector out; + for (const auto& [key, good] : ok) { + if (!good) continue; + const std::string pkg = key.substr(0, key.find('/')); + if (ok.count(pkg + "/*")) continue; + out.push_back(key); + } + return out; +} + +bool mod_runtime_netplay_input_active() { + const RuntimeMods& s = state(); + if (!s.netplay_view_plan || !s.plan.ok) return false; + for (const ModResolution::Plugin& p : s.plan.plugins) + if (p.netplay_input) return true; + return false; +} + +bool mod_runtime_netplay_view_active() { + const RuntimeMods& s = state(); + return s.netplay_view_plan && s.plan.ok && !s.plan.plugins.empty(); +} + +bool mod_runtime_commit_netplay_view(const std::filesystem::path& disc_path, + std::string* error) { + RuntimeMods& s = state(); + std::string why; + (void)mod_runtime_clear_for_netplay(&why); + if (!s.initialized) return true; + note_disc(s, disc_path); + const ModResolution full = + s.manager.resolve(s.game_id, s.exe_sha256, s.disc_sha256); + if (!full.ok) return true; /* the player's set does not resolve: vanilla */ + const std::vector keep = mod_runtime_netplay_view_features(full); + if (keep.empty()) return true; + /* The player's selection with every other feature off. */ + std::map reduced = s.manager.selections(); + for (const auto& [id, versions] : s.manager.packages()) { + (void)versions; + const ModPackage* package = s.manager.selected_package(id); + if (!package) continue; + for (const ModFeature& f : package->features) { + if (f.legacy) continue; + const bool on = std::find(keep.begin(), keep.end(), + id + "/" + f.id) != keep.end(); + ModFeatureSelection& fs = reduced[id].features[f.id]; + if (!on) { fs.enabled = false; fs.has_enabled = true; } + } + } + std::map player = + s.manager.exchange_selections(std::move(reduced)); + ModResolution plan = s.manager.resolve(s.game_id, s.exe_sha256, s.disc_sha256); + (void)s.manager.exchange_selections(std::move(player)); + if (!install_plan(s, std::move(plan), /*save*/ false, /*verify_netplay*/ 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; } @@ -1813,7 +1928,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; @@ -2185,7 +2300,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); @@ -2467,9 +2583,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, @@ -2489,6 +2613,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, @@ -2503,10 +2628,13 @@ extern "C" void psx_mod_instruction(CPUState* cpu, uint32_t address, uint32_t in } } +static int game_netplay_function_entry(CPUState* cpu, uint32_t address); + 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( @@ -2526,6 +2654,62 @@ extern "C" int psx_mod_function_entry(CPUState* cpu, uint32_t address) { return 1; } } + return game_netplay_function_entry(cpu, address); +} + +static int register_game_netplay_hook(uint32_t address, + PSXModFunctionEntryCallback callback, + PSXModFunctionFilterCallback filter) { + using namespace PSXRecompV4; + if (!address || (!callback && !filter)) return 0; + auto& table = game_netplay_entry_hooks(); + const uint32_t key = function_entry_key(address); + for (const auto& hook : table) + if (hook.key == key && hook.callback == callback && hook.filter == filter) return 1; + table.push_back({key, callback, filter, nullptr}); + std::stable_sort(table.begin(), table.end(), + [](const ActiveFunctionEntryHook& a, + const ActiveFunctionEntryHook& b) { return a.key < b.key; }); + update_function_entry_count(); + return 1; +} + +extern "C" int psx_game_register_netplay_function_entry( + uint32_t address, PSXModFunctionEntryCallback callback) { + return callback ? register_game_netplay_hook(address, callback, nullptr) : 0; +} + +extern "C" int psx_game_register_netplay_function_filter( + uint32_t address, PSXModFunctionFilterCallback filter) { + return filter ? register_game_netplay_hook(address, nullptr, filter) : 0; +} + +/* Game netplay hooks run only in a netplay session, after the mod plan's + * hooks (which a match clears). PSX_GAME_FILTER_OFFLINE_PROBE=1 runs them + * offline too, to test a title's netplay path without opening a session. */ +static int game_netplay_function_entry(CPUState* cpu, uint32_t address) { + using namespace PSXRecompV4; + const auto& table = game_netplay_entry_hooks(); + if (table.empty()) return 0; + if (!psx_netplay_active()) { + static int offline_probe = -1; + if (offline_probe < 0) { + const char* e = std::getenv("PSX_GAME_FILTER_OFFLINE_PROBE"); + offline_probe = (e && std::strcmp(e, "1") == 0) ? 1 : 0; + } + if (!offline_probe) return 0; + } + const uint32_t key = function_entry_key(address); + auto it = std::lower_bound( + table.begin(), table.end(), key, + [](const ActiveFunctionEntryHook& hook, uint32_t k) { return hook.key < k; }); + for (; it != table.end() && it->key == key; ++it) { + if (it->callback) it->callback(cpu, address); + if (it->filter && it->filter(cpu, address)) { + cpu->pc = cpu->gpr[31]; + return 1; + } + } return 0; } diff --git a/runtime/src/netplay_input_hist.c b/runtime/src/netplay_input_hist.c index 5917809d..e3c46782 100644 --- a/runtime/src/netplay_input_hist.c +++ b/runtime/src/netplay_input_hist.c @@ -41,6 +41,8 @@ void netplay_ih_pad_to_frame(const PsxNetPad *pad, uint32_t tick, uint8_t predic out->stick_x = u8_to_i8_stick(n.lx); out->stick_y = u8_to_i8_stick(n.ly); out->analog = n.analog; + out->rx = n.rx; /* the rest of the pad: right stick, NeGcon I/II */ + out->ry = n.ry; } void netplay_ih_frame_to_pad(const RNetRbFrame *frame, PsxNetPad *pad) @@ -56,6 +58,8 @@ void netplay_ih_frame_to_pad(const RNetRbFrame *frame, PsxNetPad *pad) pad->lx = i8_to_u8_stick(frame->stick_x); pad->ly = i8_to_u8_stick(frame->stick_y); pad->analog = frame->analog <= PSX_NETPAD_TYPE_MAX ? frame->analog : 0u; + pad->rx = frame->rx; + pad->ry = frame->ry; psx_netplay_normalize_pad(pad); } diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index 19e5b03e..f394a1e7 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -4,6 +4,8 @@ #include "psx_netplay.h" #include "netplay_load_probe.h" +#include "netplay_sim_pad_cache.h" +#include "netplay_local_view.h" #include "host_time.h" #include "memcard.h" @@ -442,7 +444,39 @@ uint32_t psx_netplay_sim_tick(void) { return 0; } int psx_netplay_content_note(uint32_t e, uint32_t a, uint32_t b, uint32_t seen, uint8_t slot, uint8_t op, uint8_t flags) { (void)e;(void)a;(void)b;(void)seen;(void)slot;(void)op;(void)flags;return 0; } -int psx_netplay_start(const PsxNetplayConfig *cfg) { (void)cfg; return -1; } +int psx_netplay_seat_count(void) { return 0; } +int psx_netplay_sim_pad(int seat, PsxNetPad *out) +{ + (void)seat; + (void)out; + return 0; +} +void psx_netplay_present_local_view(uint32_t x, uint32_t y, + uint32_t w, uint32_t h) +{ + (void)x; + (void)y; + (void)w; + (void)h; +} +void psx_netplay_local_view_clear(void) {} +int psx_netplay_local_view(uint32_t display_w, uint32_t display_h, + uint32_t *x, uint32_t *y, + uint32_t *w, uint32_t *h) +{ + (void)display_w; + (void)display_h; + (void)x; + (void)y; + (void)w; + (void)h; + return 0; +} +int psx_netplay_start(const PsxNetplayConfig *cfg) +{ + (void)cfg; + return -1; +} void psx_netplay_shutdown(void) {} void psx_netplay_cold_reset(void) {} void psx_netplay_stage_local(const PsxNetPad *pad) { (void)pad; } @@ -2362,6 +2396,18 @@ void psx_netplay_pad_trace_dev(int card, int fallback, int sdl_start, } } +/* A game plugin reads the exact seat row last published to SIO for this tick. + * Keep this host-side cache outside savestates: each admitted/replayed tick + * publishes it again, including sealed rollback authority overwrites. */ +static PsxNetplaySimPadCache s_sim_pad_cache; +/* Host-only presentation request; see psx_netplay_present_local_view. */ +static PsxNetplayLocalView s_local_view; + +static void note_sim_pad(int slot, uint32_t tick, const PsxNetPad *pad) +{ + psx_netplay_sim_pad_cache_publish(&s_sim_pad_cache, slot, tick, pad); +} + static void apply_pad_slot(int slot, const PsxNetPad *pad) { if (slot < 0 || slot >= g_np.slot_count || slot >= NP_SLOT_CAP || !pad) return; @@ -2467,7 +2513,6 @@ static void host_publish(rnet_u32 tick, const RNetInputSample *by_slot, int slot { int i; int n; - (void)tick; (void)ctx; if (!by_slot || slots <= 0) return; n = g_np.slot_count; @@ -2478,6 +2523,7 @@ static void host_publish(rnet_u32 tick, const RNetInputSample *by_slot, int slot PsxNetPad pad; decode_pad(&by_slot[i], &pad); apply_pad_slot(i, &pad); + note_sim_pad(i, tick, &pad); } } @@ -2504,25 +2550,29 @@ static void np_publish_hist_sio(uint32_t tick) continue; netplay_ih_frame_to_pad(&row, &pad); apply_pad_slot(i, &pad); + note_sim_pad(i, tick, &pad); } } static void np_rb_apply_frame_slot(int slot, uint32_t tick, uint16_t buttons, - int8_t sx, int8_t sy, uint8_t analog) + int8_t sx, int8_t sy, uint8_t analog, + uint8_t rx, uint8_t ry) { RNetRbFrame row; PsxNetPad pad; - (void)tick; memset(&row, 0, sizeof(row)); row.tick = tick; row.buttons = buttons; row.stick_x = sx; row.stick_y = sy; row.analog = analog <= PSX_NETPAD_TYPE_MAX ? analog : 0u; + row.rx = rx; + row.ry = ry; row.is_valid = 1; netplay_ih_frame_to_pad(&row, &pad); force_session_pads_connected(g_np.slot_count); apply_pad_slot(slot, &pad); + note_sim_pad(slot, tick, &pad); } static void np_rb_bind_and_start(void) @@ -2618,9 +2668,7 @@ static void np_scrub_ahead_predicted(int slot, rnet_u32 release_tick, continue; if (!row.is_predicted) continue; - if (row.buttons == released->buttons && - row.stick_x == released->stick_x && - row.stick_y == released->stick_y) + if (netplay_ih_pad_payload_equal(&row, released)) continue; scrub = *released; scrub.tick = t; @@ -2679,9 +2727,7 @@ static void np_tip_hold_coalesce_ahead(void) break; decode_pad(&sample, &pad); netplay_ih_pad_to_frame(&pad, t, 0, &wire_frame); - if (wire_frame.buttons == tip_row.buttons && - wire_frame.stick_x == tip_row.stick_x && - wire_frame.stick_y == tip_row.stick_y) + if (netplay_ih_pad_payload_equal(&wire_frame, &tip_row)) continue; edge = t; break; @@ -2869,6 +2915,7 @@ static void np_rollback_reconcile_wire(void) rnet_u32 wire; int pads_differ; int buttons_differ; + int rest_differ; if (!netplay_ih_get(&g_np.ih, slot, t, &published)) continue; @@ -2884,7 +2931,10 @@ static void np_rollback_reconcile_wire(void) netplay_ih_frame_to_contract(&published, &pub_c); netplay_ih_frame_to_contract(&wire_frame, &wire_c); buttons_differ = (pub_c.buttons != wire_c.buttons); - pads_differ = buttons_differ || + /* The rest of the pad (type, right stick, NeGcon I/II) is not in the + * contract view; any difference there is a real mispredict. */ + rest_differ = netplay_ih_extra_pad_differ(&published, &wire_frame); + pads_differ = buttons_differ || rest_differ || (pub_c.stick_x != wire_c.stick_x) || (pub_c.stick_y != wire_c.stick_y); completed = (sim > t) ? 1u : 0u; @@ -2895,7 +2945,7 @@ static void np_rollback_reconcile_wire(void) * release) without opening an episode. Distinct from the old * ungated menu soft-promote that forked RAM: HC fail-closed * means a real state-affecting press still rewinds. */ - if (buttons_differ && completed && + if (buttons_differ && !rest_differ && completed && np_hc_silent_promote_enabled() && netplay_hc_confirm_through(&g_np.hc, t)) { (void)netplay_ih_promote(&g_np.ih, slot, &wire_frame); @@ -2942,14 +2992,14 @@ static void np_rollback_reconcile_wire(void) continue; } gate_ctx.tick = t; - d = rnet_input_contract_stick_replace_decide( - &pub_c, &wire_c, completed, ¶ms, &gates); + d = netplay_ih_pad_correction_decide( + &published, &wire_frame, completed, ¶ms, &gates); if (rnet_input_contract_decision_is_rewind(d)) { /* FMV/settle only: soft-promote releases (skip must rewind on * press). Menu soft-promote + hold-last invent forked RAM * (sticky Up skipped resim). Live invent is hold-last again — * menu releases open a real episode unless §46 HC-silent. */ - if (fmv_defer && np_digital_release_only(&pub_c, &wire_c)) { + if (!rest_differ && fmv_defer && np_digital_release_only(&pub_c, &wire_c)) { (void)netplay_ih_promote(&g_np.ih, slot, &wire_frame); np_scrub_ahead_predicted(slot, t, &wire_frame); if (n_soft_release == 0) { @@ -2964,7 +3014,7 @@ static void np_rollback_reconcile_wire(void) /* §48: post-FMV UNLOCK_GRACE — invent is on (§26) but sticky * hold-last D-pad vs wire release must not open tip episodes * into the title/menu (soak: pub=ffef wire=ffff @902). */ - if (published.is_predicted && + if (!rest_differ && published.is_predicted && np_digital_release_only(&pub_c, &wire_c) && psx_netplay_rb_fmv_unlock_grace_active()) { (void)netplay_ih_promote(&g_np.ih, slot, &wire_frame); @@ -2980,7 +3030,7 @@ static void np_rollback_reconcile_wire(void) continue; } /* FMV: non-press pad noise → promote only (avoid CD thrash). */ - if (fmv_defer && !np_digital_new_press(&pub_c, &wire_c)) { + if (!rest_differ && fmv_defer && !np_digital_new_press(&pub_c, &wire_c)) { (void)netplay_ih_promote(&g_np.ih, slot, &wire_frame); if (pads_differ) { if (n_no_resim == 0) { @@ -3369,6 +3419,46 @@ uint32_t psx_netplay_sim_tick(void) return rnet_session_sim_tick(g_np.session); } +int psx_netplay_seat_count(void) +{ + return psx_netplay_active() ? g_np.slot_count : 0; +} + +int psx_netplay_sim_pad(int seat, PsxNetPad *out) +{ + if (!out || !psx_netplay_active() || seat < 0 || seat >= g_np.slot_count) + return 0; + return psx_netplay_sim_pad_cache_read( + &s_sim_pad_cache, seat, g_np.slot_count, + rnet_session_sim_tick(g_np.session), out); +} + +void psx_netplay_present_local_view(uint32_t x, uint32_t y, + uint32_t w, uint32_t h) +{ + if (!psx_netplay_active()) + return; + (void)psx_netplay_local_view_set(&s_local_view, + rnet_session_sim_tick(g_np.session), + x, y, w, h); +} + +void psx_netplay_local_view_clear(void) +{ + psx_netplay_local_view_reset(&s_local_view); +} + +int psx_netplay_local_view(uint32_t display_w, uint32_t display_h, + uint32_t *x, uint32_t *y, + uint32_t *w, uint32_t *h) +{ + if (!psx_netplay_active()) + return 0; + return psx_netplay_local_view_get(&s_local_view, + rnet_session_sim_tick(g_np.session), + display_w, display_h, x, y, w, h); +} + void psx_netplay_stage_local(const PsxNetPad *pad) { /* Host in the gallery: its seat still publishes a row every tick (the @@ -3683,6 +3773,8 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) if (!cfg || !cfg->enabled) return -1; if (g_np.session) psx_netplay_shutdown(); + psx_netplay_sim_pad_cache_reset(&s_sim_pad_cache); + psx_netplay_local_view_reset(&s_local_view); slots = cfg->slot_count; if (slots < 2) slots = 2; @@ -4257,6 +4349,8 @@ void psx_netplay_cold_reset(void) } } g_local_pad_prev = 0xFFFFu; + psx_netplay_sim_pad_cache_reset(&s_sim_pad_cache); + psx_netplay_local_view_reset(&s_local_view); g_local_pad_have = 0; g_live_trace_prev = 0xFFFFu; g_live_trace_have = 0; @@ -4787,13 +4881,13 @@ static int np_try_admit_gameplay(void) } /* - * §95: rollback LOAD barrier admit — tip + hold-last invent, no INPUT_CONFIRM. - * Delay-sync try_admit wait_confirm hung the slower peer after the faster one - * applied and froze (guest LOADED, host stuck load_applying+wait_confirm). + * LOAD barrier admit — tip + hold-last invent, no INPUT_CONFIRM. + * Confirmed admission can hang the slower peer after the faster one applies + * and freezes, including when a graphical peer is paced behind headless peers. * Load will hard_resync at mutual ready; pads here only need guest cycles for * savestate_poll / one resume tick. */ -static int np_try_admit_load_barrier_rb(void) +static int np_try_admit_load_barrier_unconfirmed(void) { rnet_u32 sim = rnet_session_sim_tick(g_np.session); rnet_u32 wire; @@ -4879,31 +4973,27 @@ int psx_netplay_poll_admit(void) /* Staged load must run guest cycles — bypass starvation latch. ICE xfer * often leaves lead=D-1 and would otherwise block try_admit forever. - * §95: rollback must not use delay-sync confirm here. */ + * Neither netplay mode can wait for confirmation from an already-frozen + * peer during the apply barrier. */ if (g_np.xfer == NP_XFER_LOAD_APPLYING && savestate_pending()) { if (g_np.needs_advance) return 1; - if (g_np.rollback) - return np_try_admit_load_barrier_rb(); - return np_try_admit_gameplay(); + return np_try_admit_load_barrier_unconfirmed(); } - /* Both peers: after mutual ready + sync, stay in LOAD_READY until admit - * succeeds. Dropping the barrier early on the host let it spin on confirm - * with FPS/present already "live". §95: rollback exits via tip invent. */ + /* After mutual ready + sync, stay in LOAD_READY until a confirmed first + * tick is available. Rollback's invented rows are only needed while a + * slower peer is still applying the save; inventing the first resumed + * tick let peers run different inputs from the identical restored state. */ if (g_np.xfer == NP_XFER_LOAD_READY) { if (g_np.load_sync_done && g_np.load_ready_replied && !g_np.needs_advance) { int admitted; - if (g_np.rollback) - admitted = np_try_admit_load_barrier_rb(); - else { - sim = rnet_session_sim_tick(g_np.session); - admitted = rnet_session_try_admit(g_np.session, sim); - if (admitted) - g_np.needs_advance = 1; - else - force_session_pads_connected(g_np.slot_count); - } + sim = rnet_session_sim_tick(g_np.session); + admitted = rnet_session_try_admit(g_np.session, sim); + if (admitted) + g_np.needs_advance = 1; + else + force_session_pads_connected(g_np.slot_count); if (admitted) { g_np.xfer = NP_XFER_NONE; g_np.load_applied_local = 0; diff --git a/runtime/src/psx_netplay_rb.c b/runtime/src/psx_netplay_rb.c index ce3c3404..f75e56f0 100644 --- a/runtime/src/psx_netplay_rb.c +++ b/runtime/src/psx_netplay_rb.c @@ -1482,7 +1482,8 @@ static void dump_post_diverge_diag(const char *why) hist.is_predicted ? "P" : ""); if (have_s && (seal.buttons != hist.buttons || seal.stick_x != hist.stick_x || - seal.stick_y != hist.stick_y)) + seal.stick_y != hist.stick_y || + seal.rx != hist.rx || seal.ry != hist.ry)) fprintf(stderr, "!"); } else { fprintf(stderr, "/h=----"); @@ -4170,7 +4171,7 @@ static void publish_sealed_sio(uint32_t tick) if (!rnet_rb_get_sealed_frame(g_rb, slot, tick, &row) || !row.is_valid) continue; g_b.apply_frame_slot(slot, tick, row.buttons, row.stick_x, row.stick_y, - row.analog); + row.analog, row.rx, row.ry); } } diff --git a/runtime/src/render_pass.c b/runtime/src/render_pass.c index 8424792c..176c6dc9 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -60,6 +60,8 @@ 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 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); @@ -337,17 +339,24 @@ int psx_mod_set_render_pass_flip(uint32_t mode) { return 1; } +/* Transaction kinds: what `eye` holds in render_transaction. */ +#define RP_EYE_PASS (-1) /* temporal pass */ +#define RP_EYE_LOCAL (-2) /* netplay local view */ + /* Everything that must hold before guest code may run frozen, as a - * PSX_MOD_RENDER_PASS_* reason (READY = all of it holds). */ -static uint32_t transaction_status(int stereo) { + * PSX_MOD_RENDER_PASS_* reason (READY = all of it holds). kind: 0 temporal + * pass, 1 stereo, 2 netplay local view (forward netplay frames only). */ +static uint32_t transaction_status(int kind) { uint32_t gl; if (s_stats.disabled) return PSX_MOD_RENDER_PASS_DISABLED; - if ((psx_netplay_active() && !s_netplay_allowed) || psx_netplay_is_resimulating() || + if ((kind == 2 ? !psx_netplay_active() : + (psx_netplay_active() && !s_netplay_allowed)) || 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; if (psx_presentation_fast_forward()) return PSX_MOD_RENDER_PASS_FAST_FORWARD; - gl = stereo ? gl_renderer_stereo_unavailable() : gl_renderer_pass_unavailable(); + gl = kind == 2 ? gl_renderer_local_view_unavailable() + : kind == 1 ? gl_renderer_stereo_unavailable() : gl_renderer_pass_unavailable(); if (gl != PSX_MOD_RENDER_PASS_READY) return gl; if (s_nesting || g_psx_guest_time_frozen || psx_get_in_exception() || dma_gpu_linked_list_active()) @@ -603,8 +612,20 @@ 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; +} + static int transaction_end(int eye, uint32_t alpha, int keep) { if (eye >= 0) return gl_renderer_stereo_end((uint32_t)eye, keep); + if (eye == RP_EYE_LOCAL) return gl_renderer_local_view_end(keep); gl_renderer_pass_end(alpha, keep); return 1; } @@ -632,12 +653,12 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass } if (!cpu || !pass || !fn || pass->struct_size < sizeof *pass || - pass->w == 0 || pass->h == 0 || (eye < 0 && + pass->w == 0 || pass->h == 0 || (eye == RP_EYE_PASS && (pass->alpha_q16 == 0 || pass->alpha_q16 >= 65536u))) { s_stats.argument_refused++; - return pass_refuse("arguments", transaction_status(eye >= 0)); + return pass_refuse("arguments", transaction_status(transaction_kind(eye))); } - status = transaction_status(eye >= 0); + status = transaction_status(transaction_kind(eye)); if (status != PSX_MOD_RENDER_PASS_READY) { s_stats.status_refused++; return pass_refuse("status", status); @@ -650,18 +671,21 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass s_attempt.guest_cycle = psx_cycle_count; t0 = gl_renderer_perf_ticks(); - open = eye < 0 ? s_open_generation : 0; + open = eye == RP_EYE_PASS ? s_open_generation : 0; { static double inv = 0.0; if (inv == 0.0) inv = 1000.0 / (double)gl_renderer_perf_frequency(); s_ms_per_tick = inv; } - reuse = !open && s_restored_valid && s_restored_plan == s_plan_serial && + reuse = !open && eye != RP_EYE_LOCAL && s_restored_valid && + s_restored_plan == s_plan_serial && s_restored_cycle == psx_cycle_count && s_restored_stores == g_guest_store_count; /* Frame N's own image is captured by the first pass after a plan. */ if (!(eye >= 0 ? gl_renderer_stereo_begin(pass->x, pass->y, pass->w, pass->h, reuse) + : eye == RP_EYE_LOCAL ? gl_renderer_local_view_begin(pass->x, pass->y, + pass->w, pass->h) : gl_renderer_pass_begin(pass->x, pass->y, pass->w, pass->h, open, s_plan_period, reuse))) { gl_renderer_pass_begin_diag(&s_attempt.gl); @@ -676,7 +700,7 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass gl_renderer_pass_begin_diag(&s_attempt.gl); s_stats.checkpoint_refused++; /* Capture the rejection status before rolling the GL transaction back. */ - status = transaction_status(eye >= 0); + status = transaction_status(transaction_kind(eye)); transaction_end(eye, 0, 0); return pass_refuse(s_checkpoint_failure ? s_checkpoint_failure : "checkpoint", status); } @@ -691,6 +715,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; @@ -733,10 +760,13 @@ 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; - s_restored_valid = 1; + /* A committed local view changed the presented rect: no later pass may + * reuse this backup. */ + s_restored_valid = !(eye == RP_EYE_LOCAL && ok); s_restored_plan = s_plan_serial; s_restored_cycle = psx_cycle_count; s_restored_stores = g_guest_store_count; @@ -768,16 +798,38 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass s_stats.avg_pass_ms = s_stats.avg_pass_ms > 0.0 ? s_stats.avg_pass_ms * 0.9 + ms * 0.1 : ms; } - if (eye < 0) gl_renderer_pass_note_cost(t1 - t0); - if (ok) s_stats.passes++; + if (eye == RP_EYE_PASS) gl_renderer_pass_note_cost(t1 - t0); + if (ok && eye == RP_EYE_LOCAL) { + s_stats.local_views++; + /* The peer's own image supersedes a crop of the canonical frame. */ + psx_netplay_local_view_clear(); + } else if (ok) s_stats.passes++; else if (leaks) note_fault("VRAM write outside the pass rect"); else if (s_stats.watchdog_flag) note_fault("guest-cycle watchdog"); s_stats.watchdog_flag = 0; /* Present anything that fell due while the pass ran. */ - if (eye < 0) gl_renderer_pass_service_presents(); + if (eye == RP_EYE_PASS) gl_renderer_pass_service_presents(); return ok; } +int psx_mod_render_local_view(struct CPUState *cpu, const PSXModRenderPass *rect, + PSXModRenderPassFn fn, void *user) { + PSXModRenderPass r; + s_stats.local_attempts++; + if (!rect || rect->struct_size < sizeof r || + (uint32_t)rect->x + rect->w > 1024u || (uint32_t)rect->y + rect->h > 512u) { + s_stats.argument_refused++; + return pass_refuse("arguments", transaction_status(2)); + } + r = *rect; + r.alpha_q16 = 0; + return render_transaction(cpu, &r, fn, user, RP_EYE_LOCAL); +} + +uint32_t psx_mod_render_local_view_status(void) { + return transaction_status(2); +} + int psx_mod_render_pass(struct CPUState *cpu, const PSXModRenderPass *pass, PSXModRenderPassFn fn, void *user) { return render_transaction(cpu, pass, fn, user, -1); diff --git a/runtime/tests/test_mod_resident.cpp b/runtime/tests/test_mod_resident.cpp index 57ab7198..de3d90d9 100644 --- a/runtime/tests/test_mod_resident.cpp +++ b/runtime/tests/test_mod_resident.cpp @@ -343,3 +343,6 @@ int main() { std::cout << "mod resident tests passed\n"; return 0; } + +/* render_pass.c: no sandboxed local view runs in this test. */ +extern "C" int psx_mod_local_view_scope(void) { return 0; } diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 02b069b6..49805797 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -29,6 +29,18 @@ static PsxLobbyMatchCaps test_match_caps; extern "C" const PsxLobbyMatchCaps* psx_lobby_match_caps(void) { return &test_match_caps; } +static int game_netplay_entry_hits; +static int game_netplay_filter_hits; +static int test_netplay_active; +extern "C" int psx_netplay_active(void) { return test_netplay_active; } +static void test_game_netplay_entry(CPUState*, uint32_t) { + game_netplay_entry_hits++; +} +static int test_game_netplay_filter(CPUState* cpu, uint32_t) { + game_netplay_filter_hits++; + cpu->gpr[2] = 0x1234u; + return 1; +} extern "C" uint8_t psx_read_byte(uint32_t address) { return ram[address & 0x1fffffu]; @@ -1421,8 +1433,47 @@ int main() { mod_runtime_activate_plugins(); check(hd_root == custom.generic_u8string(), "Unicode pack root survives the complete session cycle"); } + /* A compiled game hook survives mod-plan clearing, but runs only online. */ + check(psx_game_register_netplay_function_entry( + 0x80004000u, test_game_netplay_entry) == 1, + "trusted game netplay entry must register"); + check(psx_game_register_netplay_function_entry( + 0x80004000u, test_game_netplay_entry) == 1, + "duplicate game netplay registration must be idempotent"); + check(psx_game_register_netplay_function_filter( + 0x80005000u, test_game_netplay_filter) == 1, + "trusted game netplay filter must register"); + CPUState game_cpu{}; + psx_mod_function_entry(&game_cpu, 0x80004000u); + game_cpu.gpr[31] = 0x80006000u; + check(!psx_mod_function_entry(&game_cpu, 0x80005000u), + "game netplay filter must not consume offline function"); + check(game_netplay_entry_hits == 0, + "game netplay hook must not run offline"); + check(PSXRecompV4::mod_runtime_clear_for_netplay(&error), error.c_str()); + test_netplay_active = 1; + psx_mod_function_entry(&game_cpu, 0xA0004000u); + check(game_netplay_entry_hits == 1 && g_psx_mod_function_entry_hooks == 2, + "game hook must survive netplay mod clear and match code aliases"); + check(psx_mod_function_entry(&game_cpu, 0xA0005000u) == 1 && + game_cpu.pc == 0x80006000u && game_cpu.gpr[2] == 0x1234u && + game_netplay_filter_hits == 1, + "game netplay filter must consume call and resume at return PC"); + psx_mod_function_entry(&game_cpu, 0x80004004u); + check(game_netplay_entry_hits == 1, + "game hook must ignore other addresses"); + test_netplay_active = 0; + psx_mod_function_entry(&game_cpu, 0x80004000u); + check(game_netplay_entry_hits == 1, + "game hook must disarm when netplay stops"); + check(!psx_mod_function_entry(&game_cpu, 0x80005000u) && + game_netplay_filter_hits == 1, + "game filter must disarm when netplay stops"); fs::remove_all(root, ec); if (failures) return 1; std::cout << "mod runtime tests passed\n"; return 0; } + +/* render_pass.c: no sandboxed local view runs in this test. */ +extern "C" int psx_mod_local_view_scope(void) { return 0; } diff --git a/runtime/tests/test_netplay_input_hist.c b/runtime/tests/test_netplay_input_hist.c index 61fb9a96..8a026942 100644 --- a/runtime/tests/test_netplay_input_hist.c +++ b/runtime/tests/test_netplay_input_hist.c @@ -159,6 +159,43 @@ int main(void) CHECK(d == nRNetInputContractPromoteHashConfirm, "hash_confirm → promote"); CHECK(!rnet_input_contract_decision_is_rewind(d), "promote not rewind"); + /* A type-only late correction used to disappear in the stick contract. + * Cover the production PSX wrapper, including a permissive HC callback. */ + { + RNetRbFrame predicted = {0}, actual; + predicted.tick = 42; + predicted.buttons = 0xFFFFu; + predicted.rx = predicted.ry = 0x80; + predicted.is_valid = predicted.is_predicted = 1; + actual = predicted; + actual.is_predicted = 0; + CHECK(netplay_ih_pad_payload_equal(&predicted, &actual), + "prediction metadata does not change pad payload"); + CHECK(!netplay_ih_extra_pad_differ(&predicted, &actual), + "unchanged extra pad fields stay equal"); + for (uint8_t type = 1; type <= PSX_NETPAD_TYPE_MAX; ++type) { + actual.analog = type; + CHECK(!netplay_ih_pad_payload_equal(&predicted, &actual) && + netplay_ih_extra_pad_differ(&predicted, &actual), + "every controller-type change is a payload correction"); + d = netplay_ih_pad_correction_decide(&predicted, &actual, 1, ¶ms, &gates); + CHECK(rnet_input_contract_decision_is_rewind(d), + "completed type correction rewinds even with hash-confirm gate"); + } + actual = predicted; actual.rx++; + CHECK(rnet_input_contract_decision_is_rewind( + netplay_ih_pad_correction_decide(&predicted, &actual, 1, ¶ms, &gates)), + "completed RX/NeGcon-I correction rewinds"); + actual = predicted; actual.ry++; + CHECK(rnet_input_contract_decision_is_rewind( + netplay_ih_pad_correction_decide(&predicted, &actual, 1, ¶ms, &gates)), + "completed RY/NeGcon-II correction rewinds"); + actual = predicted; actual.analog = 1; + CHECK(!rnet_input_contract_decision_is_rewind( + netplay_ih_pad_correction_decide(&predicted, &actual, 0, ¶ms, &gates)), + "uncompleted type correction may replace history without rewind"); + } + if (failures) { printf("%d failure(s)\n", failures); return 1; diff --git a/runtime/tests/test_netplay_local_view.c b/runtime/tests/test_netplay_local_view.c new file mode 100644 index 00000000..b5315400 --- /dev/null +++ b/runtime/tests/test_netplay_local_view.c @@ -0,0 +1,48 @@ +#include "netplay_local_view.h" +#undef NDEBUG +#include + +int main(void) +{ + PsxNetplayLocalView view; + uint32_t x = 0, y = 0, w = 0, h = 0; + psx_netplay_local_view_reset(&view); + assert(!psx_netplay_local_view_get(&view, 10, 320, 240, &x, &y, &w, &h)); + + /* Empty and out-of-VRAM rectangles are refused. */ + assert(!psx_netplay_local_view_set(&view, 10, 0, 0, 0, 120)); + assert(!psx_netplay_local_view_set(&view, 10, 1000, 0, 160, 120)); + assert(!view.valid); + + assert(psx_netplay_local_view_set(&view, 100, 160, 120, 160, 120)); + assert(psx_netplay_local_view_get(&view, 100, 320, 240, &x, &y, &w, &h)); + assert(x == 160 && y == 120 && w == 160 && h == 120); + /* Held for PSX_NETPLAY_LOCAL_VIEW_HOLD ticks, then lapses. */ + assert(psx_netplay_local_view_get(&view, 100 + PSX_NETPLAY_LOCAL_VIEW_HOLD, + 320, 240, NULL, NULL, NULL, NULL)); + assert(!psx_netplay_local_view_get(&view, + 101 + PSX_NETPLAY_LOCAL_VIEW_HOLD, + 320, 240, NULL, NULL, NULL, NULL)); + /* A display smaller than the rectangle presents the full frame. */ + assert(!psx_netplay_local_view_get(&view, 100, 256, 240, + NULL, NULL, NULL, NULL)); + + /* Resimulating an older tick renews the rectangle, not the stamp. */ + assert(psx_netplay_local_view_set(&view, 96, 0, 0, 160, 120)); + assert(view.tick == 100); + assert(psx_netplay_local_view_get(&view, 104, 320, 240, &x, &y, &w, &h)); + assert(x == 0 && y == 0); + /* A present slightly behind a resimulated request still uses it. */ + assert(psx_netplay_local_view_get(&view, 98, 320, 240, + NULL, NULL, NULL, NULL)); + /* A load far behind restarts the stamp. */ + assert(psx_netplay_local_view_set(&view, 10, 0, 0, 160, 120)); + assert(view.tick == 10); + assert(!psx_netplay_local_view_get(&view, 200, 320, 240, + NULL, NULL, NULL, NULL)); + + psx_netplay_local_view_reset(&view); + assert(!psx_netplay_local_view_get(&view, 10, 320, 240, + NULL, NULL, NULL, NULL)); + return 0; +} diff --git a/runtime/tests/test_netplay_sim_pad_cache.c b/runtime/tests/test_netplay_sim_pad_cache.c new file mode 100644 index 00000000..a0195da5 --- /dev/null +++ b/runtime/tests/test_netplay_sim_pad_cache.c @@ -0,0 +1,24 @@ +#include "netplay_sim_pad_cache.h" +#include +#include + +int main(void) +{ + PsxNetplaySimPadCache cache = {0}; + PsxNetPad predicted = {0xFFFEu, 0x80, 0x80, 0x80, 0x80, 0, 1}; + PsxNetPad sealed = {0xFFF7u, 0x81, 0x80, 0x80, 0x80, 1, 1}; + PsxNetPad out; + memset(&out, 0x5a, sizeof(out)); + assert(!psx_netplay_sim_pad_cache_read(&cache, 2, 4, 17, &out)); + psx_netplay_sim_pad_cache_publish(&cache, 2, 17, &predicted); + assert(psx_netplay_sim_pad_cache_read(&cache, 2, 4, 17, &out)); + assert(memcmp(&out, &predicted, sizeof(out)) == 0); + psx_netplay_sim_pad_cache_publish(&cache, 2, 17, &sealed); + assert(psx_netplay_sim_pad_cache_read(&cache, 2, 4, 17, &out)); + assert(memcmp(&out, &sealed, sizeof(out)) == 0); + assert(!psx_netplay_sim_pad_cache_read(&cache, 2, 4, 18, &out)); + assert(!psx_netplay_sim_pad_cache_read(&cache, 4, 4, 17, &out)); + psx_netplay_sim_pad_cache_reset(&cache); + assert(!psx_netplay_sim_pad_cache_read(&cache, 2, 4, 17, &out)); + return 0; +} diff --git a/runtime/tests/test_netplay_view_mods.cpp b/runtime/tests/test_netplay_view_mods.cpp new file mode 100644 index 00000000..757fb06a --- /dev/null +++ b/runtime/tests/test_netplay_view_mods.cpp @@ -0,0 +1,234 @@ +#include "mod_runtime.h" +#include "mod_packages.h" +#include "mod_plugins.h" +#include "psx_sha256.h" + +#include "gpu.h" +#include "cpu_state.h" +#include "psx_lobby_client.h" +#include "gpu_hd_textures.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +/* Netplay own-view mods (mod_runtime_commit_netplay_view): a feature whose + * only contribution is a [[plugin]] with netplay = "local_view" stays on in a + * match, per player; its hooks run only inside the sandboxed own-view render + * (psx_mod_local_view_scope), never in the shared simulation; everything + * else is cleared; state.toml is never rewritten. */ +static std::array ram; +static int failures; +static int test_netplay_active = 1; +static int test_scope; +extern "C" int psx_netplay_active(void) { return test_netplay_active; } +extern "C" int psx_mod_local_view_scope(void) { return test_scope; } +extern "C" const PsxLobbyMatchCaps* psx_lobby_match_caps(void) { return nullptr; } +/* No renderer or external pack exists in this mod-policy fixture. */ +extern "C" void gpu_hd_textures_shutdown(void) {} +extern "C" int gpu_hd_textures_configure(const char*, int, int, char*, size_t) { return 0; } +extern "C" void gpu_hd_textures_get_diag(GpuHdTextureDiag* out) { *out = {}; } +extern "C" void gpu_hd_textures_set_dump_enabled(int) {} +extern "C" int gpu_hd_textures_reload(char*, size_t) { return 0; } +extern "C" uint8_t psx_read_byte(uint32_t address) { + return ram[address & 0x1fffffu]; +} + +extern "C" void psx_write_byte(uint32_t address, uint8_t value) { + ram[address & 0x1fffffu] = value; +} + +extern "C" uint16_t psx_read_half(uint32_t address) { + const uint32_t offset = address & 0x1fffffu; + return (uint16_t)(ram[offset] | ((uint16_t)ram[offset + 1] << 8)); +} + +extern "C" void psx_write_half(uint32_t address, uint16_t value) { + const uint32_t offset = address & 0x1fffffu; + ram[offset] = (uint8_t)value; + ram[offset + 1] = (uint8_t)(value >> 8); +} + +extern "C" uint32_t psx_read_word(uint32_t address) { + const uint32_t offset = address & 0x1fffffu; + return (uint32_t)ram[offset] | + ((uint32_t)ram[offset + 1] << 8) | + ((uint32_t)ram[offset + 2] << 16) | + ((uint32_t)ram[offset + 3] << 24); +} + +extern "C" void psx_write_word(uint32_t address, uint32_t value) { + const uint32_t offset = address & 0x1fffffu; + ram[offset] = (uint8_t)value; + ram[offset + 1] = (uint8_t)(value >> 8); + ram[offset + 2] = (uint8_t)(value >> 16); + ram[offset + 3] = (uint8_t)(value >> 24); +} + +/* Mod writes are host stores (memory.c psx_host_write_*). */ +extern "C" void psx_host_write_byte(uint32_t address, uint8_t value) { + psx_write_byte(address, value); +} + +extern "C" void psx_host_write_half(uint32_t address, uint16_t value) { + psx_write_half(address, value); +} + +extern "C" void psx_host_write_word(uint32_t address, uint32_t value) { + psx_write_word(address, value); +} + +extern "C" uint32_t psx_mod_memory_alloc(uint32_t, uint32_t) { return 0; } +extern "C" uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t, uint32_t) { + return 0; +} +extern "C" void psx_ram_reset_size_request(void) {} +extern "C" void psx_projection_reset_session(void) {} +extern "C" void gpu_ws_set_native_scene_predicate(int (*)(void)) {} +extern "C" int psx_ws_x_margin(void) { return 0; } + +/* Stand-in for the GPU's display geometry. psx_mod_display_width/height must + * report exactly what the presenter reports -- a plugin drawing an overlay + * uses this as the screen edge, so a substituted or rounded value would put + * HUD elements in the wrong place. */ +static GpuDisplayInfo g_test_display; +extern "C" void gpu_get_display_info(GpuDisplayInfo* out) { + *out = g_test_display; +} + +extern "C" void dirty_ram_mark_executable_range(uint32_t, uint32_t) {} +extern "C" int fntrace_is_game_started(void) { return 1; } +/* Widescreen tag passthroughs mod_runtime forwards to the GPU; unused here. */ +extern "C" void gpu_ws_tag_hud_primitive(uint32_t, int) {} +extern "C" void gpu_ws_tag_world_primitive(uint32_t, int) {} +extern "C" void gpu_ws_set_adaptive_backdrop_preload(int) {} +extern "C" int gpu_ws_configured_x_reveal(void) { return 0; } +extern "C" void gpu_ws_tag_hud_prim(uint32_t, int) {} +extern "C" void gpu_ws_tag_screen_mask_quad(uint32_t) {} +extern "C" void gpu_ws_tag_radial_screen_mask_quad(uint32_t, float) {} + +static void check(bool value, const char* message) { + if (!value) { + std::cerr << "FAIL: " << message << "\n"; + failures++; + } +} + +static void write_text(const fs::path& path, const std::string& text) { + fs::create_directories(path.parent_path()); + std::ofstream out(path); + out << text; +} + +static std::string read_text(const fs::path& path) { + std::ifstream in(path); + return std::string(std::istreambuf_iterator(in), {}); +} + +static int view_hits, sim_hits, view_activations, sim_activations, vblanks; +static void view_entry(CPUState*, uint32_t) { view_hits++; } +static void sim_entry(CPUState*, uint32_t) { sim_hits++; } +static void view_activate(void) { view_activations++; } +static void sim_activate(void) { sim_activations++; } +static void view_vblank(void) { vblanks++; } + +static std::string package(const std::string& id, const std::string& feature, + const std::string& plugin, const std::string& extra) { + return "format_version = 7\n" + "id = \"" + id + "\"\nversion = \"1.0.0\"\nname = \"T\"\n" + "[[target]]\ngame_id = \"SLUS-VIEW\"\n" + "[[feature]]\nid = \"" + feature + "\"\nname = \"F\"\n" + "[[plugin]]\nfeature = \"" + feature + "\"\nid = \"" + plugin + "\"\n" + + extra; +} + +int main() { + const fs::path root = fs::temp_directory_path() / "psx_netplay_view_mods_test"; + fs::remove_all(root); + write_text(root / "bundled/view.pkg/1.0.0/manifest.toml", + package("view.pkg", "wide", "view.wide", "netplay = \"local_view\"\n")); + write_text(root / "bundled/sim.pkg/1.0.0/manifest.toml", + package("sim.pkg", "cheat", "sim.cheat", "")); + write_text(root / "bundled/bad.pkg/1.0.0/manifest.toml", + package("bad.pkg", "x", "bad.x", "netplay = \"everywhere\"\n")); + const std::string state = + "format_version = 2\n" + "[[feature]]\npackage_id = \"view.pkg\"\nid = \"wide\"\nenabled = true\n" + "[[feature]]\npackage_id = \"sim.pkg\"\nid = \"cheat\"\nenabled = true\n"; + write_text(root / "state.toml", state); + + check(psx_mod_register_function_entry_plugin("view.wide", 0x80003000u, view_entry), + "register view hook"); + check(psx_mod_register_function_entry_plugin("sim.cheat", 0x80003100u, sim_entry), + "register sim hook"); + check(psx_mod_register_activation_plugin("view.wide", view_activate) && + psx_mod_register_activation_plugin("sim.cheat", sim_activate), + "register activations"); + check(psx_mod_register_vblank_plugin("view.wide", view_vblank), "register vblank"); + + std::string error; + check(PSXRecompV4::mod_runtime_initialize(root, "SLUS-VIEW", 0x80002000u, {}, &error), + error.c_str()); + + /* Offline: both run in the game. */ + check(PSXRecompV4::mod_runtime_commit({}, &error), error.c_str()); + check(!PSXRecompV4::mod_runtime_netplay_view_active(), "offline plan is not own-view"); + mod_runtime_activate_plugins(); + CPUState cpu{}; + psx_mod_function_entry(&cpu, 0x80003000u); + psx_mod_function_entry(&cpu, 0x80003100u); + check(view_hits == 1 && sim_hits == 1, "offline hooks run in the game"); + + /* Netplay: only the local-view feature remains, parked outside the scope. */ + view_hits = sim_hits = view_activations = sim_activations = vblanks = 0; + const std::string saved = read_text(root / "state.toml"); + check(PSXRecompV4::mod_runtime_commit_netplay_view({}, &error), error.c_str()); + check(PSXRecompV4::mod_runtime_netplay_view_active(), "own-view plan active"); + mod_runtime_activate_plugins(); + check(view_activations == 1 && sim_activations == 0, + "only the local-view plugin activates in a match"); + psx_mod_function_entry(&cpu, 0x80003000u); + psx_mod_function_entry(&cpu, 0x80003100u); + mod_runtime_on_vblank(); + check(view_hits == 0 && sim_hits == 0 && vblanks == 0, + "the shared simulation runs no mod hook or vblank"); + test_scope = 1; + psx_mod_function_entry(&cpu, 0x80003000u); + psx_mod_function_entry(&cpu, 0x80003100u); + test_scope = 0; + check(view_hits == 1 && sim_hits == 0, + "inside the own-view render only the local-view hook runs"); + check(read_text(root / "state.toml") == saved, "state.toml untouched by a match"); + + /* A plan whose own-view feature also writes the game does not qualify. */ + PSXRecompV4::ModResolution plan; + plan.plugins.push_back({"p", "a.pkg", "f", true}); + plan.plugins.push_back({"q", "b.pkg", "g", true}); + PSXRecompV4::ModResolution::Write w; + w.package_id = "b.pkg"; + w.feature_id = "g"; + plan.writes.push_back(w); + const auto keep = PSXRecompV4::mod_runtime_netplay_view_features(plan); + check(keep.size() == 1 && keep[0] == "a.pkg/f", + "a feature with a game write is not own-view"); + + check(PSXRecompV4::mod_runtime_clear_for_netplay(&error) && + !PSXRecompV4::mod_runtime_netplay_view_active(), + "a clear ends the own-view plan"); + + fs::remove_all(root); + if (failures) { + std::cerr << failures << " failure(s)\n"; + return 1; + } + std::cout << "netplay_view_mods_test: ok\n"; + return 0; +} diff --git a/runtime/tests/test_pgxp_session.cpp b/runtime/tests/test_pgxp_session.cpp index 2d3e166a..d201ba52 100644 --- a/runtime/tests/test_pgxp_session.cpp +++ b/runtime/tests/test_pgxp_session.cpp @@ -364,3 +364,6 @@ int main(void) { std::printf("test_pgxp_session: all checks passed\n"); return 0; } + +/* render_pass.c: no sandboxed local view runs in this test. */ +extern "C" int psx_mod_local_view_scope(void) { return 0; } diff --git a/runtime/tests/test_render_pass_abort.c b/runtime/tests/test_render_pass_abort.c index fe416b9b..530bba56 100644 --- a/runtime/tests/test_render_pass_abort.c +++ b/runtime/tests/test_render_pass_abort.c @@ -105,6 +105,8 @@ void mdec_snapshot_write(uint8_t *p) { (void)p; } 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; } @@ -192,6 +194,11 @@ int gl_renderer_pass_verify_vram(void) { return 1; } void gl_renderer_pass_note_cost(uint64_t t) { (void)t; } void gl_renderer_pass_service_presents(void) {} uint32_t g_psx_vblank_cycles = 564480u; +uint32_t gl_renderer_local_view_unavailable(void) { return 0; } +int gl_renderer_local_view_begin(int x, int y, int w, int h) { + return gl_renderer_pass_begin(x, y, w, h, 0, 0, 0); +} +int gl_renderer_local_view_end(int keep) { gl_renderer_pass_end(0, keep); return 1; } uint32_t gl_renderer_stereo_unavailable(void) { return 0; } int gl_renderer_stereo_begin(int x, int y, int w, int h, int reuse) { return gl_renderer_pass_begin(x, y, w, h, 0, 0, reuse); diff --git a/runtime/tests/test_render_pass_sandbox.c b/runtime/tests/test_render_pass_sandbox.c index cf67cfb9..08327a1b 100644 --- a/runtime/tests/test_render_pass_sandbox.c +++ b/runtime/tests/test_render_pass_sandbox.c @@ -157,6 +157,12 @@ int psx_get_in_exception(void) { return 0; } static int s_netplay_active, s_netplay_resim; int psx_netplay_active(void) { return s_netplay_active; } int psx_netplay_is_resimulating(void) { return s_netplay_resim; } +static int s_crop_clears; +#define s_netplay s_netplay_active +#define s_resim s_netplay_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; } @@ -243,6 +249,17 @@ uint32_t gl_renderer_pass_leaks(void) { return 0; } int gl_renderer_pass_verify_vram(void) { return 1; } void gl_renderer_pass_note_cost(uint64_t t) { (void)t; } void gl_renderer_pass_service_presents(void) {} +static uint32_t s_local_status = PSX_MOD_RENDER_PASS_READY; +static int s_local_kept; +uint32_t gl_renderer_local_view_unavailable(void) { return s_local_status; } +int gl_renderer_local_view_begin(int x, int y, int w, int h) { + return gl_renderer_pass_begin(x, y, w, h, 0, 0, 0); +} +int gl_renderer_local_view_end(int keep) { + gl_renderer_pass_end(0, 0); + if (keep) s_local_kept++; + return 1; +} static uint32_t s_stereo_mask; static uint64_t s_stereo_published; static int s_capture_fail_eye = -1; @@ -731,6 +748,66 @@ static void test_stereo(void) { CHECK(!psx_mod_render_stereo(&cpu, &f, pair_draw, NULL), "invalid cadence refused"); } +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); + return 1; +} + +/* Netplay local view: forward netplay frames only; guest state restored + * exactly; a committed image cancels the crop request. */ +static void test_local_view(void) { + CPUState cpu = {0}; + PSXModRenderPass r; + RenderPassStats st; + uint64_t cycle; + render_pass_reset_session(); s_gl_status = 0; s_begin_ok = 1; + memset(&r, 0, sizeof r); + r.struct_size = sizeof r; + r.y = 240; r.w = 320; r.h = 240; + cpu.gpr[8] = 7; s_ram[0x456] = 0x77; + cycle = psx_cycle_count; + s_netplay = 0; + CHECK(psx_mod_render_local_view_status() == PSX_MOD_RENDER_PASS_SESSION, + "local view refused offline"); + CHECK(!psx_mod_render_local_view(&cpu, &r, never_fn, NULL), "no offline call"); + s_netplay = 1; + CHECK(psx_mod_render_pass_status() == PSX_MOD_RENDER_PASS_SESSION, + "temporal passes stay off in netplay"); + CHECK(psx_mod_render_local_view_status() == PSX_MOD_RENDER_PASS_READY, + "local view ready in netplay"); + s_resim = 1; + CHECK(psx_mod_render_local_view_status() == PSX_MOD_RENDER_PASS_SESSION && + !psx_mod_render_local_view(&cpu, &r, never_fn, NULL), + "no local view while resimulating"); + s_resim = 0; + s_local_status = PSX_MOD_RENDER_PASS_BACKEND; + CHECK(psx_mod_render_local_view_status() == PSX_MOD_RENDER_PASS_BACKEND && + !psx_mod_render_local_view(&cpu, &r, never_fn, NULL), + "no local view without a separate presenter surface"); + s_local_status = PSX_MOD_RENDER_PASS_READY; + s_crop_clears = 0; + CHECK(psx_mod_render_local_view(&cpu, &r, local_draw, NULL) == 1, + "local view committed"); + render_pass_get_stats(&st); + 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; + CHECK(!psx_mod_render_local_view(&cpu, &r, never_fn, NULL), + "rect outside VRAM refused"); + s_netplay = 0; +} + static void test_texture_stream_journal(void) { RenderPassJournal j = {0}; memcpy(s_vram, s_vram0, sizeof s_vram); @@ -758,6 +835,7 @@ int main(void) { test_ram_8mb(); test_journal(); test_stereo(); + test_local_view(); test_texture_stream_journal(); CHECK(s_prec_open == 0, "precision checkpoints balanced"); printf(failures ? "FAILED (%d)\n" : "ALL PASS\n", failures); diff --git a/runtime/tests/test_ws_local_view_margin.c b/runtime/tests/test_ws_local_view_margin.c new file mode 100644 index 00000000..05305ba4 --- /dev/null +++ b/runtime/tests/test_ws_local_view_margin.c @@ -0,0 +1,38 @@ +#ifdef NDEBUG +#undef NDEBUG +#endif + +/* Netplay own-view widescreen: with gpu_ws_set_local_view_only(1) the cull + * margin the guest sees is the stock 0 everywhere except inside a sandboxed + * local-view render (gpu_ws_set_local_view_scope), where it is the margin the + * configured native-wide aspect implies. Off (the default) changes nothing. */ +#include +#include +#include +#include +#include "../src/gpu.c" +#include "gpu_exec_stubs.inc" + +int main(void) { + ws_mode = 2; + ws_cfg_num = 16; ws_cfg_den = 9; + hres1 = 1; hres2 = 0; video_mode = 0; /* 320 wide */ + h_display_x1 = 0x260; h_display_x2 = 0xC60; + v_display_y1 = 0x10; v_display_y2 = 0x100; + const int wide = psx_ws_x_margin(); + assert(wide > 0); + assert(!gpu_ws_local_view_only()); + + gpu_ws_set_local_view_only(1); + assert(psx_ws_x_margin() == 0); /* shared simulation: stock */ + assert(psx_ws_player_x_bound(160) == 160); + gpu_ws_set_local_view_scope(1); + assert(psx_ws_x_margin() == wide); /* own view: this peer's */ + gpu_ws_set_local_view_scope(0); + assert(psx_ws_x_margin() == 0); + + gpu_ws_set_local_view_only(0); + assert(psx_ws_x_margin() == wide); /* offline: unchanged */ + puts("ws_local_view_margin_test: ok"); + return 0; +}