Merge master and enable PS1 blend correction by default

This commit is contained in:
Matthew Stanley
2026-10-09 23:55:29 -07:00
36 changed files with 1283 additions and 87 deletions
+30
View File
@@ -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
+7
View File
@@ -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
+3 -2
View File
@@ -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":"<name>"}` 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":"<name>"}` 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` | ✓ | | |
+34 -1
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
83f82f8b2daed07e75765c7ae4adb78cdb1a9fd7396fb879089c2352c8dc78a6
e7c8c626e955b89839c5dda1d9990857d87a0042a1cdc1071bbdc42b754a4146
+4 -2
View File
@@ -776,8 +776,9 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path&
const auto mode = toml::find<std::string>(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<std::string>(video, "fmv_filter");
@@ -2869,6 +2870,7 @@ UserSettings load_user_settings(const fs::path& path) {
const auto m = toml::find<std::string>(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<std::string>(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)
+3 -2
View File
@@ -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;
+30
View File
@@ -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 "$<TARGET_FILE:SDL3::SDL3>"
--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
+6
View File
@@ -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;
+1 -1
View File
@@ -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);
+17
View File
@@ -0,0 +1,17 @@
#ifndef PSX_MOD_INTERNAL_RESOLUTION_H
#define PSX_MOD_INTERNAL_RESOLUTION_H
#include <stdint.h>
#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
+18
View File
@@ -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),
+21
View File
@@ -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 <stdint.h>
/* 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
+111
View File
@@ -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 <stdint.h>
#include <string.h>
#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 */
+44
View File
@@ -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 <stdint.h>
#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 */
+4
View File
@@ -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. */
+1
View File
@@ -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
+85 -3
View File
@@ -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, &gte_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;
}
}
+11
View File
@@ -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 },
+51 -4
View File
@@ -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); }
+75 -28
View File
@@ -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
+19
View File
@@ -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;
}
+63
View File
@@ -12,12 +12,74 @@
#include "boot_state.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <zlib.h>
#if defined(_WIN32)
# include <windows.h>
#else
# include <time.h>
#endif
/* PSX_RB_SNAP_AUDIT=<dir>: 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;
+6
View File
@@ -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)
+14 -8
View File
@@ -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.
+64 -5
View File
@@ -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=<K>: 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);
}
}
+154 -28
View File
@@ -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;
+35
View File
@@ -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];
+8 -1
View File
@@ -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));
+122
View File
@@ -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;
}
+37
View File
@@ -0,0 +1,37 @@
/* psx_host_rumble.h: title-supplied rumble merge and expiry. */
#include <stdio.h>
#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;
}
@@ -0,0 +1,25 @@
#include "mod_plugins.h"
#include "mod_internal_resolution.h"
#include <stdio.h>
#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;
}
+31
View File
@@ -0,0 +1,31 @@
#include "netplay_fmv_confirm.h"
#undef NDEBUG
#include <assert.h>
/* 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;
}
@@ -0,0 +1,78 @@
#include "netplay_peer_frontier.h"
#include <stdio.h>
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;
}
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Replay-determinism diff for PSX_RB_SNAP_AUDIT logs.
usage: rb_snap_audit_diff.py <dir> (dir holds snap-audit-p<N>.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]))