diff --git a/THIRD_PARTY_ATTRIBUTION.md b/THIRD_PARTY_ATTRIBUTION.md index ccbe9a2c..64ee340a 100644 --- a/THIRD_PARTY_ATTRIBUTION.md +++ b/THIRD_PARTY_ATTRIBUTION.md @@ -58,6 +58,36 @@ into `licenses/`. Developers can use the verified archive or an explicit `FETCHCONTENT_SOURCE_DIR_PSX_LIBWEBP` / `PSX_LIBWEBP_SOURCE_DIR` source override. PNG and JPEG continue to use the existing shared stb_image implementation. +## xBR — edge-directed texture filter (Hyllian) + +The `texture_filtering = "xbr"` mode in `runtime/src/gpu_gl_renderer.c` +(`xbr_d`, `xbr_sample`) follows Hyllian's xBR algorithm: its 5x5 neighbourhood, +YUV-weighted colour distance and the `4*d(H,F)` / `4*d(E,I)` edge sums. It is +an adaptation (CLUT texel fetches, primitive UV clamping, an anti-aliased corner +cut), not a copy of a shader file, but it derives from that work, licensed MIT: + +``` +Copyright (C) 2011-2016 Hyllian - sergiogdb@gmail.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +``` + ## Vendored libraries These are checked in under `recompiler/lib/` and `runtime/third_party/` with diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 9fad8eca..2c2ea70a 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -757,6 +757,13 @@ some selections of one feature is a `[[requirement]]`, not a dependency. ## Implicit requirements across packages +Trusted graphics plugins can call `psx_mod_set_internal_resolution(lines)` to +request a rendering height for their session. Values from 120 through 8192 use +the existing integer-scale resolver and backend allocation limits; zero clears +the request. `PSX_INTERNAL_RESOLUTION` takes precedence. The player's saved +resolution stays unchanged, and every new session clears the request before +its mod plan activates, including rematches and sessions without that mod. + The shared PGXP plugin uses `psx_mod_set_pgxp_precision(enabled, cpu_mode)`. This stores a session selection as well as setting live correction flags, so later renderer initialization cannot erase mod activation with the base video diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 86f079b3..3ef61436 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -486,9 +486,9 @@ between Play and TCP availability. ## Complete command index (generated) -**368 commands registered** — 355 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**369 commands registered** — 356 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -82 of 368 have prose above; **286 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. +82 of 369 have prose above; **287 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code. @@ -800,6 +800,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `stereo_dump` | ✓ | | | | `stereo_stats` | ✓ | | | | `synth_recurse` | ✓ | | | +| `texture_filter` | ✓ | | | | `texture_lod` | ✓ | | ✓ | | `thread_ctx_ring` | ✓ | | | | `thread_trace` | ✓ | | | diff --git a/docs/config_schema.md b/docs/config_schema.md index 0585d31b..6950f926 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -702,9 +702,12 @@ switches them live. ```toml [video] -accurate_blending = false # default +accurate_blending = true # default: PS1 colour/blend correctness ``` +Accurate blending is enabled by default because it corrects PS1 rendering. +`false` retains the historical path for diagnosis. + The OpenGL surface holds each 5-bit VRAM channel as an 8-bit value. Uploads, copies and untextured primitives store `k*8`, but fills and texels have always stored `k*255/31` (full-range white). Semi-transparent blending of two @@ -723,6 +726,29 @@ first layer's full 8-bit result where the console would have truncated it to `PSX_ACCURATE_BLENDING=0|1` overrides it for one run and the `accurate_blending` TCP command switches it live. +### Texture filtering (`texture_filtering`) + +```toml +[video] +texture_filtering = "nearest" # nearest (default) | bilinear | xbr +``` + +`nearest` is the PS1's point sampling. `bilinear` blends the four nearest +texels (opacity-gated, so cutout edges keep their colour). `xbr` (OpenGL +only) is edge-directed magnification after Hyllian's xBR (MIT; see +`THIRD_PARTY_ATTRIBUTION.md`): for the texel +corner nearest each sample it compares weighted colour distances over a 5x5 +neighbourhood, and where an edge runs along that corner's diagonal the corner +is cut and takes the closer side neighbour, with an anti-aliased boundary one +output pixel wide. Flat areas, straight edges and gradients stay crisp +texels; diagonals and curves lose their staircase. It acts only where a +texel spans two or more output pixels (high internal scale or a magnified +texture), so a 1:1 draw is exactly nearest; cutouts and STP stay the centre +texel's. It costs up to 13 texel fetches per pixel. JINC2 is not offered. +`PSX_TEXTURE_FILTER=0..3` overrides it for one run (2 is the mod-only "stable +world" minification filter) and the `texture_filter` TCP command switches it +live. The launcher's toggle still offers nearest and bilinear. + The runtime clamps N per backend: - Software and Vulkan stop at 4. @@ -834,6 +860,13 @@ has), and a title-allowed direct Rewind button (`psx_mod_allow_direct_shortcut`) is not claimed, so it reaches the game as if Rewind were off. The block clears at every mod/session reset; nothing changes for a title that never calls it. +A title that computes vibration but cannot send it over SIO to the presented +pad (a NeGcon has no motors) can call `psx_mod_set_host_rumble(player, small, +large)` every frame with DualShock motor values. The runtime drives the host +pad with the louder of that and the guest's SIO motors; a value lapses 8 +VBlanks after the last call and all are cleared at every mod/session reset. +`PSX_RUMBLE_TRACE=1` logs every change, headless included. + Bezel artwork is intentionally not a `[video]` key. It is exposed as the disabled-by-default `psx.presentation.bezel` mod package, which draws a user-selected image resource behind the game image in OpenGL letterbox or diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index b7f70d5c..b1aa2101 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -83f82f8b2daed07e75765c7ae4adb78cdb1a9fd7396fb879089c2352c8dc78a6 +e7c8c626e955b89839c5dda1d9990857d87a0042a1cdc1071bbdc42b754a4146 diff --git a/lib/recomp-net b/lib/recomp-net index b1eab87b..a48d355b 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit b1eab87bf3c011da8c22890601c0e659f1f89fbb +Subproject commit a48d355b1504a54c4f80354b2c57f8b5ce30c7dd diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index c7574499..d050bcb8 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -776,8 +776,9 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& const auto mode = toml::find(video, "texture_filtering"); if (mode == "nearest") rt.video_texture_filter = 0; else if (mode == "bilinear") rt.video_texture_filter = 1; + else if (mode == "xbr") rt.video_texture_filter = 3; else throw std::runtime_error(fmt::format( - "[video] texture_filtering must be \"nearest\" or \"bilinear\": {}", mode)); + "[video] texture_filtering must be \"nearest\", \"bilinear\" or \"xbr\": {}", mode)); } if (video.contains("fmv_filter")) { const auto mode = toml::find(video, "fmv_filter"); @@ -2869,6 +2870,7 @@ UserSettings load_user_settings(const fs::path& path) { const auto m = toml::find(v, "texture_filtering"); if (m == "nearest") { s.texture_filter = 0; s.has_texture_filter = true; } else if (m == "bilinear") { s.texture_filter = 1; s.has_texture_filter = true; } + else if (m == "xbr") { s.texture_filter = 3; s.has_texture_filter = true; } }); if (v.contains("fmv_filter")) try_get([&]{ const auto m = toml::find(v, "fmv_filter"); @@ -3270,7 +3272,7 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) { if (s.has_antialiasing) f << "antialiasing = " << (s.antialiasing ? "true" : "false") << "\n"; if (s.has_texture_filter) - f << "texture_filtering = \"" << (s.texture_filter ? "bilinear" : "nearest") << "\"\n"; + f << "texture_filtering = \"" << (s.texture_filter == 3 ? "xbr" : s.texture_filter ? "bilinear" : "nearest") << "\"\n"; if (s.has_fmv_filter) f << "fmv_filter = \"" << video_fmv_filter_name(s.fmv_filter) << "\"\n"; if (s.has_geometry_correction) diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index d7b37237..5c183a84 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -460,6 +460,7 @@ struct RuntimeConfig { int video_supersample_milli = 1000; // texture_filtering: "nearest" (default, native PSX look) | "bilinear" + // | "xbr" (3: edge-directed magnification, OpenGL only) // (smooths textures and 2D backgrounds). Stored as 0/1. int video_texture_filter = 0; @@ -575,8 +576,8 @@ struct RuntimeConfig { int video_anisotropic_filtering = 1; // accurate_blending: one 8-bit basis (k*8) for every VRAM write so GL // semi-transparency truncates exactly like the PS1's 5-bit blend. - // Default false (historical: fills/texels full-range). OpenGL only. - bool video_accurate_blending = false; + // Default true: PS1 colour/blend correctness. OpenGL only. + bool video_accurate_blending = true; bool video_pgxp_color_correction = false; int video_pgxp_seam = 0; double video_pgxp_depth_threshold = 4096.0; diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 59147897..76b86ff5 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -53,6 +53,9 @@ if(BUILD_TESTING) 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_fmv_confirm_test tests/test_netplay_fmv_confirm.c) + target_include_directories(netplay_fmv_confirm_test PRIVATE include) + add_test(NAME netplay_fmv_confirm_test COMMAND netplay_fmv_confirm_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) @@ -288,6 +291,18 @@ if(BUILD_TESTING) --output "${CMAKE_CURRENT_BINARY_DIR}/gl-blend-accuracy-results") set_tests_properties(gl_blend_accuracy_test PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 180 SKIP_RETURN_CODE 77) + # texture_filtering = "xbr": diagonals lose the staircase, straight + # edges, flat areas, cutouts and 1:1 draws stay nearest; RT agrees. + add_test(NAME gl_xbr_filter_test + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_render_thread.py" --single + --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_xbr.c" + --cc "${CMAKE_C_COMPILER}" + --sdl-include "${_gl_scale_sdl_inc}" + --sdl-library "$" + --output "${CMAKE_CURRENT_BINARY_DIR}/gl-xbr-results") + set_tests_properties(gl_xbr_filter_test PROPERTIES + LABELS "gpu;opengl;hardware" TIMEOUT 180 SKIP_RETURN_CODE 77) unset(_gl_scale_sdl_inc) else() message(STATUS "gl_scale_invariance_test: not registered (needs macOS, Linux or MinGW, Python 3 and SDL3)") @@ -622,6 +637,11 @@ if(BUILD_TESTING) target_include_directories(psx_pad_apply_test PRIVATE include) add_test(NAME psx_pad_apply_test COMMAND psx_pad_apply_test) + # Title-supplied host rumble (psx_mod_set_host_rumble) merge and expiry. + add_executable(host_rumble_test tests/test_host_rumble.c) + target_include_directories(host_rumble_test PRIVATE include) + add_test(NAME host_rumble_test COMMAND host_rumble_test) + add_executable(controller_type_test tests/test_controller_type.cpp) target_include_directories(controller_type_test PRIVATE include) add_test(NAME controller_type_test COMMAND controller_type_test) @@ -1059,6 +1079,10 @@ if(BUILD_TESTING) ../recompiler/lib/toml11) add_test(NAME text_xlate_fix_only_test COMMAND text_xlate_fix_only_test) + add_executable(netplay_peer_frontier_test tests/test_netplay_peer_frontier.c) + target_include_directories(netplay_peer_frontier_test PRIVATE include) + add_test(NAME netplay_peer_frontier_test COMMAND netplay_peer_frontier_test) + add_executable(netplay_content_gate_test tests/test_netplay_content_gate.c) target_include_directories(netplay_content_gate_test PRIVATE include) add_test(NAME netplay_content_gate_test COMMAND netplay_content_gate_test) @@ -1286,6 +1310,12 @@ if(BUILD_TESTING) endif() add_test(NAME render_pass_plan_test COMMAND render_pass_plan_test) + add_executable(mod_internal_resolution_test + tests/test_mod_internal_resolution.c + src/mod_internal_resolution.c) + target_include_directories(mod_internal_resolution_test PRIVATE include) + add_test(NAME mod_internal_resolution_test COMMAND mod_internal_resolution_test) + # Motion kit and per-frame driver for render-pass plugins: blending, # identity matching, limits and the crossfade fallback, on mock RAM. add_executable(render_pass_motion_test diff --git a/runtime/include/boot_state.h b/runtime/include/boot_state.h index 2229c1f9..d47e6e4d 100644 --- a/runtime/include/boot_state.h +++ b/runtime/include/boot_state.h @@ -107,6 +107,12 @@ enum { BS_SEC_SIO = 0x0D, /* SIO regs + pad-config FSM + memcard FSM */ BS_SEC_DIRTY = 0x0E, /* dirty-RAM page bitmap (guest-written code pages) */ BS_SEC_MDEC = 0x0F, /* MDEC command/FIFOs/quant/scale (FMV decode resume) */ + BS_SEC_SPUCD = 0x15, /* SPU CD-input frames queued (u32 n + n*(l,r) s16) */ + BS_SEC_CPUTIM = 0x14, /* [timing] in-flight CPU stall state, relative to + the clock: GTE / mul-div cycles left, the load- + delay give-back (read_absorb[33], which, fudge, + ld_which_t, ld_absorb). Absent => anchored at the + restored clock (pre-0x14 behaviour). */ BS_SEC_GCS = 0x13, /* [timing] guest_cycle_scale: carried fraction + gate state (3 u32). Written only when the title's scale is not 1, so faithful states are unchanged; diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 8a7150d0..d6e2c17b 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -300,7 +300,7 @@ int gl_renderer_anisotropy(void); /* [video] accurate_blending: store every VRAM write in the k*8/255 basis so * 8-bit GL blending truncated to 5 bits is exactly the PS1's semi-transparency * arithmetic (whites of fills and textures become 248 instead of 255, as - * uploads and untextured primitives already are). Off by default. */ + * uploads and untextured primitives already are). On by default. */ void gl_renderer_set_accurate_blending(int on); int gl_renderer_accurate_blending(void); int gl_renderer_get_pgxp_depth(void); diff --git a/runtime/include/mod_internal_resolution.h b/runtime/include/mod_internal_resolution.h new file mode 100644 index 00000000..e81c6a4a --- /dev/null +++ b/runtime/include/mod_internal_resolution.h @@ -0,0 +1,17 @@ +#ifndef PSX_MOD_INTERNAL_RESOLUTION_H +#define PSX_MOD_INTERNAL_RESOLUTION_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Runtime session plumbing; plugins use mod_plugins.h. Zero means no request. */ +uint32_t psx_mod_internal_resolution_request(void); +void psx_mod_internal_resolution_reset(void); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index a7af4a05..2b0d3605 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -439,6 +439,13 @@ int psx_mod_set_adaptive_display_aspect(uint32_t max_numerator, * title's setting before activation. */ int psx_mod_set_widescreen_hud_size(int proportional); +/* Request an internal rendering height for this mod session (120..8192). + * The existing resolution resolver selects an integer raster scale and the + * backend applies its normal allocation limits. Zero clears the request. + * Player settings are preserved, explicit PSX_INTERNAL_RESOLUTION overrides + * win, and each session clears this request before activating its committed + * mod plan. */ +int psx_mod_set_internal_resolution(uint32_t target_lines); /* * Set the wall-clock cadence of simulated guest VBlanks. A value of zero * removes frontend pacing; 60 and higher request that many native guest @@ -1014,6 +1021,17 @@ int psx_mod_allow_direct_shortcut(uint32_t shortcut); * title's state is fine). Cleared at every mod/session reset. */ void psx_mod_set_rewind_blocked(int blocked); +/* Title-supplied host rumble for one guest port (0-based): DualShock motor + * values (small: 0 off / nonzero on, large: 0..255) for a title that + * computes vibration but cannot send it over SIO to the presented pad (e.g. a + * NeGcon has no motors). The runtime drives the host pad with the louder of + * this and the guest's own SIO motors. Online only the local seat's value + * is used (this peer's local port, on its pad) and calls during a rollback resim are + * ignored, so a netplay = "host_output" plugin can call it for every seat. + * Opt-in: call every frame while it applies; a value lapses 8 VBlanks after the last call, and all are cleared + * at every mod/session reset (psx_host_rumble.h). Returns 0 for a bad seat. */ +int psx_mod_set_host_rumble(uint32_t player, uint32_t small, uint32_t large); + /* Local P1 mouse policy. The runtime delivers ordered events on the SDL owner * (main) thread, owns relative capture and folds the resulting right-stick * bytes after native input/presentation (and after any controller source), diff --git a/runtime/include/netplay_fmv_confirm.h b/runtime/include/netplay_fmv_confirm.h new file mode 100644 index 00000000..edf9c171 --- /dev/null +++ b/runtime/include/netplay_fmv_confirm.h @@ -0,0 +1,21 @@ +/* netplay_fmv_confirm.h - post-movie agreement counting for rollback netplay. + * + * After a movie, both peers must agree for RB_FMV_LOCKSTEP_CONFIRM ticks + * before prediction is unlocked. Agreement is measured in hash-confirmed + * ticks: each newly confirmed tick past the movie's end counts once, however + * late its confirmation arrives. A confirmation is always about one RTT + * behind the tick it confirms, so testing "is sim-1 confirmed yet" every tick + * never succeeds on the peer that runs ahead. */ +#ifndef PSX_NETPLAY_FMV_CONFIRM_H +#define PSX_NETPLAY_FMV_CONFIRM_H + +#include + +/* Confirmed ticks gained when the confirmed frontier moves from `from` (the + * highest tick already counted, or the movie's end) to `resolved`. */ +static inline uint32_t rb_fmv_confirm_gain(uint32_t from, uint32_t resolved) +{ + return resolved > from ? resolved - from : 0u; +} + +#endif diff --git a/runtime/include/netplay_peer_frontier.h b/runtime/include/netplay_peer_frontier.h new file mode 100644 index 00000000..6ec78f32 --- /dev/null +++ b/runtime/include/netplay_peer_frontier.h @@ -0,0 +1,111 @@ +/* Per-seat peer RESOLVED frontiers for rollback episodes. + * + * A follower can only replay from a snapshot at or below the frontier it + * advertised. With two seats there is one follower and one number. With more, + * the initiator must open at or below the SLOWEST follower's frontier: taking + * the highest advert (the old single watermark) let the fastest peer stand in + * for the others, and on a 3-seat WAN race the host opened load=2400 against + * followers at 2344 and 2319 -- every follower refused, the episodes aborted + * and the session diverged before the race started. + * + * Header-only and free of runtime state so the bookkeeping is unit-tested + * (tests/test_netplay_peer_frontier.c). */ +#ifndef NETPLAY_PEER_FRONTIER_H +#define NETPLAY_PEER_FRONTIER_H + +#include +#include + +#define NETPLAY_PEER_FRONTIER_SEATS 16u + +typedef struct NetplayPeerFrontier { + uint32_t through[NETPLAY_PEER_FRONTIER_SEATS]; +} NetplayPeerFrontier; + +static inline void netplay_peer_frontier_reset(NetplayPeerFrontier *f) +{ + if (f) + memset(f, 0, sizeof(*f)); +} + +/* A RESOLVED advert from `seat`. Watermark: never lowers. */ +static inline void netplay_peer_frontier_note(NetplayPeerFrontier *f, int seat, + uint32_t through) +{ + if (!f || seat < 0 || (uint32_t)seat >= NETPLAY_PEER_FRONTIER_SEATS) + return; + if (through > f->through[seat]) + f->through[seat] = through; +} + +/* Evidence above `tick` was invalidated (realign) or a follower proved it + * cannot follow above it (NACK): no seat may stay above it. */ +static inline void netplay_peer_frontier_clamp(NetplayPeerFrontier *f, uint32_t tick) +{ + uint32_t i; + if (!f) + return; + for (i = 0; i < NETPLAY_PEER_FRONTIER_SEATS; ++i) + if (f->through[i] > tick) + f->through[i] = tick; +} + +/* Frontier every seat in `seats` has reached: the lowest, and 0 while any of + * them has not advertised yet. */ +static inline uint32_t netplay_peer_frontier_min(const NetplayPeerFrontier *f, + uint32_t seats) +{ + uint32_t i, lo = 0xffffffffu; + int any = 0; + if (!f) + return 0u; + for (i = 0; i < NETPLAY_PEER_FRONTIER_SEATS; ++i) { + if (!(seats & (1u << i))) + continue; + any = 1; + if (f->through[i] < lo) + lo = f->through[i]; + } + return any ? lo : 0u; +} + +/* §55 bisect cap: after a baseline mismatch at load L the next load must be + * below L. Once every peer has hash-confirmed a tick at or past L (agreed + * ADVANCE from a live confirm) the fork is healed and the cap is stale; left + * armed it refused every later episode, because the only loads it allowed had + * long left the snapshot ring (4-seat race: cap 1928, agreed 3136, ring + * 2656..3166 -> "no confirmed snap" and a permanent fork from 3168). */ +static inline int netplay_fork_cap_retired(uint32_t fork_cap, uint32_t agreed_through) +{ + return fork_cap > 0u && agreed_through >= fork_cap; +} + +/* Concurrent BEGIN while an episode is active. Wire epoch ids carry their + * initiator in the low bits (`slot_mask`); the lower initiator slot wins. + * Returns +1 when the incoming episode wins (yield ours if still in the + * handshake, then follow it), -1 when ours wins (drop the incoming BEGIN), + * 0 when the rule does not apply (same epoch, or a follower seeing another + * epoch from the initiator it already follows -- an abort-and-reopen). + * + * A follower applies it too: with three or more seats two guests can both + * BEGIN on the same mispredict, and a follower that followed whichever + * arrived first split the seats across epochs (4-seat WAN: host following + * slot 3's epoch while slot 1's won; every seat then waited on baselines + * that never came). */ +static inline int netplay_rb_concurrent_begin(uint32_t ours, uint32_t theirs, + int following, uint32_t slot_mask) +{ + uint32_t our_slot = ours & slot_mask; + uint32_t their_slot = theirs & slot_mask; + if (ours == theirs) + return 0; + if (following && our_slot == their_slot) + return 0; + if (their_slot < our_slot) + return 1; + if (their_slot > our_slot) + return -1; + return 0; +} + +#endif /* NETPLAY_PEER_FRONTIER_H */ diff --git a/runtime/include/psx_host_rumble.h b/runtime/include/psx_host_rumble.h new file mode 100644 index 00000000..9016d4ad --- /dev/null +++ b/runtime/include/psx_host_rumble.h @@ -0,0 +1,44 @@ +/* psx_host_rumble.h - title-supplied host rumble (psx_mod_set_host_rumble). + * + * Some titles compute vibration for every pad but only send it over SIO to a + * DualShock (R4 with a presented NeGcon keeps running its vibration patterns + * and writes them nowhere useful). A trusted mod can hand the runtime the + * DualShock-equivalent motor values instead; the runtime forwards the louder + * of that and the guest's own SIO motors to the host pad. A value lapses + * PSX_HOST_RUMBLE_TTL VBlanks after the last call, so a mod that stops + * calling (menus, unloaded) can never leave a pad vibrating. Pure, so tests + * link it directly. Nothing changes for a title that never calls it. */ +#ifndef PSX_HOST_RUMBLE_H +#define PSX_HOST_RUMBLE_H + +#include + +#define PSX_HOST_RUMBLE_TTL 8u + +typedef struct PsxHostRumbleSlot { + uint8_t small; /* DualShock small motor: 0 off, nonzero on */ + uint8_t large; /* DualShock large motor strength 0..255 */ + uint8_t valid; + uint32_t stamp; /* VBlank of the last call */ +} PsxHostRumbleSlot; + +static inline void psx_host_rumble_set(PsxHostRumbleSlot *slot, uint32_t now, + uint32_t small, uint32_t large) { + if (!slot) return; + slot->small = small ? 1u : 0u; + slot->large = (uint8_t)(large > 255u ? 255u : large); + slot->valid = 1u; + slot->stamp = now; +} + +/* Merge into the guest's SIO motor values (in/out). */ +static inline void psx_host_rumble_merge(const PsxHostRumbleSlot *slot, + uint32_t now, uint8_t *small, + uint8_t *large) { + if (!slot || !slot->valid || !small || !large) return; + if ((uint32_t)(now - slot->stamp) >= PSX_HOST_RUMBLE_TTL) return; + if (slot->small && !*small) *small = 0xFFu; + if (slot->large > *large) *large = slot->large; +} + +#endif /* PSX_HOST_RUMBLE_H */ diff --git a/runtime/include/psx_netplay_rb.h b/runtime/include/psx_netplay_rb.h index e3780b12..276704de 100644 --- a/runtime/include/psx_netplay_rb.h +++ b/runtime/include/psx_netplay_rb.h @@ -47,11 +47,15 @@ void psx_netplay_rb_cold_reset(void); * without longjmp. Call psx_netplay_rb_flush_resume() afterward from a C BB-edge * (or after all C++ RAII in the vblank path has been destroyed). */ void psx_netplay_rb_poll(struct CPUState *cpu, uint32_t resume_pc); +/* psx_netplay.c: the one resume-PC rule for Live and Replay tick snaps. */ +uint32_t psx_netplay_snap_resume_hint(const struct CPUState *cpu); /* If a baseline snap was applied without resume, longjmp to the scheduler * (same as savestate_poll). No-op when nothing is pending. Never returns on * success. */ void psx_netplay_rb_flush_resume(void); +/* psx_netplay.c: admit the Live tick a realign resumes into (1 = admitted). */ +int psx_netplay_live_realign_admit(void); /* 1 after flush_resume longjmp until finish_frame / abort — top-level * dispatch has no native call chain under the resume PC. */ diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index aa30edcf..3e009852 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -476,6 +476,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/game_options.c ${PSXRECOMP_ROOT}/runtime/src/mod_builtin_speed.c ${PSXRECOMP_ROOT}/runtime/src/mod_builtin_pgxp.c + ${PSXRECOMP_ROOT}/runtime/src/mod_internal_resolution.c ${PSXRECOMP_ROOT}/runtime/src/mod_builtin_bezel.c ${PSXRECOMP_ROOT}/runtime/src/mod_builtin_ram.c ${PSXRECOMP_ROOT}/runtime/src/mod_builtin_hd_textures.c diff --git a/runtime/src/boot_state.c b/runtime/src/boot_state.c index 33a850a1..6195d9ba 100644 --- a/runtime/src/boot_state.c +++ b/runtime/src/boot_state.c @@ -110,6 +110,10 @@ static uint32_t boot_state_layout_cookie(void) { #define CPU_REGS_WIRE_BYTES (524u) /* Timer wire: 3*u16 + 3*u32 + 3*u16 + 3*i32 + 3*u32 = 48 bytes (no pad holes). */ #define TIMER_REGS_WIRE_BYTES (48u) +/* CPU stall wire: 2*u32 + 33 + 3*u8 + u32 = 48 bytes. */ +#define BS_CPUTIM_BYTES (48u) +/* Set by a BS_SEC_CPUTIM load; psx_cycles_resync_after_restore consumes it. */ +int g_boot_state_cpu_timing_restored; /* ---- deferred capture state (armed before first boot, fired at handoff) ---- */ static char s_capture_path[512]; @@ -322,8 +326,21 @@ static int write_cpu_section(BsOut* o, const CPUState* cpu) { return 0; for (int i = 0; i < 32; i++) if (!pst_w_u32(&w, cpu->cop0[i])) return 0; - for (int i = 0; i < 32; i++) - if (!pst_w_u32(&w, cpu->gte_data[i])) return 0; + for (int i = 0; i < 32; i++) { + uint32_t v = cpu->gte_data[i]; + /* LZCR is derived from LZCS (what MFC2 31 returns, and what a load + * recomputes). Serialize the derived value: the raw backing stays 0 + * until the first LZCS write, so a live save and a replayed save of + * the same tick differed here although no guest could tell. */ + if (i == 31) { + uint32_t bits = (cpu->gte_data[30] & 0x80000000u) ? ~cpu->gte_data[30] + : cpu->gte_data[30]; + v = 0; + if (bits == 0) v = 32; + else while (!(bits & 0x80000000u)) { bits <<= 1; ++v; } + } + if (!pst_w_u32(&w, v)) return 0; + } for (int i = 0; i < 32; i++) if (!pst_w_u32(&w, cpu->gte_ctrl[i])) return 0; if (w.written != CPU_REGS_WIRE_BYTES) return 0; @@ -399,9 +416,27 @@ static int boot_state_save_to(BsOut* o, const CPUState* cpu, h.codegen_hash = (uint32_t)PSX_OVERLAY_CODEGEN_HASH; h.abi_tag = (int32_t)PSX_OVERLAY_ABI_TAG; h.codegen_ver = BOOT_STATE_IMPLEMENTATION_KEY; - h.section_count = 17 + (psx_mod_memory_snapshot_bytes() ? 1u : 0u) + + h.section_count = 18 + (cpu ? 1u : 0u) + (psx_mod_memory_snapshot_bytes() ? 1u : 0u) + (psx_guest_cycle_scale_config() != 1u ? 1u : 0u); + /* Publish deferred cycle charges before ANY section: IRQ (cycles since + * VBlank) and the root counters were written before the old flush point + * (just ahead of CLOCK), so they lagged the saved clock by whatever batch + * was pending -- a host-path quantity (live vs rollback replay). */ + psx_cyc_batch_flush(); + /* Devices (root counters, VBlank phase, CD/SPU/DMA) are serviced lazily + * up to the next event deadline, so between deadlines they lag the clock + * by a host-path amount. A load re-anchors them at the restored clock, + * dropping that lag: live and replay saves of one tick then differed in + * cycles_since_vblank and the counters at the same clock. Bring every + * device to the clock first so the snap is the canonical machine at it + * (service_to_now replays the exact event-bounded timeline). */ + psx_devices_service_to_now(); + /* The SPU renders in whole samples when the host audio pump or a guest + * SPU access asks; between those it lags the clock by a path-dependent + * amount (live pumps at VBlank, rollback replay suppresses output). */ + { extern void spu_catch_up(void); spu_catch_up(); } + ok = write_header_le(o, &h); if (ok) ok = write_cpu_section(o, cpu); @@ -498,6 +533,31 @@ static int boot_state_save_to(BsOut* o, const CPUState* cpu, ok = pst_w_u32(&w, fntrace_is_game_started() ? 1u : 0u) && write_section(o, BS_SEC_GAME_START, handoff, sizeof handoff); } + if (ok) { + extern uint32_t spu_cd_snapshot_bytes(void); + extern void spu_cd_snapshot_write(uint8_t *p); + ok = write_module_section(o, BS_SEC_SPUCD, spu_cd_snapshot_bytes, spu_cd_snapshot_write); + } + if (ok && cpu) { + /* In-flight CPU stalls, relative to the clock. They used to be + * re-anchored at the restored clock on every load, so a GTE / mul-div + * op or load give-back in flight at the snap point cost the live run + * its remaining stall but the replay nothing: replaying a snap with + * the same rows ran a few cycles short of the live tick + * (PSX_RB_DET_SELFTEST: IRQ/TIMER drift at equal clock). */ + uint8_t tb[BS_CPUTIM_BYTES]; + PstW w; + uint64_t now = psx_cycle_count; + uint32_t gte_rem = cpu->gte_ts_done > now ? (uint32_t)(cpu->gte_ts_done - now) : 0u; + uint32_t md_rem = cpu->muldiv_ts_done > now ? (uint32_t)(cpu->muldiv_ts_done - now) : 0u; + pst_w_init(&w, tb, sizeof tb); + ok = pst_w_u32(&w, gte_rem) && pst_w_u32(&w, md_rem) && + pst_w_bytes(&w, cpu->read_absorb, sizeof cpu->read_absorb) && + pst_w_u8(&w, cpu->read_absorb_which) && pst_w_u8(&w, cpu->read_fudge) && + pst_w_u8(&w, cpu->ld_which_t) && pst_w_u32(&w, cpu->ld_absorb) && + w.written == BS_CPUTIM_BYTES && + write_section(o, BS_SEC_CPUTIM, tb, sizeof tb); + } if (ok && psx_guest_cycle_scale_config() != 1u) { /* Rollback re-simulation must resume with the same carried fraction * and gate verdicts, or scaled charges land a cycle apart. */ @@ -725,6 +785,26 @@ static int apply_section(uint32_t tag, const uint8_t* p, uint32_t len, case BS_SEC_GAME_START: fntrace_restore_game_started(p[0]); return 1; + case BS_SEC_SPUCD: { + extern int spu_cd_snapshot_read(const uint8_t *p, uint32_t len); + return spu_cd_snapshot_read(p, len); + } + case BS_SEC_CPUTIM: { + PstR r; + uint32_t gte_rem, md_rem; + if (len != BS_CPUTIM_BYTES || !cpu) return 0; + pst_r_init(&r, p, len); + if (!pst_r_u32(&r, >e_rem) || !pst_r_u32(&r, &md_rem) || + !pst_r_bytes(&r, cpu->read_absorb, sizeof cpu->read_absorb) || + !pst_r_u8(&r, &cpu->read_absorb_which) || !pst_r_u8(&r, &cpu->read_fudge) || + !pst_r_u8(&r, &cpu->ld_which_t) || !pst_r_u32(&r, &cpu->ld_absorb)) + return 0; + /* CLOCK precedes this section, so psx_cycle_count is restored. */ + cpu->gte_ts_done = psx_cycle_count + gte_rem; + cpu->muldiv_ts_done = psx_cycle_count + md_rem; + g_boot_state_cpu_timing_restored = 1; + return 1; + } case BS_SEC_GCS: { PstR r; uint32_t g[3]; @@ -795,6 +875,8 @@ static int validate_section(uint32_t tag, const uint8_t* p, uint32_t len) { return len == 4u && p[0] <= 1u && !p[1] && !p[2] && !p[3]; case BS_SEC_ICACHE: return len == 1024u * 4u; case BS_SEC_GCS: return len == PSX_GCS_SNAPSHOT_BYTES; + case BS_SEC_CPUTIM: return len == BS_CPUTIM_BYTES; + case BS_SEC_SPUCD: return len >= 4u && (len - 4u) % 4u == 0u; default: return 1; } } diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 461d8713..efd43efa 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -8638,6 +8638,15 @@ static void handle_accurate_blending(int id, const char *json) send_fmt("{\"id\":%d,\"ok\":true,\"on\":%d}", id, gl_renderer_accurate_blending()); } +/* texture_filter: {"cmd":"texture_filter"} reads, {"cmd":"texture_filter", + * "mode":0..3} sets it live (nearest, bilinear, stable world, xbr). */ +static void handle_texture_filter(int id, const char *json) +{ + int mode = json_get_int(json, "mode", -1); + if (mode >= 0) gr_set_texture_filter(mode); + send_fmt("{\"id\":%d,\"ok\":true,\"mode\":%d}", id, gr_texture_filter()); +} + static void handle_frame_perf(int id, const char *json) { (void)json; @@ -15864,6 +15873,8 @@ static const CmdEntry s_commands[] = { { "fmv_chroma", handle_fmv_chroma }, { "texture_lod", handle_texture_lod }, { "accurate_blending", handle_accurate_blending }, + + { "texture_filter", handle_texture_filter }, { "gl_ws_ablate", handle_gl_ws_ablate }, { "gl_interp", handle_gl_interp }, { "render_pass_stats", handle_render_pass_stats }, diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 817062b0..eaf6bca6 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -908,8 +908,8 @@ static int s_tex_lod = 0, s_tex_aniso = 1; * different bases and truncating to 5 bits at readback is off by one in * places (e.g. B+F/4, B/2+F/2 with odd sums). On, every VRAM write uses * k*8, so GL's 8-bit blend followed by >>3 is exactly the PS1's 5-bit - * arithmetic. Off (default) keeps the historical bases. */ -static int s_accurate_blend = 0; + * arithmetic. On by default; off is the historical diagnostic path. */ +static int s_accurate_blend = 1; static void c5_to_float(uint16_t c, float *r, float *g, float *b) { if (s_accurate_blend) { *r = (float)((c & 0x1F) << 3) / 255.0f; @@ -1938,7 +1938,7 @@ static const char *TEX_FS = "uniform int u_semipass; /* 0=all texels, 1=STP=0 only, 2=STP=1 only */\n" "uniform int u_semimode; /* PS1 blend mode; drives dual-source factors */\n" "uniform int u_maskset; /* GP0(E6h) set-mask: OR bit15 into output */\n" - "uniform int u_filter; /* bits 0-3: 0 nearest, 1 bilinear, 2 stable; bit 4: LOD */\n" + "uniform int u_filter; /* bits 0-3: 0 nearest, 1 bilinear, 2 stable, 3 xbr; bit 4: LOD */\n" "uniform int u_aniso; /* [video] anisotropic_filtering, 1..16 */\n" "uniform float u_shift;\n" "int vram_at(int x, int y){\n" @@ -2034,6 +2034,47 @@ static const char *TEX_FS = " }\n" " return sum.a > 0.0 ? sum.rgb / sum.a : base;\n" "}\n" + /* [video] texture_filtering = "xbr": edge-directed magnification after + * Hyllian's xBR (MIT; notice in THIRD_PARTY_ATTRIBUTION.md): the same + * E/F/H/I neighbourhood and 4x-weighted edge sums, adapted to CLUT + * texels and an anti-aliased corner cut. For the + * texel corner nearest the sample, two weighted colour-distance sums + * across a 5x5 neighbourhood decide whether an edge runs along the + * corner's anti-diagonal; if so, the corner is cut by that diagonal and + * takes the closer of the two side neighbours, with an anti-aliased + * boundary one output pixel wide. Smooth gradients and straight edges are + * left as nearest texels, so pixel art keeps its crisp blocks while + * diagonals and curves lose their staircase. Transparent (0) texels never + * supply a colour; cutout and STP stay the centre texel's. */ + "vec3 xbr_c(int raw){ return col5(raw); }\n" + "float xbr_d(int a, int b){\n" + " vec3 x = xbr_c(a), y = xbr_c(b);\n" + " vec3 dv = x - y;\n" + " float yy = dot(dv, vec3(0.299, 0.587, 0.114));\n" + " float u = dot(dv, vec3(-0.169, -0.331, 0.5)), v = dot(dv, vec3(0.5, -0.419, -0.081));\n" + " return 48.0*abs(yy) + 7.0*abs(u) + 6.0*abs(v) + (((a==0)!=(b==0)) ? 64.0 : 0.0);\n" + "}\n" + "int xbr_at(ivec2 e, ivec2 s, int x, int y){ return stable_texel(e + ivec2(x*s.x, y*s.y)); }\n" + "vec3 xbr_sample(vec2 uv, int e_raw, vec2 fw){\n" + " ivec2 e = ivec2(floor(uv)); vec2 f = fract(uv);\n" + " ivec2 s = ivec2(f.x < 0.5 ? -1 : 1, f.y < 0.5 ? -1 : 1);\n" + " vec2 l = vec2(s.x > 0 ? f.x : 1.0 - f.x, s.y > 0 ? f.y : 1.0 - f.y); /* 0.5..1 toward the corner */\n" + " int E=e_raw;\n" + " if (max(fw.x, fw.y) >= 0.5) return xbr_c(E); /* under 2x: stay nearest, no fetches */\n" + " int F=xbr_at(e,s,1,0), H=xbr_at(e,s,0,1);\n" + " if (E == F || E == H) return xbr_c(E);\n" + " int I=xbr_at(e,s,1,1);\n" + " int B=xbr_at(e,s,0,-1), C=xbr_at(e,s,1,-1), D=xbr_at(e,s,-1,0), G=xbr_at(e,s,-1,1);\n" + " int F4=xbr_at(e,s,2,0), I4=xbr_at(e,s,2,1), H5=xbr_at(e,s,0,2), I5=xbr_at(e,s,1,2);\n" + " float we = xbr_d(E,C)+xbr_d(E,G)+xbr_d(I,H5)+xbr_d(I,F4)+4.0*xbr_d(H,F);\n" + " float wi = xbr_d(H,D)+xbr_d(H,I5)+xbr_d(F,I4)+xbr_d(F,B)+4.0*xbr_d(E,I);\n" + " if (!(we < wi)) return xbr_c(E);\n" + " int px = xbr_d(E,F) <= xbr_d(E,H) ? F : H;\n" + " if (px == 0) return xbr_c(E);\n" + " float w = max(fw.x + fw.y, 1e-3);\n" + " float a = clamp((l.x + l.y - 1.5) / w + 0.5, 0.0, 1.0);\n" + " return mix(xbr_c(E), xbr_c(px), a);\n" + "}\n" "void main(){\n" " int stp; vec3 rgb;\n" " int fmode = u_filter & 15;\n" @@ -2063,6 +2104,12 @@ static const char *TEX_FS = " if (raw == 0) discard;\n" " rgb = col5(raw);\n" " stp = (raw >> 15) & 1;\n" + " } else if (fmode == 3) {\n" + " /* The nearest texel stays the PS1 one (floor of the unshifted uv). */\n" + " int raw = stable_texel(ivec2(floor(uv)));\n" + " if (raw == 0) discard;\n" + " stp = (raw >> 15) & 1;\n" + " rgb = xbr_sample(uv, raw, abs(dx) + abs(dy));\n" " } else if (fmode == 2) {\n" " uv+=vec2(u_shift); int raw=stable_texel(ivec2(floor(uv)));\n" " if(raw==0) discard; stp=(raw>>15)&1;\n" @@ -4985,7 +5032,7 @@ void gl_renderer_set_accurate_blending(int on) { if (s_ctx) accurate_apply_uniforms(); } int gl_renderer_accurate_blending(void) { return s_accurate_blend; } -static void glb_set_texture_filter(int b) { s_tex_filter = b >= 0 && b <= 2 ? b : 0; sw_set_texture_filter(b != 0); } +static void glb_set_texture_filter(int b) { s_tex_filter = b >= 0 && b <= 3 ? b : 0; sw_set_texture_filter(b != 0); } static int glb_texture_filter(void) { return s_tex_filter; } static void glb_set_semi_transparency(int e, int m) { s_semi_en = e; s_semi_mode = m & 3; sw_set_semi_transparency(e, m); } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 68bddf3a..990802df 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -11,6 +11,7 @@ #include "mod_controller_source.h" #include "window_size.h" /* default game-window size */ #include "internal_resolution.h" /* Settings -> Display -> Internal resolution */ +#include "mod_internal_resolution.h" #include "dynamic_resolution.h" /* [video] dynamic_resolution: the step controller */ #include "render_thread.h" /* rt_get_stats: queue backpressure (dynres) */ #include "psx_scheduler.h" /* psx_scheduler_run — deterministic TCB scheduler */ @@ -64,6 +65,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "frame_pacing.h" #include "latency_ring.h" #include "sio.h" +#include "psx_host_rumble.h" #ifndef PSX_MAX_PLAYERS #define PSX_MAX_PLAYERS 2 #endif @@ -81,6 +83,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "host_time.h" #if defined(PSX_HAS_RECOMP_NET) #include "recomp_net/auth.h" +#include "recomp_netplay_account.h" /* shared recomp-ui account sync */ #include "recomp_net/host_relay.h" /* RNetHostRelayStatus for the launcher relay line */ #include "recomp_net/chat_filter.h" /* chat profanity mask, LAN rooms too */ #endif @@ -1283,7 +1286,7 @@ static int g_video_pgxp_depth_buffer = 0; static int g_video_fmv_chroma = 0; /* [video] fmv_chroma_smoothing / PSX_FMV_CHROMA */ /* [video] texture_lod / anisotropic_filtering; PSX_TEXTURE_LOD / PSX_ANISO. */ static int g_video_texture_lod = 0, g_video_aniso = 1; -static int g_video_accurate_blending = 0; /* [video] accurate_blending / PSX_ACCURATE_BLENDING */ +static int g_video_accurate_blending = 1; /* [video] accurate_blending / PSX_ACCURATE_BLENDING */ static int g_video_pgxp_color_correction = 0; static int g_video_pgxp_seam = 0; static float g_video_pgxp_depth_threshold = 4096.0f; @@ -1366,11 +1369,11 @@ static int video_scale_ceiling(void) { return g_video_renderer == 1 ? GL_MAX_INTERNAL_SCALE : SW_MAX_INTERNAL_SCALE; } -/* The preset in effect this run: the environment override, else the - * configured one. */ +/* Session priority: environment override, active mod request, player setting. */ static int effective_internal_resolution(void) { + const uint32_t mod_lines = psx_mod_internal_resolution_request(); return g_video_internal_res_env != PSX_IR_UNSET ? g_video_internal_res_env - : g_video_internal_res; + : mod_lines ? (int)mod_lines : g_video_internal_res; } /* Resolve the Internal resolution preset into g_video_scale. display_px_h is @@ -1428,6 +1431,27 @@ extern "C" int psx_mod_allow_direct_shortcut(uint32_t shortcut) { extern "C" void psx_mod_set_rewind_blocked(int blocked) { psx_rewind_set_title_blocked(blocked); } + +/* psx_mod_set_host_rumble: per-seat title rumble, stamped with the rumble + * VBlank so a value the mod stops refreshing lapses (psx_host_rumble.h). */ +static PsxHostRumbleSlot g_mod_rumble[PSX_MAX_PLAYERS]; +static uint32_t g_rumble_vblank = 0; + +extern "C" int psx_mod_set_host_rumble(uint32_t player, uint32_t small, + uint32_t large) { + if (player >= PSX_MAX_PLAYERS) return 0; + if (psx_netplay_active()) { + /* Online: only this peer's own guest port drives its own pad, once per + * real tick (a rollback resim replays ticks the pad already felt). */ + if (psx_netplay_is_resimulating() || + (int)player != psx_netplay_local_port()) + return 1; + const int card = psx_netplay_input_player(); + player = card >= 0 && card < PSX_MAX_PLAYERS ? (uint32_t)card : 0u; + } + psx_host_rumble_set(&g_mod_rumble[player], g_rumble_vblank, small, large); + return 1; +} static uint32_t g_savestate_input_guard_min_until = 0; static uint32_t g_savestate_input_guard_max_until = 0; static int g_headless = 0; /* debug/CI frontend: no SDL window/audio */ @@ -1707,6 +1731,7 @@ extern "C" int psx_mod_set_adaptive_display_aspect( static PSXModSessionBaseline g_mod_owned_baseline; static void reset_mod_owned_presentation(void) { + psx_mod_internal_resolution_reset(); psx_local_mouse_clear(); PSXModSessionScalars live; live.video_vsync = g_video_vsync; @@ -5212,11 +5237,18 @@ static void update_controller_rumble(void) { const char* e = std::getenv("PSX_RUMBLE_TRACE"); return e && e[0] && e[0] != '0'; }(); + ++g_rumble_vblank; for (int s = 0; s < PSX_MAX_PLAYERS; s++) { PlayerInput& p = g_players[s]; uint8_t small = 0, large = 0; sio_get_pad_rumble(s, &small, &large); + psx_host_rumble_merge(&g_mod_rumble[s], g_rumble_vblank, &small, &large); if (!p.handle) { + if (trace && (!p.rumble_known || p.rumble_small != small || + p.rumble_large != large)) + std::fprintf(stdout, + "psxrecomp rumble: slot=%d small=%u large=%u host=none\n", + s + 1, (unsigned)small, (unsigned)large); p.rumble_small = small; p.rumble_large = large; p.rumble_known = true; @@ -8434,7 +8466,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { psx_selfcheck_finish_frame( (g_offline_pad_count >= 3 && !sio_get_multitap()) ? 1 : 0); } - if (!g_headless) update_controller_rumble(); + /* Headless has no pads; PSX_RUMBLE_TRACE still logs what would be sent. */ + if (!g_headless || std::getenv("PSX_RUMBLE_TRACE")) update_controller_rumble(); /* Latency ring: open this present cycle's slot, stamping when input was * sampled into SIO. Always-on; queried via the debug server "latency". */ @@ -9490,7 +9523,7 @@ static int debug_toggles_on(void) { } static int s_dbg_scale_target = 0; static const char *dbg_texfilter_name(int f) { - return f == 1 ? "bilinear" : f == 2 ? "stable world" : "nearest"; + return f == 1 ? "bilinear" : f == 2 ? "stable world" : f == 3 ? "xbr" : "nearest"; } static void debug_toggle_summary(char *buf, size_t cap) { GlDynresStats st; gl_renderer_dynres_stats(&st); @@ -9546,7 +9579,7 @@ static int debug_toggle_key(int key, char *out, int cap) { on && !g_render_thread ? " (inert: render thread off)" : ""); break; } case SDLK_5: { - const int f = (gr_texture_filter() + 1) % 3; + const int f = (gr_texture_filter() + 1) % 4; gr_set_texture_filter(f); std::snprintf(msg, sizeof msg, "Texture filter: %s", dbg_texfilter_name(f)); break; } @@ -12802,28 +12835,22 @@ namespace { int ae_np_account_sign_out(void*) { return rnet_account_sign_out(); } int ae_np_account_set_handle(void*, const char* h) { return rnet_account_set_handle(h); } - /* Point the account client at the lobby host -- once per URL, not once - * per pump: the login worker thread reads the host while a sign-in is in - * flight, and re-initialising it 60 times a second under that read is a - * data race for no gain. The secret is anchored to the EXECUTABLE - * directory before the first init: its default is the bare relative name - * "netplay_secret", resolved against the working directory, so the same - * install signed itself out depending on where it was launched from. - * rnet_auth migrates an old CWD-relative file into this path on first - * load, so nobody is signed out by the move. Same shape as the SNES - * host (snes_host_lobby.c cb_pump). */ + /* Point the account client at the lobby host. The rule (resolved URL, + * once per URL, secret anchored to the exe dir) lives in recomp-ui, shared + * with recomp_netplay_host.c. The URL is ae_np_default_url, NOT the raw + * g_lnch_lobby_url: that is empty until a player saves one, which left the + * auth host blank and every sign-in "could not reach the lobby server". */ + static int ae_np_exe_dir_path(void*, const char* leaf, char* out, size_t cap) { + const std::string p = + (exe_dir_from_argv(g_lnch_argv0 ? g_lnch_argv0 : "") / leaf).string(); + if (p.size() + 1 > cap) return 0; + std::memcpy(out, p.c_str(), p.size() + 1); + return 1; + } + const char* ae_np_default_url(void*); /* defined below */ void ae_np_account_sync(void) { - static std::string s_auth_url; - const std::string& url = g_lnch_lobby_url; - if (url.empty() || url == s_auth_url) return; - if (s_auth_url.empty()) { - const std::string secret = - (exe_dir_from_argv(g_lnch_argv0 ? g_lnch_argv0 : "") / - "netplay_secret").string(); - rnet_account_set_secret_path(secret.c_str()); - } - s_auth_url = url; - rnet_account_init(url.c_str()); + recomp_netplay_account_sync(ae_np_default_url(nullptr), + ae_np_exe_dir_path, nullptr); } #endif /* PSX_HAS_RECOMP_NET: account client is not linked in offline builds */ @@ -17366,6 +17393,14 @@ int main(int argc, char** argv) { ls.scanlines = seed.scanlines ? 1 : 0; ls.scanline_strength_pct = seed.has_scanline_strength ? (int)(seed.scanline_strength * 100.0 + 0.5) : 50; +#endif +#if defined(RECOMP_LAUNCHER_VSYNC_ADAPTIVE) + /* [video] vsync (1 on, 0 off/immediate, -1 adaptive, 2 VRR) <-> + * the launcher's V-Sync row (1 On, 2 Off, 3 Adaptive). VRR has no + * row; it shows as On and is kept unless the player changes it. */ + ls.vsync = g_video_vsync == 0 ? RECOMP_LAUNCHER_VSYNC_OFF + : g_video_vsync == -1 ? RECOMP_LAUNCHER_VSYNC_ADAPTIVE + : RECOMP_LAUNCHER_VSYNC_ON; #endif ls.frame_interp = seed.frame_interpolation ? 1 : 0; ls.frame_interp_fps = seed.frame_interpolation_fps; @@ -17721,6 +17756,16 @@ int main(int argc, char** argv) { seed.scanline_strength = ls.scanline_strength_pct / 100.0; seed.has_scanline_strength = true; } +#endif +#if defined(RECOMP_LAUNCHER_VSYNC_ADAPTIVE) + if (ls.vsync >= RECOMP_LAUNCHER_VSYNC_ON && ls.vsync <= RECOMP_LAUNCHER_VSYNC_ADAPTIVE) { + const int was = g_video_vsync; + g_video_vsync = ls.vsync == RECOMP_LAUNCHER_VSYNC_OFF ? 0 + : ls.vsync == RECOMP_LAUNCHER_VSYNC_ADAPTIVE ? -1 + : (was == 2 ? 2 : 1); + seed.vsync = g_video_vsync; + seed.has_vsync = true; + } #endif seed.frame_interpolation = ls.frame_interp != 0; seed.has_frame_interpolation = true; seed.frame_interpolation_fps = ls.frame_interp_fps; seed.has_frame_interpolation_fps = true; @@ -18133,6 +18178,7 @@ int main(int argc, char** argv) { mod_controller_source_reset(); mod_pad_transform_reset(); g_direct_shortcut_allowed = 0; + std::memset(g_mod_rumble, 0, sizeof g_mod_rumble); g_p1_claimed_buttons = 0; psx_rewind_set_title_blocked(0); for (auto& policy : g_mod_controller_policy) @@ -18419,6 +18465,7 @@ session_reboot: (net_cfg.enabled && s_netplay_gl_present && gl_renderer_cpu_auth_dual()); g_video_scale = gr_scale(); /* reflect any clamp / alloc fallback */ + if (const char* e = std::getenv("PSX_TEXTURE_FILTER")) g_video_texfilter = std::atoi(e); gr_set_texture_filter(g_mod_texfilter < 0 ? g_video_texfilter : g_mod_texfilter); /* Sub-pixel vertex precision + perspective-correct UVs. Both default off; * with both off every setter below leaves the tracking caches disabled and diff --git a/runtime/src/mod_internal_resolution.c b/runtime/src/mod_internal_resolution.c new file mode 100644 index 00000000..b272c902 --- /dev/null +++ b/runtime/src/mod_internal_resolution.c @@ -0,0 +1,19 @@ +#include "mod_plugins.h" +#include "mod_internal_resolution.h" + +static uint32_t requested_lines; + +int psx_mod_set_internal_resolution(uint32_t target_lines) { + if (target_lines && (target_lines < 120u || target_lines > 8192u)) + return 0; + requested_lines = target_lines; + return 1; +} + +uint32_t psx_mod_internal_resolution_request(void) { + return requested_lines; +} + +void psx_mod_internal_resolution_reset(void) { + requested_lines = 0; +} diff --git a/runtime/src/netplay_snap_ring.c b/runtime/src/netplay_snap_ring.c index d9936de8..90454222 100644 --- a/runtime/src/netplay_snap_ring.c +++ b/runtime/src/netplay_snap_ring.c @@ -12,12 +12,74 @@ #include "boot_state.h" #include +#include +#include +#include #if defined(_WIN32) # include #else # include #endif +/* PSX_RB_SNAP_AUDIT=: one line per ring save -- mode (L live, R replay), + * tick, and per boot_state section the CRC32 (+ hex for sections <= 4 KiB). + * tools/rb_snap_audit_diff.py compares seats tick by tick and a seat's replay + * against its own live save, naming the first section/byte that differs. It + * is the replay-determinism check: same snap + same rows must give the same + * machine, field by field. Off (zero cost) unless the variable is set. */ +static void netplay_snap_audit_note(uint32_t tick, const uint8_t *d, size_t n) +{ + extern int psx_netplay_local_slot(void); + extern int psx_netplay_is_resimulating(void); + static FILE *f; + static int init; + size_t start; + if (!init) { + const char *dir = getenv("PSX_RB_SNAP_AUDIT"); + char nm[600]; + init = 1; + if (dir && *dir) { + snprintf(nm, sizeof(nm), "%s/snap-audit-p%d.txt", dir, psx_netplay_local_slot()); + f = fopen(nm, "w"); + } + } + if (!f || !d) + return; + for (start = 0; start < 256; start += 4) { + size_t o = start; + int cnt = 0, ok = 1; + while (o + 16 <= n) { + uint32_t tag; + uint64_t ln; + memcpy(&tag, d + o, 4); + memcpy(&ln, d + o + 8, 8); + if (ln > n || tag == 0 || tag > 0x7fffffffu) { ok = 0; break; } + o += 16 + (size_t)ln; + cnt++; + } + if (!ok || o != n || cnt < 6) + continue; + fprintf(f, "%c %u", psx_netplay_is_resimulating() ? 'R' : 'L', (unsigned)tick); + o = start; + while (o + 16 <= n) { + uint32_t tag; + uint64_t ln, i; + memcpy(&tag, d + o, 4); + memcpy(&ln, d + o + 8, 8); + fprintf(f, " %x:%08lx", (unsigned)tag, + (unsigned long)crc32(0L, d + o + 16, (uInt)ln)); + if (ln <= 4096u && tag != 0x10u) { + fputc('=', f); + for (i = 0; i < ln; i++) + fprintf(f, "%02x", d[o + 16 + i]); + } + o += 16 + (size_t)ln; + } + fputc('\n', f); + return; + } +} + static double snap_mono_ms(void) { #if defined(_WIN32) @@ -69,6 +131,7 @@ int netplay_snap_ring_save(NetplaySnapRing *r, uint32_t tick, 100.0 * (double)s_tele_incr_n / (double)s_tele_n); fflush(stderr); } + netplay_snap_audit_note(tick, data, len); if (!netplay_snap_ring_store(r, tick, data, len)) return 0; return 1; diff --git a/runtime/src/netplay_state_digest.c b/runtime/src/netplay_state_digest.c index 7a60e1d5..5c4ace73 100644 --- a/runtime/src/netplay_state_digest.c +++ b/runtime/src/netplay_state_digest.c @@ -93,6 +93,12 @@ uint32_t netplay_av_digest(void) gcap = gn; } gpu_snapshot_write(gbuf); + /* The last 12 bytes are depth-24 present helpers (upload span, + * present hold, previous display height): host presentation state + * that a suppressed-present replay leaves different from Live. Not + * guest-visible; keep them out of the av comparison. */ + if (gn >= 12u) + memset(gbuf + gn - 12u, 0, 12u); crc = crc32_update(crc, gbuf, gn); } if (vram) diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index 5fd078ff..76467c2e 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -766,14 +766,20 @@ void psx_cycles_resync_after_restore(CPUState *cpu) { * would then advance (live_ts - restored_cycle) in one shot — tens of * millions of cycles / N nested presents with zero IRQ checks (MotK * transform CTC2 path). Anchor them at the restored clock. */ - if (cpu) { - cpu->gte_ts_done = psx_cycle_count; - cpu->muldiv_ts_done = psx_cycle_count; - memset(cpu->read_absorb, 0, sizeof(cpu->read_absorb)); - cpu->read_absorb_which = 0; - cpu->read_fudge = 0x20u; /* no committed predecessor load */ - cpu->ld_which_t = 0x20u; /* no pending load dest */ - cpu->ld_absorb = 0; + { + /* A snap with BS_SEC_CPUTIM carried them relative to its clock -- + * keep those; older snaps anchor at the restored clock as before. */ + extern int g_boot_state_cpu_timing_restored; + if (cpu && !g_boot_state_cpu_timing_restored) { + cpu->gte_ts_done = psx_cycle_count; + cpu->muldiv_ts_done = psx_cycle_count; + memset(cpu->read_absorb, 0, sizeof(cpu->read_absorb)); + cpu->read_absorb_which = 0; + cpu->read_fudge = 0x20u; /* no committed predecessor load */ + cpu->ld_which_t = 0x20u; /* no pending load dest */ + cpu->ld_absorb = 0; + } + g_boot_state_cpu_timing_restored = 0; } /* Dirty-RAM interpreter load-delay writebacks live in host statics, not * BS_SEC_CPU. Discard (do not flush): snap GPRs are already architectural. diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index d4a912ff..d388115c 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -779,6 +779,37 @@ static void np_log_live_digest(uint32_t tick, const NetplayCoreParts *parts, * episode reloads from the newest provably-safe snap (≤ fork tick — HC is * confirmed through fork-1 by definition) and reseals both seats from * wire-authoritative history, converging the states. */ +/* PSX_RB_DET_SELFTEST=: the replay-determinism test. Every K ticks the + * slot-0 host opens a rollback episode 16 ticks back although nothing was + * mispredicted, so every seat reloads a snap and replays confirmed rows over + * ticks it already ran live. Same snap + same rows must give the same + * machine: with PSX_RB_SNAP_AUDIT set, tools/rb_snap_audit_diff.py compares + * each replayed save with the live save of that tick section by section + * (CPU, RAM, timers, DMA, SIO/multitap, SPU, ...) and names the first field + * that differs. Off unless set. */ +static void np_det_selftest_tick(uint32_t done) +{ + static int k = -1; + static uint32_t last; + if (k < 0) { + const char *e = getenv("PSX_RB_DET_SELFTEST"); + k = (e && *e) ? atoi(e) : 0; + if (k < 0) k = 0; + } + if (k <= 0 || g_np.local_slot != 0 || !g_np.rollback || done < 64u) + return; + if (done - last < (uint32_t)k) + return; + if (psx_netplay_rb_active() || psx_netplay_rb_tip_holding() || + psx_netplay_rb_load_pending()) + return; + last = done; + fprintf(stderr, "psxrecomp: rb det-selftest rewind mismatch=%u\n", + (unsigned)(done - 16u)); + fflush(stderr); + (void)psx_netplay_rb_begin_rewind(done - 16u, g_np.slot_count > 1 ? 1 : 0); +} + static void np_try_hc_fork_recovery(uint32_t fork_tick) { uint32_t sim; @@ -5016,6 +5047,21 @@ static int np_try_admit_load_barrier_unconfirmed(void) return 1; } +/* A Live realign restores the snap at T and resumes the guest at the frame + * boundary after it, with the session already at T+1. Admit T+1 before the + * resume: resuming first ran frame T+1 unadmitted (no inputs latched, no + * advance), so the next frame was counted as T+1 and this peer ran one frame + * ahead of its tick label (4-seat race: L-saves after a NACK realign carried + * the next tick's clock; the realigned seat forked from every other). */ +int psx_netplay_live_realign_admit(void) +{ + if (!psx_netplay_active() || !g_np.rollback || g_np.xfer != NP_XFER_NONE) + return 1; + if (g_np.needs_advance) + return 1; + return np_try_admit_rollback(); +} + int psx_netplay_poll_admit(void) { rnet_u32 sim; @@ -5222,6 +5268,22 @@ int psx_netplay_poll_admit(void) return 0; } +/* The resume PC a tick snapshot is saved with. Live finish_frame and the + * Replay finish_frame must use the same rule: Replay passed no hint, so its + * snap often fell back to a different PC or was deferred to a later BB edge, + * and the replayed save of tick T sat at another guest point than the live + * save of T (pc, cycles_since_vblank, root counters all differed -- + * PSX_RB_DET_SELFTEST: 106 of 262 replayed saves). */ +uint32_t psx_netplay_snap_resume_hint(const CPUState *cpu) +{ + uint32_t hint = psx_compiled_irq_resume_pc(); + if (!psx_is_dispatchable(hint)) + hint = psx_last_irq_check_pc(); + if (!psx_is_dispatchable(hint) && cpu) + hint = cpu->pc; + return hint; +} + void psx_netplay_finish_frame(void) { rnet_u32 done; @@ -5261,15 +5323,12 @@ void psx_netplay_finish_frame(void) np_rollback_reconcile_wire(); psx_netplay_rb_pump(); psx_netplay_rb_request_snap(done); + np_det_selftest_tick(done); /* Flush at vblank: MotK IRQ fast/mid paths used to skip poll_snap, so * deferred BB-edge saves never ran and the ring stayed empty. Prefer * IRQ BB-edge PCs — cpu->pc is often 0 during present/finish_frame. */ if (g_np.cpu) { - resume_hint = psx_compiled_irq_resume_pc(); - if (!psx_is_dispatchable(resume_hint)) - resume_hint = psx_last_irq_check_pc(); - if (!psx_is_dispatchable(resume_hint)) - resume_hint = g_np.cpu->pc; + resume_hint = psx_netplay_snap_resume_hint(g_np.cpu); psx_netplay_rb_poll(g_np.cpu, resume_hint); } } diff --git a/runtime/src/psx_netplay_rb.c b/runtime/src/psx_netplay_rb.c index a4db587c..a4e4f711 100644 --- a/runtime/src/psx_netplay_rb.c +++ b/runtime/src/psx_netplay_rb.c @@ -116,6 +116,8 @@ uint32_t psx_netplay_rb_rtt_estimate_ms(void) { return 0; } #else #include "netplay_hash_confirm.h" +#include "netplay_peer_frontier.h" +#include "netplay_fmv_confirm.h" #include "netplay_input_hist.h" #include "netplay_rb_post.h" #include "netplay_snap_ring.h" @@ -435,6 +437,9 @@ static uint32_t g_agreed_span_lo; * otherwise ADVANCE→begin races the follower's agreed and NACK-storms * (2026-08-02 post-§39 soak). */ static uint32_t g_peer_resolved_through; +/* Per-seat RESOLVED adverts; g_peer_resolved_through is the lowest of them + * across every peer seat (netplay_peer_frontier.h). */ +static NetplayPeerFrontier g_peer_frontier; /* §41: wall-clock when choose_load/begin first blocked on peer RESOLVED * (0 = not waiting). After RB_MOTK_PEER_RESOLVED_GATE_MS, force-open — * unbounded defer silently desynced Live (2026-08-02 post-§40 soak). */ @@ -715,6 +720,8 @@ static uint32_t g_fmv_settle_until; static uint32_t g_fmv_lockstep_until; static uint32_t g_fmv_media_end_sim; static uint32_t g_fmv_core_match_streak; +/* Highest hash-confirmed tick already counted into the streak. */ +static uint32_t g_fmv_confirm_seen; static int g_fmv_lockstep_released; /* sticky: never re-lock after invent on */ /* Inclusive last tick both peers dense-snapped (media/lockstep/+tip). */ static uint32_t g_fmv_dense_through; @@ -1037,21 +1044,31 @@ static int rb_fmv_media_active(void) /* After media: grow/shrink lockstep_until from hash_confirm agreement. */ static void rb_fmv_update_lockstep_gate(uint32_t sim) { - int matched = 0; uint32_t cap; uint32_t prev_until; if (!g_fmv_media_end_sim || rb_fmv_media_active()) return; - if (g_b.hc && !netplay_hc_peek_mismatch(g_b.hc, NULL, NULL, NULL)) { - uint32_t need = (sim > 0u) ? (sim - 1u) : 0u; - if (netplay_hc_confirm_through(g_b.hc, need)) - matched = 1; + /* Agreement is counted in CONFIRMED ticks, not in "sim-1 is already + * confirmed" ticks. The peer's digest for a tick arrives one RTT after + * the tick; the peer that runs ahead (usually the host, cross-machine) + * never has sim-1 confirmed when it asks, so the old per-tick test reset + * the streak every tick and fired a false "MAX unmatched" DESYNC after + * every movie even though every digest matched. Only a real mismatch + * resets the streak now; a confirm that is merely late leaves it. */ + { + uint32_t gained = 0u; + if (g_b.hc && netplay_hc_peek_mismatch(g_b.hc, NULL, NULL, NULL)) { + g_fmv_core_match_streak = 0; + } else if (g_b.hc && netplay_hc_confirm_through(g_b.hc, g_fmv_media_end_sim)) { + const uint32_t rt = netplay_hc_resolved_through(g_b.hc); + const uint32_t from = (g_fmv_confirm_seen > g_fmv_media_end_sim) + ? g_fmv_confirm_seen : g_fmv_media_end_sim; + gained = rb_fmv_confirm_gain(from, rt); + if (rt > g_fmv_confirm_seen) g_fmv_confirm_seen = rt; + } + g_fmv_core_match_streak += gained; } - if (matched) - g_fmv_core_match_streak++; - else - g_fmv_core_match_streak = 0; /* §93: after MAX-unmatched DESYNC, keep counting so a later rematch can * clear invent/begin hold without requiring a new media bout. */ @@ -1237,6 +1254,7 @@ static void rb_fmv_tick_settle(void) if (sim >= g_fmv_media_hi) g_fmv_media_hi = sim; g_fmv_core_match_streak = 0; + g_fmv_confirm_seen = 0; g_fmv_lockstep_released = 0; g_fmv_settle_until = sim + RB_FMV_SETTLE_TICKS; g_fmv_lockstep_until = sim + RB_FMV_LOCKSTEP_MIN; @@ -2245,6 +2263,7 @@ static void realign_invalidate_evidence(uint32_t tick) } if (g_peer_resolved_through > tick) g_peer_resolved_through = tick; + netplay_peer_frontier_clamp(&g_peer_frontier, tick); if (g_local_advertised_through > tick) g_local_advertised_through = tick; if (g_snaps) @@ -3295,10 +3314,35 @@ static int maybe_abandon_tip_extend(void) /* Apply inbound peer RESOLVED: raise g_peer_resolved_through + library * convergence, and complete Verify if POST was lost. */ -static void apply_peer_resolved(uint32_t resolved) +static void apply_peer_resolved(int from, uint32_t advert) { - if (!g_rb || resolved == 0u) + uint32_t seats = psx_netplay_peer_seats(); + uint32_t resolved; + if (!g_rb || advert == 0u) return; + if (seats == 0u) { + /* No seat roster (bare two-seat session): the one peer's advert. */ + resolved = advert; + } else { + int seat = -1; + if (from >= 0 && (uint32_t)from < NETPLAY_PEER_FRONTIER_SEATS && + (seats & (1u << from))) + seat = from; + else if ((seats & (seats - 1u)) == 0u) { + /* Sole peer: the sender is unambiguous. */ + seat = 0; + while (!(seats & (1u << seat))) + seat++; + } + if (seat < 0) + return; + netplay_peer_frontier_note(&g_peer_frontier, seat, advert); + /* With more than two seats an advert speaks only for its sender: + * the frontier every follower has reached is the lowest. */ + resolved = netplay_peer_frontier_min(&g_peer_frontier, seats); + if (resolved == 0u) + return; + } if (resolved > g_peer_resolved_through) { g_peer_resolved_through = resolved; g_peer_resolved_gate_expired = 0; @@ -3522,6 +3566,15 @@ static void advance_agreed_watermark_from_hc(void) fprintf(stderr, "psxrecomp: rb agreed ADVANCE %u→%u (hc=%u live confirm)\n", (unsigned)old, (unsigned)t, (unsigned)rt); + /* §55: every peer matched cores at t >= the bisect cap, so the + * fork it bounded is healed. A stale cap refuses every later + * episode once its loads leave the ring. */ + if (netplay_fork_cap_retired(g_bl_fork_cap, t)) { + fprintf(stderr, + "psxrecomp: rb fork cap %u retired (agreed %u live confirm)\n", + (unsigned)g_bl_fork_cap, (unsigned)t); + g_bl_fork_cap = 0u; + } fflush(stderr); /* §40: peer follow frontier lags local agreed until RESOLVED lands. */ advertise_agreed_resolved(t); @@ -3889,6 +3942,31 @@ static int choose_load_tick_inner(uint32_t mismatch, uint32_t *out_load) return 0; } +/* The bisect window (below the fork cap, above the peer NACK floor) is + * empty or holds no loadable snap. Bisecting cannot heal the fork, but a + * MEDIA_KF heal can: it transfers the initiator's pin to every follower, so + * any recent mutual snap works. Without this an hc-fork recovery on a + * 4-seat WAN race refused for the rest of the session (cap 3296, floor 3280: + * "no mutually-loadable snap") and two pairs of seats finished the race on + * different states. */ +static int choose_load_fork_cap_heal_bypass(uint32_t mismatch, uint32_t *out_load) +{ + static uint32_t s_log_cap; + if (!rb_media_kf_enabled() || !(g_request_post_fmv_heal || g_post_fmv_heal_kf)) + return 0; + if (!choose_load_tick_inner(mismatch, out_load)) + return 0; + if (s_log_cap != g_bl_fork_cap) { + fprintf(stderr, + "psxrecomp: rb choose_load fork cap %u bypass load=%u " + "(bisect window empty; MEDIA_KF heal transfers the pin)\n", + (unsigned)g_bl_fork_cap, (unsigned)*out_load); + fflush(stderr); + s_log_cap = g_bl_fork_cap; + } + return 1; +} + static int choose_load_tick(uint32_t mismatch, uint32_t *out_load) { /* §55: after a baseline core mismatch at load L the fork provably predates @@ -3899,7 +3977,7 @@ static int choose_load_tick(uint32_t mismatch, uint32_t *out_load) if (m > g_bl_fork_cap) m = g_bl_fork_cap; /* inner's shared walk returns load < m */ if (!choose_load_tick_inner(m, out_load)) - return 0; + return choose_load_fork_cap_heal_bypass(mismatch, out_load); /* §62: peer proved it cannot follow ≤ floor (snap evicted). If the * bisect window (must be < fork cap AND > peer floor) is empty, no * mutually-loadable snap covers the fork — refuse to open rather @@ -3915,7 +3993,7 @@ static int choose_load_tick(uint32_t mismatch, uint32_t *out_load) fflush(stderr); s_floor_log = *out_load; } - return 0; + return choose_load_fork_cap_heal_bypass(mismatch, out_load); } if (*out_load >= g_bl_fork_cap) { static uint32_t s_refuse_log; @@ -3927,7 +4005,7 @@ static int choose_load_tick(uint32_t mismatch, uint32_t *out_load) fflush(stderr); s_refuse_log = g_bl_fork_cap; } - return 0; + return choose_load_fork_cap_heal_bypass(mismatch, out_load); } { static uint32_t s_bisect_log; @@ -4728,8 +4806,13 @@ static void log_resim_tick_audit(uint32_t sim, const char *tag) for (slot = 0; slot < n; ++slot) { RNetRbFrame row; if (rnet_rb_get_sealed_frame(g_rb, slot, sim, &row) && row.is_valid) - fprintf(stderr, " s%d=%04x%s", slot, (unsigned)row.buttons, - row.analog ? "A" : ""); + /* Device type and axes too: equal buttons hid a sealed-row + * difference (a digital idle fill vs the seat's analog row). */ + fprintf(stderr, " s%d=%04x%s:t%u:%02x%02x%02x%02x", slot, + (unsigned)row.buttons, row.analog ? "A" : "", + (unsigned)row.analog, (unsigned)(uint8_t)row.stick_x, + (unsigned)(uint8_t)row.stick_y, (unsigned)row.rx, + (unsigned)row.ry); else fprintf(stderr, " s%d=----", slot); } @@ -6415,6 +6498,7 @@ void psx_netplay_rb_start(void) g_agreed_through = 0; g_agreed_span_lo = 0; g_peer_resolved_through = 0; + netplay_peer_frontier_reset(&g_peer_frontier); g_peer_resolved_wait_t0_ms = 0ull; g_peer_resolved_gate_expired = 0; g_past_frontier_sticky = 0; @@ -6498,6 +6582,7 @@ void psx_netplay_rb_cold_reset(void) g_fmv_settle_until = 0; g_fmv_lockstep_until = 0; g_fmv_media_end_sim = 0; + g_fmv_confirm_seen = 0; g_fmv_core_match_streak = 0; g_fmv_lockstep_released = 0; g_fmv_dense_through = 0; @@ -6551,6 +6636,7 @@ void psx_netplay_rb_shutdown(void) g_fmv_settle_until = 0; g_fmv_lockstep_until = 0; g_fmv_media_end_sim = 0; + g_fmv_confirm_seen = 0; g_fmv_core_match_streak = 0; g_fmv_lockstep_released = 0; g_fmv_dense_through = 0; @@ -6595,6 +6681,7 @@ void psx_netplay_rb_shutdown(void) g_agreed_through = 0; g_agreed_span_lo = 0; g_peer_resolved_through = 0; + netplay_peer_frontier_reset(&g_peer_frontier); g_peer_resolved_wait_t0_ms = 0ull; g_peer_resolved_gate_expired = 0; g_past_frontier_sticky = 0; @@ -6630,6 +6717,15 @@ static int rb_fmv_dense_snap_window(void) void psx_netplay_rb_request_snap(uint32_t tick) { uint32_t iv; + /* Every tick boundary, Live and Replay alike: re-anchor the dirty-RAM + * interpreter's host-only IRQ poll stride. A load resets it to phase 0; + * Live otherwise carried an arbitrary phase, so a replay took the VBlank + * in RAM code at another instruction (EPC / clock split at R4's race + * start under PSX_RB_DET_SELFTEST). */ + { + extern void dirty_ram_irq_ambient_resync_after_restore(void); + dirty_ram_irq_ambient_resync_after_restore(); + } if (!g_snaps) return; /* Never overwrite the frozen episode baseline (or its ring slot). */ @@ -6747,8 +6843,14 @@ static int try_apply_pending_load(CPUState *cpu_in) psx_cycles_resync_after_restore(cpu_in); interrupts_resync_after_restore(); cdrom_resync_deadlines_after_restore(); - if (!cdrom_xa_stream_active() && !cdrom_fmv_stream_pending()) - spu_cd_audio_reset(); + { + /* The snap carried the CD input FIFO (BS_SEC_SPUCD): keep it. */ + extern int g_spu_cd_restored; + if (!g_spu_cd_restored && !cdrom_xa_stream_active() && + !cdrom_fmv_stream_pending()) + spu_cd_audio_reset(); + g_spu_cd_restored = 0; + } { extern void overlay_loader_clear_lazy_miss(void); overlay_loader_clear_lazy_miss(); @@ -6882,8 +6984,14 @@ static int try_apply_pending_load(CPUState *cpu_in) cdrom_resync_deadlines_after_restore(); /* Wipe SPU CD FIFO only when XA is idle — clearing mid-stream * left MotK FMV silent/black after tip loads into media. */ - if (!cdrom_xa_stream_active() && !cdrom_fmv_stream_pending()) - spu_cd_audio_reset(); + { + /* The snap carried the CD input FIFO (BS_SEC_SPUCD): keep it. */ + extern int g_spu_cd_restored; + if (!g_spu_cd_restored && !cdrom_xa_stream_active() && + !cdrom_fmv_stream_pending()) + spu_cd_audio_reset(); + g_spu_cd_restored = 0; + } { extern void overlay_loader_clear_lazy_miss(void); overlay_loader_clear_lazy_miss(); @@ -7045,6 +7153,11 @@ void psx_netplay_rb_flush_resume(void) live_realign_resume = !g_rb || !rnet_rb_is_active(g_rb); if (!live_realign_resume && rnet_rb_get_phase(g_rb) != nRNetRbPhaseReplay) return; + /* Live realign: the resumed frame must be an admitted tick, or it runs + * uncounted and this seat ends one frame ahead of its label. Stay + * spinning until the tick's inputs admit. */ + if (live_realign_resume && !psx_netplay_live_realign_admit()) + return; pc = g_pending_resume_pc; if (!rb_resume_pc_ok(pc)) { uint32_t alt = pick_snap_resume_pc(cpu(), pc); @@ -7796,7 +7909,7 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) { rnet_u32 resolved = 0; while (rnet_session_take_rb_resolved(s, &resolved)) - apply_peer_resolved(resolved); + apply_peer_resolved(rnet_session_rb_last_take_from(s), resolved); } /* Tick settle tracker. Media/lockstep episodes resume into MotK wait/VLC @@ -8537,9 +8650,20 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin * after local snap apply. Gating on !snap_applied left the loser * stashing the winner's BEGIN forever while both sides waited on * peer baseline (2026-08-02 soak: epoch 16 vs 17, 404× WIN drop). - * Replay/Verify still refuse — abort/NACK owns those. */ - if (g_rb && rnet_rb_is_active(g_rb) && !rnet_rb_is_from_peer_notify(g_rb) && - epoch != rnet_rb_get_epoch_id(g_rb)) { + * Replay/Verify still refuse — abort/NACK owns those. + * With three or more seats a FOLLOWER sees the race too: two guests + * mispredict the same tick and both BEGIN. The follower used to follow + * whichever BEGIN landed first and stash the other, so with three + * initiators the seats split across epochs and each waited forever for + * baselines that never came (4-seat WAN: host on epoch 43 from slot 3, + * slot 1 winning with 41). The epoch id names its initiator, so a + * follower applies the same rule to the episode it follows + * (netplay_rb_concurrent_begin). Two seats never reach this for a + * follower: the only other initiator is the one it already follows. */ + if (g_rb && rnet_rb_is_active(g_rb) && + netplay_rb_concurrent_begin(rnet_rb_get_epoch_id(g_rb), epoch, + rnet_rb_is_from_peer_notify(g_rb), + RB_EPOCH_SLOT_MASK) != 0) { uint32_t our_epoch = rnet_rb_get_epoch_id(g_rb); uint32_t our_slot = our_epoch & RB_EPOCH_SLOT_MASK; uint32_t their_slot = epoch & RB_EPOCH_SLOT_MASK; @@ -9003,7 +9127,7 @@ void psx_netplay_rb_pump(void) { rnet_u32 resolved = 0; while (rnet_session_take_rb_resolved(s, &resolved)) - apply_peer_resolved(resolved); + apply_peer_resolved(rnet_session_rb_last_take_from(s), resolved); } /* §53b: Live cadence — only HC-confirmed ADVANCE. Aged HEAL / HEAL-FORCE * stay at begin/follow: running them every pump under a live HC stall @@ -9027,7 +9151,7 @@ void psx_netplay_rb_pump(void) (g_peer_commit_epoch != epoch || load > g_peer_commit_tick)) { g_peer_commit_epoch = epoch; g_peer_commit_tick = load; - apply_peer_resolved(load); + apply_peer_resolved(rnet_session_rb_last_take_from(s), load); (void)maybe_abandon_tip_extend(); } continue; @@ -9144,7 +9268,7 @@ void psx_netplay_rb_pump(void) (unsigned)peer_frontier); fflush(stderr); } - apply_peer_resolved(peer_frontier); + apply_peer_resolved(rnet_session_rb_last_take_from(s), peer_frontier); if (!g_agreed_valid || g_agreed_through < peer_frontier) { g_agreed_through = peer_frontier; g_agreed_span_lo = peer_frontier; @@ -9284,6 +9408,8 @@ void psx_netplay_rb_pump(void) if (g_peer_resolved_through == 0u || g_peer_resolved_through > peer_frontier) g_peer_resolved_through = peer_frontier; + netplay_peer_frontier_clamp(&g_peer_frontier, + peer_frontier); g_peer_resolved_gate_expired = 0; g_peer_resolved_wait_t0_ms = 0ull; g_past_frontier_sticky = 1; @@ -9957,7 +10083,7 @@ void psx_netplay_rb_finish_frame(void) log_resim_tick_audit(done, "fin"); psx_netplay_rb_request_snap(done); if (c) - psx_netplay_rb_poll(c, pick_snap_resume_pc(c, 0u)); + psx_netplay_rb_poll(c, psx_netplay_snap_resume_hint(c)); rnet_session_advance(s); g_needs_advance = 0; diff --git a/runtime/src/spu.c b/runtime/src/spu.c index 5239bad0..ebb21347 100644 --- a/runtime/src/spu.c +++ b/runtime/src/spu.c @@ -466,6 +466,41 @@ void spu_cd_audio_push(const int16_t* stereo, int frames) { cd_frame_count); } +/* CD input frames queued for the SPU are guest state: they come from the + * CD-ROM decoder at guest time and the SPU consumes them sample by sample + * (output and the CD capture area of SPU RAM). They used to be outside the + * snapshot and were wiped on rollback loads, so a replay mixed different CD + * audio than the live run (SPU voices / SPU RAM split at R4's race start). */ +int g_spu_cd_restored; +uint32_t spu_cd_snapshot_bytes(void) { return 4u + cd_frame_count * 4u; } +void spu_cd_snapshot_write(uint8_t *p) { + uint32_t n = cd_frame_count, pos = cd_read_pos; + p[0] = (uint8_t)n; p[1] = (uint8_t)(n >> 8); p[2] = (uint8_t)(n >> 16); p[3] = (uint8_t)(n >> 24); + p += 4; + for (uint32_t i = 0; i < n; i++) { + uint16_t l = (uint16_t)cd_ring[pos * 2u], r = (uint16_t)cd_ring[pos * 2u + 1u]; + p[0] = (uint8_t)l; p[1] = (uint8_t)(l >> 8); p[2] = (uint8_t)r; p[3] = (uint8_t)(r >> 8); + p += 4; + pos = (pos + 1u) % SPU_CD_RING_FRAMES; + } +} +int spu_cd_snapshot_read(const uint8_t *p, uint32_t len) { + uint32_t n; + if (!p || len < 4u) return 0; + n = (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); + if (n > SPU_CD_RING_FRAMES || len != 4u + n * 4u) return 0; + p += 4; + for (uint32_t i = 0; i < n; i++, p += 4) { + cd_ring[i * 2u] = (int16_t)(uint16_t)(p[0] | (p[1] << 8)); + cd_ring[i * 2u + 1u] = (int16_t)(uint16_t)(p[2] | (p[3] << 8)); + } + cd_read_pos = 0; + cd_write_pos = n % SPU_CD_RING_FRAMES; + cd_frame_count = n; + g_spu_cd_restored = 1; + return 1; +} + static int cd_audio_pop(int16_t* left, int16_t* right) { if (cd_frame_count == 0) return 0; *left = cd_ring[cd_read_pos * 2u + 0u]; diff --git a/runtime/tests/test_gl_blend_accuracy.c b/runtime/tests/test_gl_blend_accuracy.c index 45509c45..162330ff 100644 --- a/runtime/tests/test_gl_blend_accuracy.c +++ b/runtime/tests/test_gl_blend_accuracy.c @@ -197,7 +197,14 @@ int main(void) { gl_renderer_set_swap_interval(0); if (!gl_renderer_init_context(win)) { fprintf(stderr, "SKIP no GL context\n"); return 77; } page(); - /* Default bases (accurate_blending off): measured, not required. */ + /* No caller opt-in: default rendering must match the console. */ + check(gl_renderer_accurate_blending() == 1, "correct blending is the default"); + scene(0); + gr_vram_transfer_out(0, 0, 1024, 512, out); + check(oracle_errors(out) == 0, "default GL rendering matches the PS1 formulas"); + check(diff(out, ref) == 0, "default GL VRAM matches the software reference"); + /* Historical bases: measured for diagnosis, not required. */ + gl_renderer_set_accurate_blending(0); scene(0); gr_vram_transfer_out(0, 0, 1024, 512, out); printf("gl 4x accurate_blending=0: oracle mismatches=%ld (historical bases)\n", oracle_errors(out)); diff --git a/runtime/tests/test_gl_xbr.c b/runtime/tests/test_gl_xbr.c new file mode 100644 index 00000000..5001c967 --- /dev/null +++ b/runtime/tests/test_gl_xbr.c @@ -0,0 +1,122 @@ +/* [video] texture_filtering = "xbr" on a real, hidden OpenGL context, driven + * through the renderer facade (gr_*). + * + * - a 45-degree red/blue edge magnified 8x loses its 8-pixel staircase: the + * boundary column in each row follows the diagonal closely; + * - straight edges and flat areas stay exactly the nearest-texel image, and + * a 1:1 (unmagnified) draw is byte-identical to nearest; + * - cutouts (texel 0) stay cutouts and never lend their colour; + * - the render thread gives the same pixels as the direct path; + * - the other filter modes are unchanged by the new one (nearest after xbr + * restores the exact image). + * Original source-owned scene; no retail payload. */ +#define main rt_fixture_main +#include "test_gl_render_thread.c" +#undef main + +static uint16_t nat[1024 * 512]; +#define TP 0x0108 /* 15-bit page at (512,0) */ + +static void pages(void) { + static uint16_t pg[32 * 16]; + for (int y = 0; y < 16; y++) + for (int x = 0; x < 32; x++) { + uint16_t c; + if (x < 16) c = x > y ? 0x001f : 0x7c00; /* diagonal edge */ + else if (x < 24) c = x < 20 ? 0x001f : 0x7c00; /* vertical edge */ + else c = (x == 28 && y == 8) ? 0x0000 : 0x03e0; /* green with a hole */ + pg[y * 32 + x] = c; + } + cpu_upload(512, 0, 32, 16, pg); +} +/* Each triangle carries GTE provenance (precise vertices), as world geometry + * does, so [video] texture_lod sets its LOD bit on these draws. */ +static void quad(int x, int y, int w, int h, int u0, int v0, int uw, int vh) { + gr_set_precise_triangle(1, x << 16, y << 16, (x + w) << 16, y << 16, x << 16, (y + h) << 16); + gr_draw_textured_triangle(x, y, u0, v0, x + w, y, u0 + uw, v0, x, y + h, u0, v0 + vh, 0, 0, TP); + gr_set_precise_triangle(1, (x + w) << 16, y << 16, (x + w) << 16, (y + h) << 16, x << 16, (y + h) << 16); + gr_draw_textured_triangle(x + w, y, u0 + uw, v0, x + w, y + h, u0 + uw, v0 + vh, x, y + h, u0, v0 + vh, 0, 0, TP); +} +static void scene(void) { + gr_set_draw_area(0, 0, 319, 239); + gr_set_draw_offset(0, 0); + mask_bits(0, 0); + gr_set_semi_transparency(0, 0); + gr_set_color_modulation(128, 128, 128, 1); + gr_fill_rect(0, 0, 320, 240, 0x1084); + quad(0, 0, 128, 128, 0, 0, 16, 16); /* diagonal, 8x */ + quad(140, 0, 64, 128, 16, 0, 8, 16); /* vertical edge, 8x */ + quad(210, 0, 64, 128, 24, 0, 8, 16); /* flat green with a hole, 8x */ + quad(0, 150, 32, 16, 0, 0, 32, 16); /* 1:1 */ + gl_renderer_render_thread_frame_boundary(); + gr_vram_transfer_out(0, 0, 1024, 512, nat); +} +/* Mean distance between each row's first red-dominant column and the ideal + * diagonal (x = y + 0.5 texel, in pixels). */ +static double stair_error(void) { + double e = 0; int n = 0; + for (int y = 4; y < 124; y++) { + int edge = 128; + for (int x = 0; x < 128; x++) { + uint16_t p = nat[y * 1024 + x]; + if ((p & 31) > ((p >> 10) & 31)) { edge = x; break; } + } + e += fabs((double)edge - ((double)y + 4.0)); n++; + } + return e / n; +} +static int region_equal(const uint16_t *a, const uint16_t *b, int x, int y, int w, int h) { + for (int r = y; r < y + h; r++) + if (memcmp(a + r * 1024 + x, b + r * 1024 + x, (size_t)w * 2)) return 0; + return 1; +} + +int main(void) { + if (SDL_Init(SDL_INIT_VIDEO) != 0) { fprintf(stderr, "SKIP no video\n"); return 77; } + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); +#if defined(__APPLE__) + SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, SDL_GL_CONTEXT_FORWARD_COMPATIBLE_FLAG); +#endif + SDL_Window *win = SDL_CreateWindow("xBR hidden test", 0, 0, 320, 240, + SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN); + if (!win) { fprintf(stderr, "SKIP no window\n"); return 77; } + gr_set_backend(GR_BACKEND_OPENGL); + gr_init(vram); + gr_set_scale(1); + gl_renderer_set_swap_interval(0); + if (!gl_renderer_init_context(win)) { fprintf(stderr, "SKIP no GL context\n"); return 77; } + pages(); + static uint16_t ref[1024 * 512], x[1024 * 512]; + gr_set_texture_filter(0); + scene(); memcpy(ref, nat, sizeof ref); + const double e_near = stair_error(); + gr_set_texture_filter(3); + check(gr_texture_filter() == 3, "xbr selectable"); + scene(); memcpy(x, nat, sizeof x); + const double e_xbr = stair_error(); + printf("stair error nearest=%.2f xbr=%.2f\n", e_near, e_xbr); + check(e_xbr < e_near * 0.7, "diagonal staircase smoothed"); + check(region_equal(ref, x, 140, 0, 64, 128), "straight edge unchanged"); + check(region_equal(ref, x, 210, 0, 64, 128), "flat area and cutout unchanged"); + check(region_equal(ref, x, 0, 150, 32, 16), "1:1 draw identical to nearest"); + /* texture_lod adds bit 4 to the filter key; xbr must still run (the + * magnified quads keep their texels under LOD). */ + gl_renderer_set_texture_lod(1, 16); + scene(); + check(memcmp(nat, x, sizeof x) == 0, "xbr unchanged with texture_lod on"); + gl_renderer_set_texture_lod(0, 1); + check(gl_renderer_render_thread_start(2) == 1, "render thread started"); + scene(); + gl_renderer_render_thread_sync("xbr"); + check(memcmp(nat, x, sizeof x) == 0, "render thread gives the same pixels"); + gl_renderer_render_thread_stop(); + gr_set_texture_filter(0); + scene(); + check(memcmp(nat, ref, sizeof ref) == 0, "nearest after xbr restores the exact image"); + check(glGetError() == GL_NO_ERROR, "GL errors"); + printf("checks=%d failures=%d\n", checks, failures); + gl_renderer_shutdown(); SDL_DestroyWindow(win); SDL_Quit(); + return failures ? 1 : 0; +} diff --git a/runtime/tests/test_host_rumble.c b/runtime/tests/test_host_rumble.c new file mode 100644 index 00000000..87419b65 --- /dev/null +++ b/runtime/tests/test_host_rumble.c @@ -0,0 +1,37 @@ +/* psx_host_rumble.h: title-supplied rumble merge and expiry. */ +#include +#include "psx_host_rumble.h" + +static int s_fail; +#define CHECK(c, m) do { if (!(c)) { fprintf(stderr, "FAIL %s\n", m); ++s_fail; } } while (0) + +int main(void) { + PsxHostRumbleSlot slot = {0, 0, 0, 0}; + uint8_t small = 0, large = 0; + psx_host_rumble_merge(&slot, 5, &small, &large); + CHECK(small == 0 && large == 0, "never set: guest motors unchanged"); + + psx_host_rumble_set(&slot, 10, 1, 150); + psx_host_rumble_merge(&slot, 10, &small, &large); + CHECK(small == 0xFF && large == 150, "set: small on, large 150"); + + small = 0; large = 200; + psx_host_rumble_merge(&slot, 12, &small, &large); + CHECK(large == 200 && small == 0xFF, "louder guest large motor wins"); + + small = 0; large = 0; + psx_host_rumble_merge(&slot, 10 + PSX_HOST_RUMBLE_TTL - 1, &small, &large); + CHECK(large == 150, "still live just before the TTL"); + small = 0; large = 0; + psx_host_rumble_merge(&slot, 10 + PSX_HOST_RUMBLE_TTL, &small, &large); + CHECK(small == 0 && large == 0, "lapses after the TTL without a refresh"); + + psx_host_rumble_set(&slot, 0xFFFFFFFEu, 0, 999); + small = 0; large = 0; + psx_host_rumble_merge(&slot, 1u, &small, &large); + CHECK(large == 255 && small == 0, "clamped to 255; VBlank wrap handled"); + + if (s_fail) return 1; + puts("host rumble tests passed"); + return 0; +} diff --git a/runtime/tests/test_mod_internal_resolution.c b/runtime/tests/test_mod_internal_resolution.c new file mode 100644 index 00000000..f33ce426 --- /dev/null +++ b/runtime/tests/test_mod_internal_resolution.c @@ -0,0 +1,25 @@ +#include "mod_plugins.h" +#include "mod_internal_resolution.h" +#include + +#define CHECK(x) do { if (!(x)) { fprintf(stderr, "Failed: %s\n", #x); return 1; } } while (0) + +int main(void) { + /* Disabled sessions retain the configured baseline; a later launch must + * not inherit an earlier active graphics mod's requested height. */ + CHECK(psx_mod_internal_resolution_request() == 0u); + CHECK(psx_mod_set_internal_resolution(1080u)); + CHECK(psx_mod_internal_resolution_request() == 1080u); + CHECK(!psx_mod_set_internal_resolution(119u)); + CHECK(!psx_mod_set_internal_resolution(8193u)); + CHECK(!psx_mod_set_internal_resolution(UINT32_MAX)); + CHECK(psx_mod_internal_resolution_request() == 1080u); + psx_mod_internal_resolution_reset(); + CHECK(psx_mod_internal_resolution_request() == 0u); + CHECK(psx_mod_set_internal_resolution(120u)); + CHECK(psx_mod_set_internal_resolution(8192u)); + CHECK(psx_mod_set_internal_resolution(0u)); + CHECK(psx_mod_internal_resolution_request() == 0u); + puts("session-scoped mod resolution validation passed"); + return 0; +} diff --git a/runtime/tests/test_netplay_fmv_confirm.c b/runtime/tests/test_netplay_fmv_confirm.c new file mode 100644 index 00000000..1721d52c --- /dev/null +++ b/runtime/tests/test_netplay_fmv_confirm.c @@ -0,0 +1,31 @@ +#include "netplay_fmv_confirm.h" +#undef NDEBUG +#include + +/* Model the gate's counting: the peer's digests trail by `lag` ticks. */ +static uint32_t streak_after(uint32_t media_end, uint32_t ticks, uint32_t lag) +{ + uint32_t seen = 0, streak = 0, sim; + for (sim = media_end; sim < media_end + ticks; ++sim) { + const uint32_t resolved = sim > lag ? sim - lag : 0; + if (resolved < media_end) continue; /* nothing past the movie yet */ + streak += rb_fmv_confirm_gain(seen > media_end ? seen : media_end, resolved); + if (resolved > seen) seen = resolved; + } + return streak; +} + +int main(void) +{ + assert(rb_fmv_confirm_gain(10, 10) == 0); + assert(rb_fmv_confirm_gain(10, 9) == 0); + assert(rb_fmv_confirm_gain(10, 13) == 3); + /* Same machine (no lag) and cross-machine (2-6 ticks of lag) both reach + * the 16-tick confirmation well inside the 300-tick cap. */ + assert(streak_after(1000, 300, 0) >= 16); + assert(streak_after(1000, 300, 2) >= 16); + assert(streak_after(1000, 300, 6) >= 16); + /* Bursty confirmations (a frontier jump) still count every tick once. */ + assert(streak_after(1000, 40, 30) == 9); + return 0; +} diff --git a/runtime/tests/test_netplay_peer_frontier.c b/runtime/tests/test_netplay_peer_frontier.c new file mode 100644 index 00000000..4fa958a4 --- /dev/null +++ b/runtime/tests/test_netplay_peer_frontier.c @@ -0,0 +1,78 @@ +#include "netplay_peer_frontier.h" + +#include + +static int failures; +#define CHECK(cond, msg) \ + do { \ + if (!(cond)) { \ + printf("FAIL: %s\n", msg); \ + failures++; \ + } else { \ + printf("ok: %s\n", msg); \ + } \ + } while (0) + +int main(void) +{ + NetplayPeerFrontier f; + const uint32_t two = 1u << 1; /* host + seat 1 */ + const uint32_t three = (1u << 1) | (1u << 2); /* host + seats 1, 2 */ + const uint32_t four = three | (1u << 3); + + netplay_peer_frontier_reset(&f); + CHECK(netplay_peer_frontier_min(&f, three) == 0u, "nothing advertised -> 0"); + + netplay_peer_frontier_note(&f, 1, 752u); + CHECK(netplay_peer_frontier_min(&f, two) == 752u, "two seats: the single peer's frontier"); + CHECK(netplay_peer_frontier_min(&f, three) == 0u, + "three seats: one advert does not speak for the silent seat"); + + /* The 3-seat WAN failure: host had 2400 from the fast peer only. */ + netplay_peer_frontier_reset(&f); + netplay_peer_frontier_note(&f, 1, 2344u); + netplay_peer_frontier_note(&f, 2, 2319u); + netplay_peer_frontier_note(&f, 1, 2400u); + CHECK(netplay_peer_frontier_min(&f, three) == 2319u, "slowest follower bounds the load"); + + netplay_peer_frontier_note(&f, 2, 2300u); + CHECK(netplay_peer_frontier_min(&f, three) == 2319u, "reordered older advert never lowers"); + + netplay_peer_frontier_note(&f, 3, 5000u); + CHECK(netplay_peer_frontier_min(&f, four) == 2319u, "four seats: still the slowest"); + + netplay_peer_frontier_clamp(&f, 2320u); + CHECK(f.through[1] == 2320u && f.through[2] == 2319u && f.through[3] == 2320u, + "clamp lowers every seat above the tick, leaves the rest"); + + netplay_peer_frontier_note(&f, -1, 9u); + netplay_peer_frontier_note(&f, 99, 9u); + CHECK(netplay_peer_frontier_min(&f, four) == 2319u, "out-of-range seats ignored"); + CHECK(netplay_peer_frontier_min(&f, 0u) == 0u, "no peer seats -> 0"); + + CHECK(!netplay_fork_cap_retired(0u, 3136u), "no cap: nothing to retire"); + CHECK(!netplay_fork_cap_retired(1928u, 1920u), "agreed below the cap keeps it"); + CHECK(netplay_fork_cap_retired(1928u, 1928u), "agreed at the cap retires it"); + CHECK(netplay_fork_cap_retired(1928u, 3136u), "agreed past the cap retires it (4-seat race)"); + + /* Epoch ids: (counter << 3) | initiator slot. */ +#define EP(n, slot) (((uint32_t)(n) << 3) | (uint32_t)(slot)) + CHECK(netplay_rb_concurrent_begin(EP(5, 1), EP(5, 1), 0, 7u) == 0, "same epoch: no contest"); + CHECK(netplay_rb_concurrent_begin(EP(5, 2), EP(5, 1), 0, 7u) == 1, + "initiator: lower peer slot wins, we yield"); + CHECK(netplay_rb_concurrent_begin(EP(5, 1), EP(5, 2), 0, 7u) == -1, + "initiator: higher peer slot loses, drop it"); + CHECK(netplay_rb_concurrent_begin(EP(5, 3), EP(5, 1), 1, 7u) == 1, + "follower of slot 3 switches to slot 1 (4-seat WAN split)"); + CHECK(netplay_rb_concurrent_begin(EP(5, 1), EP(5, 3), 1, 7u) == -1, + "follower of slot 1 drops slot 3's BEGIN"); + CHECK(netplay_rb_concurrent_begin(EP(5, 0), EP(6, 0), 1, 7u) == 0, + "follower: initiator reopened -> not a contest (two-seat path)"); + + if (failures) { + printf("%d failure(s)\n", failures); + return 1; + } + printf("ALL PASS\n"); + return 0; +} diff --git a/tools/rb_snap_audit_diff.py b/tools/rb_snap_audit_diff.py new file mode 100755 index 00000000..cd3a0834 --- /dev/null +++ b/tools/rb_snap_audit_diff.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Replay-determinism diff for PSX_RB_SNAP_AUDIT logs. + +usage: rb_snap_audit_diff.py (dir holds snap-audit-p.txt per seat) + +1. Same seat, replay vs live: every R save at tick T must equal the seat's + live L save at T section by section (same snap + same rows => same machine). +2. Across seats: the last save of each tick (after any rollback) must agree. +Prints the first differing section/byte offsets; exit 1 on any difference. +""" +import collections, glob, re, sys + +NAMES = {1: 'CPU', 2: 'RAM', 3: 'SPAD', 4: 'IRQ', 5: 'TIMER', 6: 'CLOCK', 7: 'GPU', + 8: 'VRAM', 9: 'SPU', 0xa: 'SPURAM', 0xb: 'CDROM', 0xc: 'DMA', 0xd: 'SIO', + 0xe: 'DIRTY', 0xf: 'MDEC', 0x10: 'ICACHE', 0x11: 'MODMEM', 0x13: 'GCS'} + + +def parse(path): + live, replay = {}, collections.defaultdict(list) + for line in open(path): + p = line.split() + if len(p) < 3: + continue + secs = {} + for x in p[2:]: + tag, rest = x.split(':', 1) + crc, _, hexd = rest.partition('=') + secs[int(tag, 16)] = (crc, hexd) + (live.__setitem__(int(p[1]), secs) if p[0] == 'L' + else replay[int(p[1])].append(secs)) + return live, replay + + +def bytes_diff(a, b): + if not a or not b: + return [] + x, y = bytes.fromhex(a), bytes.fromhex(b) + return [(i, x[i], y[i]) for i in range(min(len(x), len(y))) if x[i] != y[i]][:8] + + +def main(d): + seats = {int(re.search(r'p(-?\d+)', f)[1]): parse(f) + for f in glob.glob(f'{d}/snap-audit-p*.txt')} + bad = 0 + for s, (live, rep) in sorted(seats.items()): + for t, rows in sorted(rep.items()): + for r in rows: + for tag, (crc, h) in r.items(): + if t in live and tag in live[t] and live[t][tag][0] != crc: + bad += 1 + if bad <= 10: + print(f'seat {s} tick {t} replay!=live {NAMES.get(tag, hex(tag))}', + bytes_diff(live[t][tag][1], h)) + if len(seats) > 1: + common = set.intersection(*(set(v[0]) for v in seats.values())) + for t in sorted(common): + ref = min(seats) + for tag in seats[ref][0][t]: + vals = {s: v[0][t].get(tag, ('', ''))[0] for s, v in seats.items()} + if len(set(vals.values())) > 1: + bad += 1 + if bad <= 20: + print(f'tick {t} seats differ {NAMES.get(tag, hex(tag))} {vals}') + print('differences:', bad) + return 1 if bad else 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1]))