From ff994b26c2aa1869a24110ed718ac8ecc2272780 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Wed, 22 Jul 2026 01:34:02 -0400 Subject: [PATCH 01/38] runtime: land BB-defer cycle batching on netplay pin Provide psx_cyc_bb_defer_* helpers and soft-batch charge path so MotK generated code that uses GCC cleanup guards can compile against the merged netplay stack without waiting for the full FMV branch merge. Co-authored-by: Cursor --- runtime/include/psx_cyc.h | 41 ++++++++++++++++++++++++++++++------ runtime/include/psx_cycles.h | 3 +++ runtime/src/psx_cycles.c | 3 +++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/runtime/include/psx_cyc.h b/runtime/include/psx_cyc.h index 1e18923ef..158542f71 100644 --- a/runtime/include/psx_cyc.h +++ b/runtime/include/psx_cyc.h @@ -38,10 +38,29 @@ extern "C" { #endif -/* Load-charge batching (MotK VLC): under the published deadline, accumulate - * into g_psx_cyc_batch instead of storing psx_cycle_count every insn. Flush - * at IRQ edges / MMIO (psx_cyc_batch_flush). Absorb/fudge state still updates - * per insn — only the host counter publish is deferred. */ +/* Load-charge batching (MotK VLC): accumulate into g_psx_cyc_batch instead of + * storing psx_cycle_count every insn. Absorb/fudge still update per insn — + * only the host counter publish is deferred until: + * - psx_cyc_batch_flush (IRQ / MMIO / savestate), or + * - the deferred batch grows past PSX_CYC_BATCH_SOFT (device deadline check), + * - or emitter BB-defer is active (g_psx_cyc_bb_defer): no mid-BB deadline + * probe at all; compiled branches already flush via psx_check_interrupts. + * Guest totals at those barriers are unchanged. */ +enum { PSX_CYC_BATCH_SOFT = 64u }; + +static inline void psx_cyc_bb_defer_begin(void) { g_psx_cyc_bb_defer++; } +static inline void psx_cyc_bb_defer_end(void) { + if (g_psx_cyc_bb_defer > 0) g_psx_cyc_bb_defer--; + if (g_psx_cyc_bb_defer == 0) psx_cyc_batch_flush(); +} +static inline void psx_cyc_bb_defer_flush(void) { psx_cyc_batch_flush(); } +/* GCC/Clang cleanup helper: emitter places one guard at function entry so + * every return path ends BB-defer (CPS/jr/bail) without per-site codegen. */ +static inline void psx_cyc_bb_defer_cleanup(int *guard) { + (void)guard; + psx_cyc_bb_defer_end(); +} + static inline void psx_cyc_charge(uint32_t cycles) { if (cycles == 0u) return; #if defined(__GNUC__) || defined(__clang__) @@ -56,14 +75,22 @@ static inline void psx_cyc_charge(uint32_t cycles) { psx_cycle_count += (uint64_t)cycles; return; } - uint64_t next = psx_cycle_count + (uint64_t)g_psx_cyc_batch + (uint64_t)cycles; - if (psx_next_service_cycle != 0u && next < psx_next_service_cycle) { +#if !defined(PSX_COSIM) + { uint32_t sum = g_psx_cyc_batch + cycles; if (sum >= g_psx_cyc_batch) { /* no uint32 wrap */ g_psx_cyc_batch = sum; - return; + /* Compiled BB defer: IRQ edges publish. Otherwise probe deadline + * only after a soft quantum so MotK VLC doesn't 64-bit-compare + * on every LW. */ + if (g_psx_cyc_bb_defer > 0) return; + if (sum < (uint32_t)PSX_CYC_BATCH_SOFT) return; + uint64_t next = psx_cycle_count + (uint64_t)sum; + if (psx_next_service_cycle != 0u && next < psx_next_service_cycle) + return; } } +#endif psx_advance_cycles(cycles); /* publishes any pending batch first */ } diff --git a/runtime/include/psx_cycles.h b/runtime/include/psx_cycles.h index a82f25dde..737030808 100644 --- a/runtime/include/psx_cycles.h +++ b/runtime/include/psx_cycles.h @@ -54,6 +54,9 @@ extern int g_ls_replay_active; * psx_advance_cycles before IRQ checks, MMIO, or any cycle read that must * match the published counter. Guest totals at those barriers are unchanged. */ extern uint32_t g_psx_cyc_batch; +/* Emitter BB-defer depth: when >0, psx_cyc_charge skips mid-BB deadline + * probes (compiled IRQ edges / psx_cyc_bb_defer_end publish). */ +extern int g_psx_cyc_bb_defer; /* Advance guest time. The common production path is inlined: bump the * counter and only service devices when the next event deadline is due. diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index c4ae26499..17960353d 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -18,6 +18,7 @@ uint64_t psx_cycle_count = 0; uint32_t g_psx_cyc_batch = 0; +int g_psx_cyc_bb_defer = 0; static int s_cycle_replay_active = 0; static uint64_t s_cycle_replay_live = 0; @@ -518,6 +519,7 @@ void psx_idle_note_check(CPUState *cpu, uint32_t check_pc) { * force a fresh deadline on the next charge. */ void psx_cycles_resync_after_restore(void) { g_psx_cyc_batch = 0; + g_psx_cyc_bb_defer = 0; s_devices_synced_cycle = psx_cycle_count; psx_next_service_cycle = 0; /* recompute on next charge */ psx_in_device_service = 0; @@ -525,6 +527,7 @@ void psx_cycles_resync_after_restore(void) { void psx_cycles_reset_for_boot(void) { g_psx_cyc_batch = 0; + g_psx_cyc_bb_defer = 0; psx_cycle_count = 0; s_devices_synced_cycle = 0; s_next_service_cycle = 0; From 1237f27358b67de76cad912a78ac81a6a6d3b407 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Wed, 22 Jul 2026 01:35:31 -0400 Subject: [PATCH 02/38] runtime: fix starvation-ring throttle symbol names STARVATION_RING_ENABLED builds referenced s_watchdog_throttle / s_pc_sample_throttle while the pin still defines the public psx_*_throttle globals. Co-authored-by: Cursor --- runtime/src/psx_cycles.c | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index 17960353d..9414603a0 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -288,14 +288,14 @@ void psx_advance_cycles_slow(uint32_t cycles) { } #endif #if STARVATION_RING_ENABLED - s_watchdog_throttle += cycles; - if (s_watchdog_throttle >= 65536u) { - s_watchdog_throttle = 0; + psx_watchdog_throttle += cycles; + if (psx_watchdog_throttle >= 65536u) { + psx_watchdog_throttle = 0; starvation_watchdog_check(); } - s_pc_sample_throttle += cycles; - if (s_pc_sample_throttle >= 1048576u) { - s_pc_sample_throttle = 0; + psx_pc_sample_throttle += cycles; + if (psx_pc_sample_throttle >= 1048576u) { + psx_pc_sample_throttle = 0; psx_cycles_pc_sample_fire(); } #endif From 3d6b0f3a86d7d31df4b2d9888465739e6d904ba0 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Wed, 22 Jul 2026 13:13:35 -0400 Subject: [PATCH 03/38] launcher: enumerate LAN IPs for host advertise dropdown Implement list_lan_ips (GetAdaptersAddresses / getifaddrs), bump recomp-ui for the Host Lobby LAN IP combo, and keep UDP bind on 0.0.0.0. Co-authored-by: Cursor --- lib/recomp-ui | 2 +- runtime/runtime.cmake | 2 +- runtime/src/main.cpp | 70 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/lib/recomp-ui b/lib/recomp-ui index bd3faeba4..fdbeec682 160000 --- a/lib/recomp-ui +++ b/lib/recomp-ui @@ -1 +1 @@ -Subproject commit bd3faeba42ed76d95906559e84c144f1ff9280a6 +Subproject commit fdbeec682152bd5b8c16fdfa3b759fa85993688a diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 3b4c0d726..5f74eb10d 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -553,7 +553,7 @@ function(psxrecomp_add_runtime_target target) if(WIN32 OR MINGW) # opengl32: GL backend (gpu_gl_renderer.c). GL 1.x is exported directly # by opengl32; Phase 2b will load modern GL via SDL_GL_GetProcAddress. - target_link_libraries(${target} PRIVATE ws2_32 dbghelp comdlg32 opengl32) + target_link_libraries(${target} PRIVATE ws2_32 iphlpapi dbghelp comdlg32 opengl32) else() if(CMAKE_DL_LIBS) target_link_libraries(${target} PRIVATE ${CMAKE_DL_LIBS}) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 2aef1fa27..b2347e850 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -87,8 +87,16 @@ extern "C" void psx_event_step_conservative_env_init(void); #endif #include #include +#include #include #include +#else +#include +#include +#include +#include +#include +#include #endif #ifndef PSX_DEFAULT_BIOS_PATH @@ -4048,6 +4056,67 @@ namespace { #endif } + static int ae_np_push_lan_ip(char out_ips[][64], int max_ips, int* count, + const char* ip) { + if (!out_ips || !count || !ip || !ip[0] || *count >= max_ips) return 0; + if (std::strcmp(ip, "0.0.0.0") == 0 || std::strcmp(ip, "127.0.0.1") == 0) + return 0; + for (int i = 0; i < *count; ++i) { + if (std::strcmp(out_ips[i], ip) == 0) return 0; + } + std::snprintf(out_ips[*count], 64, "%s", ip); + (*count)++; + return 1; + } + + int ae_np_list_lan_ips(void*, char out_ips[][64], int max_ips, int* out_count) { + if (!out_ips || max_ips <= 0 || !out_count) return 0; + *out_count = 0; +#ifdef _WIN32 + WSADATA wsa; + WSAStartup(MAKEWORD(2, 2), &wsa); + ULONG flags = GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | + GAA_FLAG_SKIP_DNS_SERVER; + ULONG buf_len = 16 * 1024; + std::vector buf(buf_len); + IP_ADAPTER_ADDRESSES* addrs = + reinterpret_cast(buf.data()); + ULONG rc = GetAdaptersAddresses(AF_INET, flags, nullptr, addrs, &buf_len); + if (rc == ERROR_BUFFER_OVERFLOW) { + buf.resize(buf_len); + addrs = reinterpret_cast(buf.data()); + rc = GetAdaptersAddresses(AF_INET, flags, nullptr, addrs, &buf_len); + } + if (rc != NO_ERROR) return 0; + for (IP_ADAPTER_ADDRESSES* a = addrs; a; a = a->Next) { + if (a->OperStatus != IfOperStatusUp) continue; + if (a->IfType == IF_TYPE_SOFTWARE_LOOPBACK) continue; + for (IP_ADAPTER_UNICAST_ADDRESS* u = a->FirstUnicastAddress; u; u = u->Next) { + if (!u->Address.lpSockaddr || + u->Address.lpSockaddr->sa_family != AF_INET) + continue; + auto* sin = reinterpret_cast(u->Address.lpSockaddr); + char ip[64] = {}; + if (!inet_ntop(AF_INET, &sin->sin_addr, ip, sizeof(ip))) continue; + ae_np_push_lan_ip(out_ips, max_ips, out_count, ip); + } + } +#else + struct ifaddrs* ifa = nullptr; + if (getifaddrs(&ifa) != 0 || !ifa) return 0; + for (struct ifaddrs* i = ifa; i; i = i->ifa_next) { + if (!i->ifa_addr || i->ifa_addr->sa_family != AF_INET) continue; + if (!(i->ifa_flags & IFF_UP) || (i->ifa_flags & IFF_LOOPBACK)) continue; + auto* sin = reinterpret_cast(i->ifa_addr); + char ip[64] = {}; + if (!inet_ntop(AF_INET, &sin->sin_addr, ip, sizeof(ip))) continue; + ae_np_push_lan_ip(out_ips, max_ips, out_count, ip); + } + freeifaddrs(ifa); +#endif + return *out_count > 0 ? 1 : 0; + } + int ae_np_create(void*, const char* lobby_name, const char* host_port, const char* password, const RecompLauncherCSettings* settings) { @@ -4237,6 +4306,7 @@ namespace { ae_np_launch_pending, ae_np_clear_launch_pending, ae_np_fill_launch, + ae_np_list_lan_ips, }; } // namespace #endif From 2634a88055ab12fcd4182b378e801f4515ef282c Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Wed, 22 Jul 2026 16:14:08 -0400 Subject: [PATCH 04/38] bump --- lib/recomp-ui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/recomp-ui b/lib/recomp-ui index fdbeec682..4347a7e6a 160000 --- a/lib/recomp-ui +++ b/lib/recomp-ui @@ -1 +1 @@ -Subproject commit fdbeec682152bd5b8c16fdfa3b759fa85993688a +Subproject commit 4347a7e6a4c11abf8c56730f38cd4fe9b492c4ce From f8c9af3d6b14212dc1b24c620c18bd3644a211ef Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Wed, 22 Jul 2026 21:24:19 -0400 Subject: [PATCH 05/38] Netplay: LAN/online lobby split, rematch soft-return, port conflict handling. Keep LAN membership on the local registry/UDP path and online lobbies on the WebSocket server, return soft-exits to the same room for rematch, accept guest ephemeral peers, and fail LAN create when the UDP port is busy while online create auto-selects a free port. Bump recomp-ui for lobby UI updates. Co-authored-by: Cursor --- lib/recomp-ui | 2 +- runtime/include/psx_lobby_client.h | 8 + runtime/src/main.cpp | 1047 ++++++++++++++++++++++++++-- runtime/src/psx_lobby_client.c | 122 +++- runtime/src/psx_netplay.c | 3 +- 5 files changed, 1109 insertions(+), 73 deletions(-) diff --git a/lib/recomp-ui b/lib/recomp-ui index 4347a7e6a..2a37e3c23 160000 --- a/lib/recomp-ui +++ b/lib/recomp-ui @@ -1 +1 @@ -Subproject commit 4347a7e6a4c11abf8c56730f38cd4fe9b492c4ce +Subproject commit 2a37e3c2375061e030169b66c701dedde8a3d196 diff --git a/runtime/include/psx_lobby_client.h b/runtime/include/psx_lobby_client.h index 1b7bfd7ff..fdd62a681 100644 --- a/runtime/include/psx_lobby_client.h +++ b/runtime/include/psx_lobby_client.h @@ -111,8 +111,16 @@ int psx_lobby_join(const char *lobby_id, const char *password, int psx_lobby_leave(void); +/* Host-only: remove the player in `slot` (not the host / self). */ +int psx_lobby_kick(int slot); + +/* Host-only: swap/move a seated player between slots (server broadcasts update). */ +int psx_lobby_move_member(int from_slot, int to_slot); + int psx_lobby_in_lobby(void); int psx_lobby_is_host(void); +/* Host's player_id from the last create/lobby_update (empty if unknown). */ +const char *psx_lobby_host_player_id(void); /* Filled after create/join/lobby_update; peer endpoints for PsxNetplayConfig. */ const PsxLobbyJoinInfo *psx_lobby_join_info(void); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index b2347e850..fc557f663 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -92,11 +92,13 @@ extern "C" void psx_event_step_conservative_env_init(void); #include #else #include +#include #include #include #include #include #include +#include #endif #ifndef PSX_DEFAULT_BIOS_PATH @@ -2705,6 +2707,10 @@ static int netplay_timing_on(void) { * sample per sim tick; stalls on INPUT_CONFIRM desync. */ static void netplay_barrier_admit(int override) { if (!psx_netplay_active()) return; + /* Launcher/game window teardown can leave a queued SDL_QUIT; draining it + * prevents an instant soft-return before the first frame. */ + SDL_PumpEvents(); + SDL_FlushEvent(SDL_QUIT); static int desync_logged = 0; const uint64_t admit_t0 = netplay_timing_on() ? SDL_GetPerformanceCounter() : 0; @@ -3805,7 +3811,10 @@ namespace { RecompLauncherCNetplayLaunch g_lnch_pending_direct_launch{}; bool g_lnch_hosting_lan = false; bool g_lnch_joined_lan = false; + /* Join Direct / cross-machine: membership via UDP, not the local file. */ + bool g_lnch_remote_lan = false; std::string g_lnch_lan_endpoint; + uint32_t g_lnch_lan_session_id = 1; struct AeLanLobbyState { std::string name; @@ -3816,17 +3825,158 @@ namespace { std::string password; bool started = false; int host_slot = 0; + uint32_t session_id = 1; }; + AeLanLobbyState g_lnch_remote_lan_state{}; + +#ifdef _WIN32 + using AeLanSock = SOCKET; + static constexpr AeLanSock kAeLanSockInvalid = INVALID_SOCKET; +#else + using AeLanSock = int; + static constexpr AeLanSock kAeLanSockInvalid = -1; +#endif + AeLanSock g_lnch_lan_udp = kAeLanSockInvalid; + sockaddr_in g_lnch_lan_peer{}; + bool g_lnch_lan_peer_valid = false; + uint32_t g_lnch_lan_join_pulse_ms = 0; std::filesystem::path ae_np_lan_file() { return std::filesystem::current_path() / "netplay_lan_lobby.txt"; } + static void ae_np_lan_sock_close(AeLanSock* s) { + if (!s || *s == kAeLanSockInvalid) return; +#ifdef _WIN32 + closesocket(*s); +#else + close(*s); +#endif + *s = kAeLanSockInvalid; + } + + static void ae_np_lan_udp_close(void) { + ae_np_lan_sock_close(&g_lnch_lan_udp); + g_lnch_lan_peer_valid = false; + } + + static int ae_np_lan_endpoint_port(const std::string& endpoint) { + const size_t colon = endpoint.rfind(':'); + if (colon == std::string::npos) return 7777; + const int p = std::atoi(endpoint.c_str() + colon + 1); + return (p > 0 && p <= 65535) ? p : 7777; + } + + static bool ae_np_lan_endpoint_host(const std::string& endpoint, char* host, + size_t host_len) { + if (!host || host_len == 0) return false; + const size_t colon = endpoint.rfind(':'); + if (colon == std::string::npos || colon == 0) return false; + if (colon >= host_len) return false; + std::memcpy(host, endpoint.data(), colon); + host[colon] = '\0'; + return host[0] != '\0'; + } + + static bool ae_np_lan_set_nonblock(AeLanSock s) { +#ifdef _WIN32 + u_long mode = 1; + return ioctlsocket(s, FIONBIO, &mode) == 0; +#else + const int fl = fcntl(s, F_GETFL, 0); + return fl >= 0 && fcntl(s, F_SETFL, fl | O_NONBLOCK) == 0; +#endif + } + + /* Exclusive bind probe (no SO_REUSEADDR) so a busy port is detected. */ + static bool ae_np_udp_port_available(int port) { + if (port <= 0 || port > 65535) return false; +#ifdef _WIN32 + WSADATA wsa; + WSAStartup(MAKEWORD(2, 2), &wsa); +#endif + AeLanSock s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (s == kAeLanSockInvalid) return false; + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_ANY); + addr.sin_port = htons((uint16_t)port); + const bool ok = (bind(s, (sockaddr*)&addr, sizeof(addr)) == 0); + ae_np_lan_sock_close(&s); + return ok; + } + + /* Online create: try preferred, then the next few ports. */ + static int ae_np_find_free_udp_port(int preferred) { + if (preferred <= 0 || preferred > 65535) preferred = 7777; + for (int i = 0; i < 32; ++i) { + const int p = preferred + i; + if (p > 65535) break; + if (ae_np_udp_port_available(p)) return p; + } + return -1; + } + + static bool ae_np_endpoint_replace_port(char* endpoint, size_t cap, int port) { + if (!endpoint || cap < 4 || port <= 0 || port > 65535) return false; + char host[64]; + if (!ae_np_lan_endpoint_host(endpoint, host, sizeof(host))) { + if (endpoint[0] == ':' || !endpoint[0]) + std::snprintf(host, sizeof(host), "0.0.0.0"); + else + return false; + } + const int n = std::snprintf(endpoint, cap, "%s:%d", host, port); + return n > 0 && (size_t)n < cap; + } + + static bool ae_np_lan_udp_ensure(bool bind_port, int port) { +#ifdef _WIN32 + WSADATA wsa; + WSAStartup(MAKEWORD(2, 2), &wsa); +#endif + if (g_lnch_lan_udp == kAeLanSockInvalid) { + g_lnch_lan_udp = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (g_lnch_lan_udp == kAeLanSockInvalid) return false; + if (!ae_np_lan_set_nonblock(g_lnch_lan_udp)) { + ae_np_lan_udp_close(); + return false; + } + /* Do not SO_REUSEADDR on the host lobby port — a second host on the + * same port must fail so we can surface "port in use". */ + if (bind_port) { + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_ANY); + addr.sin_port = htons((uint16_t)port); + if (bind(g_lnch_lan_udp, (sockaddr*)&addr, sizeof(addr)) != 0) { + ae_np_lan_udp_close(); + return false; + } + } + } + return true; + } + + static void ae_np_lan_udp_sendto(const sockaddr_in& to, const char* msg) { + if (g_lnch_lan_udp == kAeLanSockInvalid || !msg) return; + const int n = (int)std::strlen(msg); +#ifdef _WIN32 + sendto(g_lnch_lan_udp, msg, n, 0, (const sockaddr*)&to, sizeof(to)); +#else + sendto(g_lnch_lan_udp, msg, (size_t)n, 0, (const sockaddr*)&to, sizeof(to)); +#endif + } + bool ae_np_read_lan_state(AeLanLobbyState* state) { if (!state) return false; + if (g_lnch_remote_lan) { + *state = g_lnch_remote_lan_state; + return !state->endpoint.empty(); + } std::ifstream f(ae_np_lan_file()); if (!f) return false; - std::string started, host_slot; + std::string started, host_slot, session; std::getline(f, state->name); std::getline(f, state->game); std::getline(f, state->endpoint); @@ -3835,14 +3985,25 @@ namespace { std::getline(f, started); std::getline(f, host_slot); std::getline(f, state->password); + std::getline(f, session); state->started = started == "1"; state->host_slot = host_slot == "1" ? 1 : 0; + state->session_id = 1; + if (!session.empty()) { + const unsigned v = (unsigned)std::strtoul(session.c_str(), nullptr, 10); + if (v) state->session_id = (uint32_t)v; + } return !state->endpoint.empty(); } bool ae_np_write_lan_state(const AeLanLobbyState& state) { + if (g_lnch_remote_lan) { + g_lnch_remote_lan_state = state; + return true; + } std::ofstream f(ae_np_lan_file(), std::ios::trunc); if (!f) return false; + const uint32_t sid = state.session_id ? state.session_id : 1u; f << state.name << "\n" << state.game << "\n" << state.endpoint << "\n" @@ -3850,12 +4011,36 @@ namespace { << state.joiner_name << "\n" << (state.started ? "1" : "0") << "\n" << state.host_slot << "\n" - << state.password << "\n"; + << state.password << "\n" + << sid << "\n"; return (bool)f; } - void ae_np_write_lan_lobby(const char* name, const char* endpoint, + static void ae_np_lan_send_update_to_peer(const AeLanLobbyState& state) { + if (!g_lnch_lan_peer_valid) return; + char msg[384]; + std::snprintf(msg, sizeof(msg), + "MOTK1 UPDATE\n%s\n%s\n%d\n%d\n", + state.host_name.c_str(), + state.joiner_name.c_str(), + state.host_slot, + state.started ? 1 : 0); + ae_np_lan_udp_sendto(g_lnch_lan_peer, msg); + } + + static void ae_np_lan_atexit_cleanup(void) { + if (!g_lnch_hosting_lan) return; + std::error_code ec; + std::filesystem::remove(ae_np_lan_file(), ec); + g_lnch_hosting_lan = false; + } + + /* Returns false if the lobby UDP port cannot be bound (in use). */ + bool ae_np_write_lan_lobby(const char* name, const char* endpoint, const char* password) { + ae_np_lan_udp_close(); + g_lnch_remote_lan = false; + g_lnch_remote_lan_state = {}; AeLanLobbyState state; state.name = name && name[0] ? name : "LAN Lobby"; state.game = g_lnch_netplay_game_name.empty() ? "PSX" : g_lnch_netplay_game_name; @@ -3863,10 +4048,25 @@ namespace { state.host_name = psx_lobby_display_name(); if (state.host_name.empty()) state.host_name = "Host"; state.password = password ? password : ""; - ae_np_write_lan_state(state); + const int port = ae_np_lan_endpoint_port(state.endpoint); + if (!ae_np_udp_port_available(port) || + !ae_np_lan_udp_ensure(true, port)) { + ae_np_lan_udp_close(); + return false; + } + if (!ae_np_write_lan_state(state)) { + ae_np_lan_udp_close(); + return false; + } g_lnch_hosting_lan = true; g_lnch_joined_lan = false; g_lnch_lan_endpoint = state.endpoint; + static bool atexit_hooked = false; + if (!atexit_hooked) { + std::atexit(ae_np_lan_atexit_cleanup); + atexit_hooked = true; + } + return true; } int ae_np_read_lan_lobby(RecompLauncherCNetplayLobby* out) { @@ -3884,6 +4084,195 @@ namespace { return 1; } + static bool ae_np_remote_has_same_name_as_lan(const char* lan_display_name) { + static constexpr const char kLanPrefix[] = "LAN - "; + const char* base = lan_display_name; + if (base && std::strncmp(base, kLanPrefix, sizeof(kLanPrefix) - 1) == 0) + base += sizeof(kLanPrefix) - 1; + if (!base || !base[0]) return false; + for (int i = 0; i < psx_lobby_list_count(); ++i) { + PsxLobbyRow row{}; + if (!psx_lobby_list_get(i, &row)) continue; + if (std::strcmp(row.name, base) == 0) return true; + } + return false; + } + + static bool ae_np_read_lan_file_state(AeLanLobbyState* state) { + if (!state) return false; + std::ifstream f(ae_np_lan_file()); + if (!f) return false; + std::string started, host_slot, session; + std::getline(f, state->name); + std::getline(f, state->game); + std::getline(f, state->endpoint); + std::getline(f, state->host_name); + std::getline(f, state->joiner_name); + std::getline(f, started); + std::getline(f, host_slot); + std::getline(f, state->password); + std::getline(f, session); + state->started = started == "1"; + state->host_slot = host_slot == "1" ? 1 : 0; + state->session_id = 1; + if (!session.empty()) { + const unsigned v = (unsigned)std::strtoul(session.c_str(), nullptr, 10); + if (v) state->session_id = (uint32_t)v; + } + return !state->endpoint.empty(); + } + + /* Probe whether a LAN host is still answering on endpoint. */ + static bool ae_np_lan_probe_host_ms(const std::string& endpoint, uint32_t timeout_ms) { + char host[64]; + if (!ae_np_lan_endpoint_host(endpoint, host, sizeof(host))) return false; +#ifdef _WIN32 + WSADATA wsa; + WSAStartup(MAKEWORD(2, 2), &wsa); +#endif + AeLanSock s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (s == kAeLanSockInvalid) return false; + if (!ae_np_lan_set_nonblock(s)) { + ae_np_lan_sock_close(&s); + return false; + } + sockaddr_in to{}; + to.sin_family = AF_INET; + to.sin_port = htons((uint16_t)ae_np_lan_endpoint_port(endpoint)); + if (inet_pton(AF_INET, host, &to.sin_addr) != 1) { + ae_np_lan_sock_close(&s); + return false; + } + const char ping[] = "MOTK1 PING\n"; +#ifdef _WIN32 + sendto(s, ping, (int)sizeof(ping) - 1, 0, (const sockaddr*)&to, sizeof(to)); +#else + sendto(s, ping, sizeof(ping) - 1, 0, (const sockaddr*)&to, sizeof(to)); +#endif + const uint32_t deadline = SDL_GetTicks() + timeout_ms; + bool alive = false; + while ((int32_t)(deadline - SDL_GetTicks()) > 0) { + char buf[64]; + sockaddr_in from{}; +#ifdef _WIN32 + int fromlen = (int)sizeof(from); + const int n = recvfrom(s, buf, (int)sizeof(buf) - 1, 0, + (sockaddr*)&from, &fromlen); +#else + socklen_t fromlen = sizeof(from); + const int n = (int)recvfrom(s, buf, sizeof(buf) - 1, 0, + (sockaddr*)&from, &fromlen); +#endif + if (n > 0) { + buf[n] = '\0'; + if (std::strncmp(buf, "MOTK1 PONG", 10) == 0) { + alive = true; + break; + } + } + SDL_Delay(5); + } + ae_np_lan_sock_close(&s); + return alive; + } + + static bool ae_np_lan_probe_host(const std::string& endpoint) { + return ae_np_lan_probe_host_ms(endpoint, 200u); + } + + /* Send JOIN and wait for UPDATE / ERR. Returns 0, -1 full, -2 password, -3 timeout. */ + static int ae_np_lan_wait_join_ack(const std::string& endpoint, const char* password, + AeLanLobbyState* out) { + if (!out) return -1; + char host[64]; + if (!ae_np_lan_endpoint_host(endpoint, host, sizeof(host))) return -3; + if (!ae_np_lan_udp_ensure(false, 0)) return -3; + sockaddr_in to{}; + to.sin_family = AF_INET; + to.sin_port = htons((uint16_t)ae_np_lan_endpoint_port(endpoint)); + if (inet_pton(AF_INET, host, &to.sin_addr) != 1) return -3; + + std::string me = psx_lobby_display_name(); + if (me.empty()) me = "Player"; + char msg[320]; + std::snprintf(msg, sizeof(msg), "MOTK1 JOIN\n%s\n%s\n", me.c_str(), + password ? password : ""); + ae_np_lan_udp_sendto(to, msg); + + const uint32_t deadline = SDL_GetTicks() + 1000u; + while ((int32_t)(deadline - SDL_GetTicks()) > 0) { + char buf[512]; + sockaddr_in from{}; +#ifdef _WIN32 + int fromlen = (int)sizeof(from); + const int n = recvfrom(g_lnch_lan_udp, buf, (int)sizeof(buf) - 1, 0, + (sockaddr*)&from, &fromlen); +#else + socklen_t fromlen = sizeof(from); + const int n = (int)recvfrom(g_lnch_lan_udp, buf, sizeof(buf) - 1, 0, + (sockaddr*)&from, &fromlen); +#endif + if (n <= 0) { + SDL_Delay(5); + continue; + } + buf[n] = '\0'; + if (std::strncmp(buf, "MOTK1 ERR\n", 10) == 0) { + const char* code = buf + 10; + if (std::strncmp(code, "bad_password", 12) == 0) return -2; + return -1; + } + if (std::strncmp(buf, "MOTK1 UPDATE\n", 13) == 0) { + char* p = buf + 13; + char* lines[4] = {}; + for (int i = 0; i < 4; ++i) { + lines[i] = p; + char* nl = std::strchr(p, '\n'); + if (!nl) break; + *nl = '\0'; + p = nl + 1; + } + if (!lines[0] || !lines[1] || !lines[2] || !lines[3]) continue; + out->host_name = lines[0]; + out->joiner_name = lines[1]; + out->host_slot = (std::atoi(lines[2]) == 1) ? 1 : 0; + out->started = std::atoi(lines[3]) != 0; + out->endpoint = endpoint; + if (out->joiner_name != me) return -1; + return 0; + } + SDL_Delay(5); + } + return -3; + } + + /* Drop orphan LAN registry files (host crashed / unreachable). + * Never delete merely because started=1: host closes lobby UDP before + * delay-sync bind, so PING fails mid-launch and a delete races the joiner + * reading session_id/started from the file. Started lobbies are already + * hidden by ae_np_lan_list_visible(). */ + static void ae_np_lan_rescan(void) { + if (g_lnch_hosting_lan) return; + if (g_lnch_joined_lan) return; + AeLanLobbyState st; + if (!ae_np_read_lan_file_state(&st)) return; + if (st.started) return; + if (!ae_np_lan_probe_host(st.endpoint)) { + std::error_code ec; + std::filesystem::remove(ae_np_lan_file(), ec); + } + } + + static bool ae_np_lan_list_visible(void) { + if (g_lnch_hosting_lan) { + AeLanLobbyState st; + return ae_np_read_lan_file_state(&st) && !st.started; + } + AeLanLobbyState st; + if (!ae_np_read_lan_file_state(&st) || st.started) return false; + return true; + } + PsxLobbyMatchCaps ae_netplay_caps_from_settings(const RecompLauncherCSettings* s) { PsxLobbyMatchCaps caps{}; caps.valid = 1; @@ -3932,8 +4321,161 @@ namespace { return psx_lobby_connected(); } + static void ae_np_lan_udp_pump(void) { + if (!g_lnch_hosting_lan && !(g_lnch_joined_lan && g_lnch_remote_lan)) + return; + + if (g_lnch_hosting_lan) { + AeLanLobbyState st; + if (!ae_np_read_lan_state(&st)) return; + if (g_lnch_lan_udp == kAeLanSockInvalid) + (void)ae_np_lan_udp_ensure(true, ae_np_lan_endpoint_port(st.endpoint)); + } else if (g_lnch_remote_lan) { + if (g_lnch_lan_udp == kAeLanSockInvalid) + (void)ae_np_lan_udp_ensure(false, 0); + const uint32_t now = SDL_GetTicks(); + if (g_lnch_lan_udp != kAeLanSockInvalid && + now - g_lnch_lan_join_pulse_ms >= 400u) { + g_lnch_lan_join_pulse_ms = now; + char host[64]; + if (ae_np_lan_endpoint_host(g_lnch_lan_endpoint, host, sizeof(host))) { + sockaddr_in to{}; + to.sin_family = AF_INET; + to.sin_port = htons((uint16_t)ae_np_lan_endpoint_port(g_lnch_lan_endpoint)); + if (inet_pton(AF_INET, host, &to.sin_addr) == 1) { + std::string me = psx_lobby_display_name(); + if (me.empty()) me = "Player"; + char msg[320]; + std::snprintf(msg, sizeof(msg), "MOTK1 JOIN\n%s\n%s\n", + me.c_str(), + g_lnch_remote_lan_state.password.c_str()); + ae_np_lan_udp_sendto(to, msg); + } + } + } + } + + if (g_lnch_lan_udp == kAeLanSockInvalid) return; + + for (;;) { + char buf[512]; + sockaddr_in from{}; +#ifdef _WIN32 + int fromlen = (int)sizeof(from); + const int n = recvfrom(g_lnch_lan_udp, buf, (int)sizeof(buf) - 1, 0, + (sockaddr*)&from, &fromlen); +#else + socklen_t fromlen = sizeof(from); + const int n = (int)recvfrom(g_lnch_lan_udp, buf, sizeof(buf) - 1, 0, + (sockaddr*)&from, &fromlen); +#endif + if (n <= 0) break; + buf[n] = '\0'; + + if (std::strncmp(buf, "MOTK1 PING", 10) == 0 && g_lnch_hosting_lan) { + ae_np_lan_udp_sendto(from, "MOTK1 PONG\n"); + continue; + } + + if (std::strncmp(buf, "MOTK1 JOIN\n", 11) == 0 && g_lnch_hosting_lan) { + char* p = buf + 11; + char* nl = std::strchr(p, '\n'); + if (!nl) continue; + *nl = '\0'; + const char* name = p; + p = nl + 1; + nl = std::strchr(p, '\n'); + if (nl) *nl = '\0'; + const char* pass = p; + AeLanLobbyState st; + if (!ae_np_read_lan_state(&st)) continue; + if (st.password != pass) { + ae_np_lan_udp_sendto(from, "MOTK1 ERR\nbad_password\n"); + continue; + } + if (!st.joiner_name.empty() && st.joiner_name != name) { + ae_np_lan_udp_sendto(from, "MOTK1 ERR\nfull\n"); + continue; + } + st.joiner_name = name; + st.started = false; + if (!ae_np_write_lan_state(st)) continue; + g_lnch_lan_peer = from; + g_lnch_lan_peer_valid = true; + ae_np_lan_send_update_to_peer(st); + continue; + } + + if (std::strncmp(buf, "MOTK1 LEAVE\n", 12) == 0 && g_lnch_hosting_lan) { + AeLanLobbyState st; + if (!ae_np_read_lan_state(&st)) continue; + st.joiner_name.clear(); + st.started = false; + ae_np_write_lan_state(st); + g_lnch_lan_peer_valid = false; + continue; + } + + if (!g_lnch_remote_lan) continue; + + if (std::strncmp(buf, "MOTK1 UPDATE\n", 13) == 0) { + char* p = buf + 13; + char* lines[4] = {}; + for (int i = 0; i < 4; ++i) { + lines[i] = p; + char* nl = std::strchr(p, '\n'); + if (!nl) break; + *nl = '\0'; + p = nl + 1; + } + if (!lines[0] || !lines[1] || !lines[2] || !lines[3]) continue; + g_lnch_remote_lan_state.host_name = lines[0]; + g_lnch_remote_lan_state.joiner_name = lines[1]; + g_lnch_remote_lan_state.host_slot = (std::atoi(lines[2]) == 1) ? 1 : 0; + g_lnch_remote_lan_state.started = std::atoi(lines[3]) != 0; + std::string me = psx_lobby_display_name(); + if (me.empty()) me = "Player"; + if (g_lnch_remote_lan_state.joiner_name != me) { + g_lnch_joined_lan = false; + g_lnch_remote_lan = false; + g_lnch_lan_endpoint.clear(); + ae_np_lan_udp_close(); + } + continue; + } + if (std::strncmp(buf, "MOTK1 START\n", 12) == 0) { + g_lnch_remote_lan_state.started = true; + const char* sid = buf + 12; + if (*sid) { + const unsigned v = (unsigned)std::strtoul(sid, nullptr, 10); + if (v) { + g_lnch_lan_session_id = (uint32_t)v; + g_lnch_remote_lan_state.session_id = (uint32_t)v; + } + } + continue; + } + if (std::strncmp(buf, "MOTK1 KICK\n", 11) == 0 || + std::strncmp(buf, "MOTK1 ERR\n", 10) == 0) { + g_lnch_joined_lan = false; + g_lnch_remote_lan = false; + g_lnch_lan_endpoint.clear(); + g_lnch_remote_lan_state = {}; + ae_np_lan_udp_close(); + } + } + } + void ae_np_pump(void*) { psx_lobby_pump(); + ae_np_lan_udp_pump(); + /* Lobby UI has no Ready toggle; production WS still requires every + * seated player ready before start. Keep seats ready while in-room + * (including after soft-return rematch clears ready). */ + if (!g_lnch_hosting_lan && !g_lnch_joined_lan && + psx_lobby_in_lobby() && !psx_lobby_local_ready()) { + (void)psx_lobby_set_ready(1); + } } void ae_np_set_player_name(void*, const char* name) { @@ -3946,19 +4488,19 @@ namespace { } void ae_np_request_list(void*) { + ae_np_lan_rescan(); psx_lobby_request_list(); } int ae_np_list_count(void*) { - RecompLauncherCNetplayLobby lan{}; - return psx_lobby_list_count() + (ae_np_read_lan_lobby(&lan) ? 1 : 0); + return psx_lobby_list_count() + (ae_np_lan_list_visible() ? 1 : 0); } int ae_np_list_get(void*, int index, RecompLauncherCNetplayLobby* out) { if (!out) return 0; const int remote_count = psx_lobby_list_count(); if (index >= remote_count) - return ae_np_read_lan_lobby(out); + return ae_np_lan_list_visible() ? ae_np_read_lan_lobby(out) : 0; PsxLobbyRow row{}; if (!psx_lobby_list_get(index, &row)) return 0; std::snprintf(out->lobby_id, sizeof(out->lobby_id), "%s", row.lobby_id); @@ -4056,22 +4598,25 @@ namespace { #endif } - static int ae_np_push_lan_ip(char out_ips[][64], int max_ips, int* count, - const char* ip) { - if (!out_ips || !count || !ip || !ip[0] || *count >= max_ips) return 0; - if (std::strcmp(ip, "0.0.0.0") == 0 || std::strcmp(ip, "127.0.0.1") == 0) - return 0; - for (int i = 0; i < *count; ++i) { - if (std::strcmp(out_ips[i], ip) == 0) return 0; - } - std::snprintf(out_ips[*count], 64, "%s", ip); - (*count)++; - return 1; - } - - int ae_np_list_lan_ips(void*, char out_ips[][64], int max_ips, int* out_count) { - if (!out_ips || max_ips <= 0 || !out_count) return 0; - *out_count = 0; + /* Collect non-loopback IPv4 addresses for local_address_get. */ + static void ae_np_collect_local_addresses( + std::vector* out) { + if (!out) return; + out->clear(); + auto push = [&](const char* ip, const char* label) { + if (!ip || !ip[0]) return; + if (std::strcmp(ip, "0.0.0.0") == 0 || + std::strcmp(ip, "127.0.0.1") == 0) + return; + for (const auto& existing : *out) { + if (std::strcmp(existing.address, ip) == 0) return; + } + RecompLauncherCNetplayLocalAddress entry{}; + std::snprintf(entry.address, sizeof(entry.address), "%s", ip); + if (label && label[0]) + std::snprintf(entry.label, sizeof(entry.label), "%s", label); + out->push_back(entry); + }; #ifdef _WIN32 WSADATA wsa; WSAStartup(MAKEWORD(2, 2), &wsa); @@ -4087,42 +4632,137 @@ namespace { addrs = reinterpret_cast(buf.data()); rc = GetAdaptersAddresses(AF_INET, flags, nullptr, addrs, &buf_len); } - if (rc != NO_ERROR) return 0; + if (rc != NO_ERROR) return; for (IP_ADAPTER_ADDRESSES* a = addrs; a; a = a->Next) { if (a->OperStatus != IfOperStatusUp) continue; if (a->IfType == IF_TYPE_SOFTWARE_LOOPBACK) continue; - for (IP_ADAPTER_UNICAST_ADDRESS* u = a->FirstUnicastAddress; u; u = u->Next) { + char label[64] = {}; + if (a->FriendlyName) { + WideCharToMultiByte(CP_UTF8, 0, a->FriendlyName, -1, label, + (int)sizeof(label), nullptr, nullptr); + } + for (IP_ADAPTER_UNICAST_ADDRESS* u = a->FirstUnicastAddress; u; + u = u->Next) { if (!u->Address.lpSockaddr || u->Address.lpSockaddr->sa_family != AF_INET) continue; auto* sin = reinterpret_cast(u->Address.lpSockaddr); char ip[64] = {}; if (!inet_ntop(AF_INET, &sin->sin_addr, ip, sizeof(ip))) continue; - ae_np_push_lan_ip(out_ips, max_ips, out_count, ip); + push(ip, label); } } #else struct ifaddrs* ifa = nullptr; - if (getifaddrs(&ifa) != 0 || !ifa) return 0; + if (getifaddrs(&ifa) != 0 || !ifa) return; for (struct ifaddrs* i = ifa; i; i = i->ifa_next) { if (!i->ifa_addr || i->ifa_addr->sa_family != AF_INET) continue; if (!(i->ifa_flags & IFF_UP) || (i->ifa_flags & IFF_LOOPBACK)) continue; auto* sin = reinterpret_cast(i->ifa_addr); char ip[64] = {}; if (!inet_ntop(AF_INET, &sin->sin_addr, ip, sizeof(ip))) continue; - ae_np_push_lan_ip(out_ips, max_ips, out_count, ip); + push(ip, i->ifa_name ? i->ifa_name : ""); } freeifaddrs(ifa); #endif - return *out_count > 0 ? 1 : 0; } - int ae_np_create(void*, const char* lobby_name, const char* host_port, + int ae_np_local_address_get(void*, int index, + RecompLauncherCNetplayLocalAddress* out) { + if (!out || index < 0) return 0; + std::memset(out, 0, sizeof(*out)); + std::vector addrs; + ae_np_collect_local_addresses(&addrs); + if (index >= (int)addrs.size()) return 0; + *out = addrs[(size_t)index]; + return out->address[0] ? 1 : 0; + } + + /* Online create uses 0.0.0.0 / * / :: so the lobby server can rewrite the + * peer-facing endpoint. Those binds are never a same-machine LAN room. */ + static bool ae_np_endpoint_is_any_bind(const char* endpoint) { + if (!endpoint || !endpoint[0]) return true; + const char* colon = std::strrchr(endpoint, ':'); + std::string host = colon ? std::string(endpoint, colon) : std::string(endpoint); + return host.empty() || host == "0.0.0.0" || host == "*" || host == "::" || + host == "[::]"; + } + + /* LAN/Direct IP rooms own membership via the local file registry. Server + * lobbies use WebSocket lobby_update. Never mix: LAN mode wins if set. */ + static bool ae_np_use_lan_members(void) { + return g_lnch_hosting_lan || g_lnch_joined_lan; + } + + static bool ae_np_use_ws_members(void) { + return !ae_np_use_lan_members() && psx_lobby_in_lobby() != 0; + } + + /* Joiner was cleared / file gone → treat as kicked or host left. */ + static void ae_np_poll_lan_joiner_still_seated(void) { + if (!g_lnch_joined_lan) return; + if (g_lnch_remote_lan) { + /* Remote seat cleared by UDP KICK/ERR/UPDATE in pump. */ + return; + } + AeLanLobbyState state; + if (!ae_np_read_lan_state(&state) || state.joiner_name.empty()) { + g_lnch_joined_lan = false; + g_lnch_lan_endpoint.clear(); + return; + } + std::string me = psx_lobby_display_name(); + if (me.empty()) me = "Player"; + if (state.joiner_name != me) { + g_lnch_joined_lan = false; + g_lnch_lan_endpoint.clear(); + } + } + + /* host_endpoint is in/out (capacity >= 64). Online may rewrite the UDP + * port when the requested one is busy. Returns 0 ok, -4 port unavailable. */ + int ae_np_create(void*, const char* lobby_name, char* host_endpoint, const char* password, const RecompLauncherCSettings* settings) { PsxLobbyMatchCaps caps = ae_netplay_caps_from_settings(settings); - const char* endpoint = host_port && host_port[0] ? host_port : "0.0.0.0:7777"; - ae_np_write_lan_lobby(lobby_name, endpoint, password); + char endpoint[96]; + if (host_endpoint && host_endpoint[0]) + std::snprintf(endpoint, sizeof(endpoint), "%s", host_endpoint); + else + std::snprintf(endpoint, sizeof(endpoint), "0.0.0.0:7777"); + const int want_port = ae_np_lan_endpoint_port(endpoint); + + if (!ae_np_endpoint_is_any_bind(endpoint)) { + /* LAN/Direct IP: exact port required — fail if busy. */ + if (psx_lobby_in_lobby()) + (void)psx_lobby_leave(); + if (!ae_np_write_lan_lobby(lobby_name, endpoint, password)) + return -4; + if (host_endpoint) + std::snprintf(host_endpoint, 96, "%s", endpoint); + return 0; + } + + /* Online: auto-pick a free UDP port starting at the requested one. */ + const int free_port = ae_np_find_free_udp_port(want_port); + if (free_port < 0) return -4; + if (free_port != want_port && + !ae_np_endpoint_replace_port(endpoint, sizeof(endpoint), free_port)) { + return -4; + } + if (host_endpoint) + std::snprintf(host_endpoint, 96, "%s", endpoint); + + if (g_lnch_hosting_lan) { + std::error_code ec; + std::filesystem::remove(ae_np_lan_file(), ec); + } + ae_np_lan_udp_close(); + g_lnch_hosting_lan = false; + g_lnch_joined_lan = false; + g_lnch_remote_lan = false; + g_lnch_remote_lan_state = {}; + g_lnch_lan_endpoint.clear(); return psx_lobby_create(lobby_name && lobby_name[0] ? lobby_name : "Netplay Lobby", g_lnch_netplay_game_name.c_str(), PSX_GAME_VERSION, password ? password : "", endpoint, &caps); @@ -4130,54 +4770,157 @@ namespace { int ae_np_join(void*, const char* lobby_id, const char* password) { if (lobby_id && strncmp(lobby_id, "lan:", 4) == 0) { - AeLanLobbyState state; - if (!ae_np_read_lan_state(&state) || !state.joiner_name.empty()) return -1; - if (state.password != (password ? password : "")) return -2; - state.joiner_name = psx_lobby_display_name(); - if (state.joiner_name.empty()) state.joiner_name = "Player"; - state.started = false; - if (!ae_np_write_lan_state(state)) return -1; + const char* endpoint = lobby_id + 4; + if (!endpoint[0]) return -1; + if (psx_lobby_in_lobby()) + (void)psx_lobby_leave(); + + /* Must be a live LAN/Direct IP host (UDP PONG). Online-only hosts + * never answer — refuse so we don't open a fake local room. */ + if (!ae_np_lan_probe_host_ms(endpoint, 750u)) + return -3; + + /* Peek the on-disk registry (ignore any prior remote seat). */ + const bool prior_remote = g_lnch_remote_lan; + g_lnch_remote_lan = false; + AeLanLobbyState file{}; + const bool have_file = ae_np_read_lan_file_state(&file); + g_lnch_remote_lan = prior_remote; + /* Same-machine / shared cwd: claim the local LAN file when the + * endpoint matches. */ + if (have_file && !g_lnch_hosting_lan && file.endpoint == endpoint) { + if (!file.joiner_name.empty()) return -1; + if (file.password != (password ? password : "")) return -2; + g_lnch_remote_lan = false; + g_lnch_remote_lan_state = {}; + file.joiner_name = psx_lobby_display_name(); + if (file.joiner_name.empty()) file.joiner_name = "Player"; + file.started = false; + if (!ae_np_write_lan_state(file)) return -1; + g_lnch_hosting_lan = false; + g_lnch_joined_lan = true; + g_lnch_lan_endpoint = file.endpoint; + return 0; + } + + /* Cross-machine Join Direct: JOIN must be acked before we seat. */ + ae_np_lan_udp_close(); + AeLanLobbyState seated{}; + seated.name = "Direct"; + seated.game = + g_lnch_netplay_game_name.empty() ? "PSX" : g_lnch_netplay_game_name; + seated.endpoint = endpoint; + seated.password = password ? password : ""; + const int ack = ae_np_lan_wait_join_ack(endpoint, password, &seated); + if (ack != 0) { + ae_np_lan_udp_close(); + g_lnch_joined_lan = false; + g_lnch_remote_lan = false; + g_lnch_remote_lan_state = {}; + g_lnch_lan_endpoint.clear(); + return ack; + } + g_lnch_remote_lan = true; + g_lnch_remote_lan_state = seated; + if (g_lnch_remote_lan_state.joiner_name.empty()) { + g_lnch_remote_lan_state.joiner_name = psx_lobby_display_name(); + if (g_lnch_remote_lan_state.joiner_name.empty()) + g_lnch_remote_lan_state.joiner_name = "Player"; + } g_lnch_hosting_lan = false; g_lnch_joined_lan = true; - g_lnch_lan_endpoint = state.endpoint; + g_lnch_lan_endpoint = endpoint; + g_lnch_lan_join_pulse_ms = SDL_GetTicks(); return 0; } + /* Server join: leave any stale LAN-file room mode so membership follows WS. */ + ae_np_lan_udp_close(); + g_lnch_hosting_lan = false; + g_lnch_joined_lan = false; + g_lnch_remote_lan = false; + g_lnch_remote_lan_state = {}; + g_lnch_lan_endpoint.clear(); return psx_lobby_join(lobby_id, password ? password : "", "0.0.0.0:0"); } int ae_np_leave(void*) { if (g_lnch_hosting_lan) { + if (g_lnch_lan_peer_valid) + ae_np_lan_udp_sendto(g_lnch_lan_peer, "MOTK1 KICK\n"); std::error_code ec; std::filesystem::remove(ae_np_lan_file(), ec); g_lnch_hosting_lan = false; } else if (g_lnch_joined_lan) { - AeLanLobbyState state; - if (ae_np_read_lan_state(&state)) { - state.joiner_name.clear(); - state.started = false; - ae_np_write_lan_state(state); + if (g_lnch_remote_lan) { + char host[64]; + if (ae_np_lan_endpoint_host(g_lnch_lan_endpoint, host, sizeof(host)) && + g_lnch_lan_udp != kAeLanSockInvalid) { + sockaddr_in to{}; + to.sin_family = AF_INET; + to.sin_port = + htons((uint16_t)ae_np_lan_endpoint_port(g_lnch_lan_endpoint)); + if (inet_pton(AF_INET, host, &to.sin_addr) == 1) + ae_np_lan_udp_sendto(to, "MOTK1 LEAVE\n"); + } + } else { + AeLanLobbyState state; + if (ae_np_read_lan_state(&state)) { + state.joiner_name.clear(); + state.started = false; + ae_np_write_lan_state(state); + } } } + ae_np_lan_udp_close(); g_lnch_joined_lan = false; + g_lnch_remote_lan = false; + g_lnch_remote_lan_state = {}; g_lnch_lan_endpoint.clear(); g_lnch_pending_direct_launch = {}; return psx_lobby_leave(); } int ae_np_in_lobby(void*) { - return g_lnch_hosting_lan || g_lnch_joined_lan || psx_lobby_in_lobby(); + ae_np_poll_lan_joiner_still_seated(); + if (g_lnch_hosting_lan) { + AeLanLobbyState state; + return ae_np_read_lan_state(&state) ? 1 : 0; + } + if (g_lnch_joined_lan) return 1; + return psx_lobby_in_lobby(); } int ae_np_is_host(void*) { - if (g_lnch_hosting_lan || g_lnch_joined_lan) return g_lnch_hosting_lan ? 1 : 0; + if (ae_np_use_lan_members()) return g_lnch_hosting_lan ? 1 : 0; + if (ae_np_use_ws_members()) return psx_lobby_is_host(); return psx_lobby_is_host(); } int ae_np_member_count(void*) { - if (g_lnch_hosting_lan || g_lnch_joined_lan) return 2; + if (ae_np_use_ws_members()) { + const int n = psx_lobby_member_count(); + return n > 0 ? n : 1; + } + if (ae_np_use_lan_members()) return 2; return psx_lobby_member_count(); } int ae_np_member_get(void*, int index, RecompLauncherCNetplayMember* out) { if (!out) return 0; - if (g_lnch_hosting_lan || g_lnch_joined_lan) { + if (ae_np_use_ws_members()) { + PsxLobbyMember mem{}; + if (!psx_lobby_member_get(index, &mem)) return 0; + out->slot = mem.slot; + std::snprintf(out->display_name, sizeof(out->display_name), "%s", + mem.display_name); + out->ready = mem.ready; + const char* host_id = psx_lobby_host_player_id(); + /* Prefer host_player_id; slot-0 fallback only when unknown (never + * mark a guest as host after a seat swap). */ + if (host_id && host_id[0] && mem.player_id[0]) + out->is_host = (std::strcmp(host_id, mem.player_id) == 0) ? 1 : 0; + else + out->is_host = (mem.slot == 0) ? 1 : 0; + return 1; + } + if (ae_np_use_lan_members()) { if (index < 0 || index > 1) return 0; AeLanLobbyState state; if (!ae_np_read_lan_state(&state)) return 0; @@ -4194,18 +4937,47 @@ namespace { out->slot = mem.slot; std::snprintf(out->display_name, sizeof(out->display_name), "%s", mem.display_name); out->ready = mem.ready; - out->is_host = mem.slot == 0; + const char* host_id = psx_lobby_host_player_id(); + if (host_id && host_id[0] && mem.player_id[0]) + out->is_host = (std::strcmp(host_id, mem.player_id) == 0) ? 1 : 0; + else + out->is_host = (mem.slot == 0) ? 1 : 0; return 1; } int ae_np_move_member(void*, int from_slot, int to_slot) { - if (!g_lnch_hosting_lan || from_slot < 0 || from_slot > 1 || - to_slot < 0 || to_slot > 1 || from_slot == to_slot) return -1; - AeLanLobbyState state; - if (!ae_np_read_lan_state(&state)) return -1; - state.host_slot = 1 - state.host_slot; - state.started = false; - return ae_np_write_lan_state(state) ? 0 : -1; + if (from_slot < 0 || to_slot < 0 || from_slot == to_slot) return -1; + if (g_lnch_hosting_lan && from_slot <= 1 && to_slot <= 1) { + AeLanLobbyState state; + if (!ae_np_read_lan_state(&state)) return -1; + /* Swap which physical seat is "host slot" (P1/P2). */ + state.host_slot = 1 - state.host_slot; + state.started = false; + if (!ae_np_write_lan_state(state)) return -1; + ae_np_lan_send_update_to_peer(state); + return 0; + } + if (ae_np_use_ws_members() && psx_lobby_is_host()) + return psx_lobby_move_member(from_slot, to_slot); + return -1; + } + + int ae_np_kick_member(void*, int slot) { + if (g_lnch_hosting_lan) { + AeLanLobbyState state; + if (!ae_np_read_lan_state(&state)) return -1; + if (slot < 0 || slot > 1 || slot == state.host_slot) return -1; + state.joiner_name.clear(); + state.started = false; + if (!ae_np_write_lan_state(state)) return -1; + if (g_lnch_lan_peer_valid) + ae_np_lan_udp_sendto(g_lnch_lan_peer, "MOTK1 KICK\n"); + g_lnch_lan_peer_valid = false; + return 0; + } + if (ae_np_use_ws_members() && psx_lobby_is_host()) + return psx_lobby_kick(slot); + return -1; } int ae_np_local_ready(void*) { return psx_lobby_local_ready(); } @@ -4217,8 +4989,22 @@ namespace { AeLanLobbyState state; if (!ae_np_read_lan_state(&state) || state.joiner_name.empty()) return -1; state.started = true; - return ae_np_write_lan_state(state) ? 0 : -1; + state.session_id += 1u; + if (state.session_id == 0) state.session_id = 1; + g_lnch_lan_session_id = state.session_id; + if (!ae_np_write_lan_state(state)) return -1; + if (g_lnch_lan_peer_valid) { + ae_np_lan_send_update_to_peer(state); + char start_msg[64]; + std::snprintf(start_msg, sizeof(start_msg), "MOTK1 START\n%u\n", + (unsigned)state.session_id); + ae_np_lan_udp_sendto(g_lnch_lan_peer, start_msg); + } + return 0; } + if (!psx_lobby_is_host()) return -1; + /* Ensure host seat is ready even if pump hasn't run since rematch. */ + (void)psx_lobby_set_ready(1); PsxLobbyMatchCaps caps = ae_netplay_caps_from_settings(settings); return psx_lobby_request_start(&caps); } @@ -4228,12 +5014,15 @@ namespace { !g_lnch_pending_direct_launch.enabled) { AeLanLobbyState state; if (ae_np_read_lan_state(&state) && state.started) { + /* Free the lobby UDP port before delay-sync binds it. */ + ae_np_lan_udp_close(); + g_lnch_lan_session_id = state.session_id ? state.session_id : 1u; g_lnch_pending_direct_launch = {}; g_lnch_pending_direct_launch.enabled = 1; g_lnch_pending_direct_launch.local_slot = g_lnch_hosting_lan ? state.host_slot : 1 - state.host_slot; g_lnch_pending_direct_launch.input_player = 0; - g_lnch_pending_direct_launch.session_id = 1; + g_lnch_pending_direct_launch.session_id = g_lnch_lan_session_id; g_lnch_pending_direct_launch.input_delay = 2; if (g_lnch_hosting_lan) { const size_t colon = state.endpoint.rfind(':'); @@ -4258,6 +5047,33 @@ namespace { psx_lobby_clear_launch_pending(); } + /* After a match soft-exit: keep seats, clear started/ready, rebind LAN UDP. */ + void ae_np_prepare_lobby_rematch(void) { + g_lnch_pending_direct_launch = {}; + psx_lobby_set_ready(0); + psx_lobby_clear_launch_pending(); + if (!(g_lnch_hosting_lan || g_lnch_joined_lan)) return; + AeLanLobbyState st; + if (ae_np_read_lan_state(&st)) { + st.started = false; + (void)ae_np_write_lan_state(st); + } + if (g_lnch_hosting_lan && !g_lnch_lan_endpoint.empty()) { + ae_np_lan_udp_close(); + (void)ae_np_lan_udp_ensure(true, ae_np_lan_endpoint_port(g_lnch_lan_endpoint)); + if (g_lnch_lan_peer_valid && ae_np_read_lan_state(&st)) + ae_np_lan_send_update_to_peer(st); + } else if (g_lnch_remote_lan) { + ae_np_lan_udp_close(); + (void)ae_np_lan_udp_ensure(false, 0); + g_lnch_lan_join_pulse_ms = 0; + } + } + + const char* ae_np_lan_endpoint_cstr(void) { + return g_lnch_lan_endpoint.empty() ? nullptr : g_lnch_lan_endpoint.c_str(); + } + int ae_np_fill_launch(void*, RecompLauncherCNetplayLaunch* out) { if (!out) return 0; if (g_lnch_pending_direct_launch.enabled) { @@ -4306,7 +5122,8 @@ namespace { ae_np_launch_pending, ae_np_clear_launch_pending, ae_np_fill_launch, - ae_np_list_lan_ips, + ae_np_local_address_get, + ae_np_kick_member, }; } // namespace #endif @@ -6180,6 +6997,8 @@ int main(int argc, char** argv) { /* Delay-sync: do not free-run boot while HELLO/START is in flight. * Park until tick 0 pads are published, then enter the guest. */ if (psx_netplay_active()) { + SDL_PumpEvents(); + SDL_FlushEvent(SDL_QUIT); std::printf("psxrecomp: netplay waiting for peer START + tick-0 admit…\n"); std::fflush(stdout); netplay_barrier_admit(-1); @@ -6266,9 +7085,113 @@ int main(int argc, char** argv) { return 0; soft_return_lobby: - /* Netplay soft-exit: tear down the match window. Lobby resume will be - * restored through recomp-ui. */ + /* Netplay soft-exit: tear down the match, keep the lobby seat, and reopen + * the launcher on the LOBBY room so every peer can rematch. */ teardown_game_session_keep_lobby(); +#if defined(RECOMP_LAUNCHER) && defined(PSX_HAS_LOBBY_CLIENT) + ae_np_prepare_lobby_rematch(); + { + std::string assets_dir_str = exe_dir_from_argv(argv[0]).string(); + std::string rui_title = (game_name.empty() ? std::string("PSX") : game_name) + + " \xE2\x80\x94 Launcher"; + std::string rui_initial_disc = disc_path_str; + + RecompLauncherCSettings ls{}; + ls.output_method = 2; + ls.window_scale = std::max(1, std::min(4, g_video_win_w / 320)); + ls.fullscreen = g_fullscreen ? 1 : 0; + ls.enable_audio = 1; + ls.audio_freq = 44100; + ls.volume = 100; + ls.window_width = g_video_win_w; + ls.renderer = g_video_renderer; + ls.supersampling = g_video_scale; + ls.antialiasing = g_video_aa ? 1 : 0; + ls.texture_filter = g_video_texfilter; + ls.screen_kind = g_video_screen; + ls.frame_interp = g_frame_interpolation ? 1 : 0; + ls.frame_interp_fps = g_frame_interpolation_fps; + ls.spu_hq = g_audio_spu_hq ? 1 : 0; + ls.auto_skip_fmv = g_auto_skip_fmv ? 1 : 0; + ls.turbo_loads = g_turbo_loads_enabled ? 1 : 0; + ls.aspect_index = (g_video_aspect_num * 9 == g_video_aspect_den * 21) ? 2 + : (g_video_aspect_num == 16 && g_video_aspect_den == 9) ? 1 : 0; + std::snprintf(ls.netplay_player_name, sizeof(ls.netplay_player_name), "%s", + psx_lobby_display_name()); + std::snprintf(ls.bios_path, sizeof(ls.bios_path), "%s", bios_path_str.c_str()); + + RecompLauncherCGameInfo gi{}; + launcher_profile_apply("psx", &gi); + gi.name = game_name.empty() ? nullptr : game_name.c_str(); + gi.num_players = game_players; + gi.netplay_supported = 1; + gi.netplay = &g_lnch_netplay_callbacks; + gi.resume_netplay_room = 1; + gi.resume_netplay_endpoint = ae_np_lan_endpoint_cstr(); + gi.disc_verify = ae_disc_verify; + gi.memcard_inspect = ae_memcard_inspect; + + char rui_out_disc[1024] = {0}; + const int rui_rc = recomp_launcher_run_window( + rui_title.c_str(), &ls, &gi, assets_dir_str.c_str(), + rui_initial_disc.c_str(), rui_out_disc, sizeof(rui_out_disc)); + + if (rui_rc == 1) { + /* User closed the launcher — leave the lobby and exit. */ + if (g_lnch_netplay_callbacks.leave) + (void)g_lnch_netplay_callbacks.leave(g_lnch_netplay_callbacks.ctx); + else + (void)psx_lobby_leave(); + psx_lobby_disconnect(); + SDL_Quit(); + return 0; + } + + if (rui_rc == 0) { + if (rui_out_disc[0]) { + resolved_disc = normalize_disc_path_for_launch(rui_out_disc); + disc_path_str = resolved_disc.string(); + } + if (ls.netplay_launch.enabled) { + net_cfg = {}; + net_cfg.enabled = 1; + net_cfg.local_slot = ls.netplay_launch.local_slot; + net_cfg.input_player = ls.netplay_launch.input_player; + net_cfg.session_id = ls.netplay_launch.session_id; + net_cfg.input_delay = ls.netplay_launch.input_delay; + std::snprintf(net_cfg.bind_hostport, sizeof(net_cfg.bind_hostport), "%s", + ls.netplay_launch.bind_hostport); + std::snprintf(net_cfg.peer_hostport, sizeof(net_cfg.peer_hostport), "%s", + ls.netplay_launch.peer_hostport); + g_netplay_from_lobby = 1; + } else { + net_cfg = {}; + g_netplay_from_lobby = 0; + } + g_video_renderer = ls.renderer; + g_video_scale = ls.supersampling; + g_video_aa = ls.antialiasing; + g_video_texfilter = ls.texture_filter; + g_video_screen = ls.screen_kind; + g_auto_skip_fmv = ls.auto_skip_fmv ? 1 : 0; + g_turbo_loads_enabled = ls.turbo_loads ? 1 : 0; + g_fullscreen = ls.fullscreen != 0; + g_frame_interpolation = ls.frame_interp ? 1 : 0; + g_frame_interpolation_fps = ls.frame_interp_fps; + g_audio_spu_hq = ls.spu_hq != 0; + switch (ls.aspect_index) { + case 2: g_video_aspect_num = 21; g_video_aspect_den = 9; break; + case 1: g_video_aspect_num = 16; g_video_aspect_den = 9; break; + default: g_video_aspect_num = 4; g_video_aspect_den = 3; break; + } + g_video_win_w = ls.window_width > 0 ? ls.window_width : g_video_win_w; + std::printf("psxrecomp: rematch from lobby (netplay=%d)\n", + net_cfg.enabled ? 1 : 0); + std::fflush(stdout); + goto session_reboot; + } + } +#endif psx_lobby_disconnect(); SDL_Quit(); return 0; diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index 6af3ba3d6..151da476f 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -31,8 +31,12 @@ int psx_lobby_create(const char *a, const char *b, const char *c, const char *d int psx_lobby_join(const char *a, const char *b, const char *c) { (void)a; (void)b; (void)c; return -1; } int psx_lobby_leave(void) { return -1; } +int psx_lobby_kick(int slot) { (void)slot; return -1; } +int psx_lobby_move_member(int from_slot, int to_slot) +{ (void)from_slot; (void)to_slot; return -1; } int psx_lobby_in_lobby(void) { return 0; } int psx_lobby_is_host(void) { return 0; } +const char *psx_lobby_host_player_id(void) { return ""; } const PsxLobbyJoinInfo *psx_lobby_join_info(void) { static PsxLobbyJoinInfo z; @@ -89,6 +93,7 @@ typedef struct { int list_count; int in_lobby; int is_host; + char host_player_id[PSX_LOBBY_ID_LEN]; char my_bind[PSX_LOBBY_ENDPOINT_LEN]; char filter_game_name[PSX_LOBBY_NAME_LEN]; char filter_game_version[PSX_LOBBY_VERSION_LEN]; @@ -387,6 +392,15 @@ static void flush_pending(void) g_lc.pending_n = 0; } +static int endpoint_port_is_zero(const char *ep) +{ + const char *colon; + if (!ep || !ep[0]) return 1; + colon = strrchr(ep, ':'); + if (!colon || !colon[1]) return 1; + return (int)strtoul(colon + 1, NULL, 10) == 0; +} + static void fill_peer_bind_from_join(void) { PsxLobbyJoinInfo *j = &g_lc.join; @@ -394,7 +408,12 @@ static void fill_peer_bind_from_join(void) memset(j->peer_hostport, 0, sizeof(j->peer_hostport)); if (g_lc.is_host) { strncpy(j->bind_hostport, g_lc.my_bind, sizeof(j->bind_hostport) - 1); - strncpy(j->peer_hostport, j->guest_endpoint, sizeof(j->peer_hostport) - 1); + /* Online guests join with 0.0.0.0:0 (ephemeral). The lobby rewrites + * that to peer_ip:0, which rnet rejects as a dial target. Leave peer + * empty so the host learns the guest from the first HELLO (guest + * dials host_endpoint). Fixed guest ports still dial normally. */ + if (j->guest_endpoint[0] && !endpoint_port_is_zero(j->guest_endpoint)) + strncpy(j->peer_hostport, j->guest_endpoint, sizeof(j->peer_hostport) - 1); } else { strncpy(j->bind_hostport, g_lc.my_bind, sizeof(j->bind_hostport) - 1); strncpy(j->peer_hostport, j->host_endpoint, sizeof(j->peer_hostport) - 1); @@ -458,6 +477,7 @@ static void parse_slots_array(const char *json) if (g_lc.player_id[0] && strcmp(g_lc.members[n].player_id, g_lc.player_id) == 0) { g_lc.local_ready = g_lc.members[n].ready; + g_lc.join.local_slot = g_lc.members[n].slot; } ++n; p = end; @@ -467,6 +487,17 @@ static void parse_slots_array(const char *json) g_lc.member_count = n; } +static void ingest_host_player_id(const char *json) +{ + char host_id[PSX_LOBBY_ID_LEN]; + host_id[0] = '\0'; + json_get_str(json, "host_player_id", host_id, sizeof(host_id)); + if (host_id[0]) { + strncpy(g_lc.host_player_id, host_id, sizeof(g_lc.host_player_id) - 1); + g_lc.host_player_id[sizeof(g_lc.host_player_id) - 1] = '\0'; + } +} + static void handle_server_json(const char *json); /* Parse complete unmasked server text frames from ws_pending; leave remainder. */ @@ -620,6 +651,11 @@ static void handle_server_json(const char *json) g_lc.join.player_count = 1; g_lc.join.max_slots = 2; g_lc.join.last_error[0] = '\0'; + if (g_lc.player_id[0]) { + strncpy(g_lc.host_player_id, g_lc.player_id, sizeof(g_lc.host_player_id) - 1); + g_lc.host_player_id[sizeof(g_lc.host_player_id) - 1] = '\0'; + } + ingest_host_player_id(json); ingest_match_caps_from_json(json); fill_peer_bind_from_join(); parse_slots_array(json); @@ -645,20 +681,28 @@ static void handle_server_json(const char *json) g_lc.join.local_slot = json_get_int(json, "local_slot", 1); json_get_str(json, "host_endpoint", g_lc.join.host_endpoint, sizeof(g_lc.join.host_endpoint)); json_get_str(json, "guest_endpoint", g_lc.join.guest_endpoint, sizeof(g_lc.join.guest_endpoint)); - g_lc.join.player_count = 2; - g_lc.join.max_slots = 2; + g_lc.join.player_count = json_get_int(json, "player_count", 2); + g_lc.join.max_slots = json_get_int(json, "max_slots", 2); g_lc.join.last_error[0] = '\0'; + ingest_host_player_id(json); ingest_match_caps_from_json(json); fill_peer_bind_from_join(); + /* Prefer slots on joined when present; lobby_update usually follows. */ + parse_slots_array(json); return; } if (strcmp(op, "lobby_update") == 0) { + g_lc.in_lobby = 1; json_get_str(json, "host_endpoint", g_lc.join.host_endpoint, sizeof(g_lc.join.host_endpoint)); json_get_str(json, "guest_endpoint", g_lc.join.guest_endpoint, sizeof(g_lc.join.guest_endpoint)); g_lc.join.player_count = json_get_int(json, "player_count", g_lc.join.player_count); g_lc.join.max_slots = json_get_int(json, "max_slots", g_lc.join.max_slots); g_lc.join.session_id = (uint32_t)json_get_int(json, "session_id", (int)g_lc.join.session_id); g_lc.all_ready = json_get_bool(json, "all_ready", 0); + ingest_host_player_id(json); + if (g_lc.host_player_id[0] && g_lc.player_id[0]) { + g_lc.is_host = (strcmp(g_lc.host_player_id, g_lc.player_id) == 0); + } ingest_match_caps_from_json(json); fill_peer_bind_from_join(); parse_slots_array(json); @@ -673,9 +717,12 @@ static void handle_server_json(const char *json) ingest_match_caps_from_json(json); fill_peer_bind_from_join(); parse_slots_array(json); - /* Rematch/join without a peer endpoint would hang forever in HELLO. */ - if (!g_lc.join.peer_hostport[0] || !g_lc.join.host_endpoint[0] || - (g_lc.is_host && !g_lc.join.guest_endpoint[0])) { + /* Guest must know host:port. Host may leave peer empty (accept-first) + * when the guest advertised an ephemeral :0 bind. */ + if (!g_lc.join.host_endpoint[0] || + (g_lc.is_host && !g_lc.join.guest_endpoint[0]) || + (!g_lc.is_host && (!g_lc.join.peer_hostport[0] || + endpoint_port_is_zero(g_lc.join.peer_hostport)))) { strncpy(g_lc.join.last_error, "missing_endpoints", sizeof(g_lc.join.last_error) - 1); g_lc.launch_pending = 0; @@ -686,13 +733,30 @@ static void handle_server_json(const char *json) return; } if (strcmp(op, "error") == 0) { - json_get_str(json, "code", g_lc.join.last_error, sizeof(g_lc.join.last_error)); - g_lc.join.ok = 0; + char code[64]; + json_get_str(json, "code", code, sizeof(code)); + strncpy(g_lc.join.last_error, code, sizeof(g_lc.join.last_error) - 1); + g_lc.join.last_error[sizeof(g_lc.join.last_error) - 1] = '\0'; + /* Create/join failures are fatal to the seat. In-lobby ops (kick/move + * on an older server, not_host, …) must not clear join.ok or the room + * looks abandoned after a rejected host action. */ + if (!g_lc.in_lobby || + strcmp(code, "bad_password") == 0 || + strcmp(code, "full") == 0 || + strcmp(code, "gone") == 0 || + strcmp(code, "already_in_lobby") == 0 || + strcmp(code, "lobby_limit") == 0 || + strcmp(code, "version_mismatch") == 0 || + strcmp(code, "game_mismatch") == 0) { + g_lc.join.ok = 0; + } return; } - if (strcmp(op, "lobby_closed") == 0 || strcmp(op, "left") == 0) { + if (strcmp(op, "lobby_closed") == 0 || strcmp(op, "left") == 0 || + strcmp(op, "kicked") == 0) { g_lc.in_lobby = 0; g_lc.is_host = 0; + g_lc.host_player_id[0] = '\0'; g_lc.member_count = 0; g_lc.local_ready = 0; g_lc.all_ready = 0; @@ -1051,6 +1115,7 @@ int psx_lobby_leave(void) flush_pending(); g_lc.in_lobby = 0; g_lc.is_host = 0; + g_lc.host_player_id[0] = '\0'; g_lc.member_count = 0; g_lc.local_ready = 0; g_lc.all_ready = 0; @@ -1059,6 +1124,40 @@ int psx_lobby_leave(void) return 0; } +int psx_lobby_kick(int slot) +{ + char msg[64]; + if (!psx_lobby_connected() || !g_lc.in_lobby || !g_lc.is_host) { + return -1; + } + if (slot < 0 || slot >= PSX_LOBBY_MAX_MEMBERS) { + return -1; + } + snprintf(msg, sizeof(msg), "{\"op\":\"kick\",\"slot\":%d}", slot); + queue_send(msg); + flush_pending(); + return 0; +} + +int psx_lobby_move_member(int from_slot, int to_slot) +{ + char msg[96]; + if (!psx_lobby_connected() || !g_lc.in_lobby || !g_lc.is_host) { + return -1; + } + if (from_slot < 0 || from_slot >= PSX_LOBBY_MAX_MEMBERS || + to_slot < 0 || to_slot >= PSX_LOBBY_MAX_MEMBERS || + from_slot == to_slot) { + return -1; + } + snprintf(msg, sizeof(msg), + "{\"op\":\"move\",\"from_slot\":%d,\"to_slot\":%d}", + from_slot, to_slot); + queue_send(msg); + flush_pending(); + return 0; +} + int psx_lobby_in_lobby(void) { return g_lc.in_lobby; @@ -1069,6 +1168,11 @@ int psx_lobby_is_host(void) return g_lc.is_host; } +const char *psx_lobby_host_player_id(void) +{ + return g_lc.host_player_id; +} + const PsxLobbyJoinInfo *psx_lobby_join_info(void) { return &g_lc.join; diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index e9089fbe6..0fe896ea3 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -877,7 +877,8 @@ int psx_netplay_input_desync(uint32_t *tick, uint32_t *local_hash, uint32_t *rem int psx_netplay_peer_disconnected(uint32_t timeout_ms) { if (!psx_netplay_active()) return 0; - if (timeout_ms == 0) timeout_ms = 1500u; + /* timeout_ms == 0: BYE / peer_gone only (no silence timeout). Used during + * load barriers where INPUT is suppressed for seconds. */ return rnet_session_peer_disconnected(g_np.session, (rnet_u64)timeout_ms); } From 401ec6cc836da1609faf5c704c68e4c6bd801f5d Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Wed, 22 Jul 2026 22:33:39 -0400 Subject: [PATCH 06/38] bump --- .gitmodules | 1 + lib/recomp-ui | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index 4878401c8..e8564d501 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,4 @@ [submodule "lib/recomp-ui"] path = lib/recomp-ui url = https://github.com/mstan/recomp-ui.git + branch = master diff --git a/lib/recomp-ui b/lib/recomp-ui index 2a37e3c23..119ce601c 160000 --- a/lib/recomp-ui +++ b/lib/recomp-ui @@ -1 +1 @@ -Subproject commit 2a37e3c2375061e030169b66c701dedde8a3d196 +Subproject commit 119ce601c0127e724ba7bf11298350f754384207 From 65d254cc9b069cc9cb5e9379a74d581ae4d741e1 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Wed, 22 Jul 2026 23:09:00 -0400 Subject: [PATCH 07/38] Add MAX_PLAYERS build flag, multitap, and N-slot netplay wiring. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Games pass MAX_PLAYERS (2..5, default 2) to bake PSX_MAX_PLAYERS. SCPH-1070 multitap maps pads 0–3 on port 1 and pad 4 on port 2. Lobby max_slots and recomp-net slot_count follow the game player count; MotK stays at 2. Co-authored-by: Cursor --- lib/recomp-net | 2 +- lib/recomp-ui | 2 +- runtime/include/psx_lobby_client.h | 5 +- runtime/include/psx_netplay.h | 12 +- runtime/include/sio.h | 47 ++-- runtime/runtime.cmake | 18 ++ runtime/src/main.cpp | 118 +++++++--- runtime/src/psx_lobby_client.c | 17 +- runtime/src/psx_netplay.c | 70 ++++-- runtime/src/sio.c | 342 ++++++++++++++++++++--------- 10 files changed, 465 insertions(+), 168 deletions(-) diff --git a/lib/recomp-net b/lib/recomp-net index 1dc4fe0d4..6a2c7b866 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit 1dc4fe0d4891ba6895e5b057c0976f0b56fe707a +Subproject commit 6a2c7b866c4a1a80e8ce15ad4bef0bcadea9d8ab diff --git a/lib/recomp-ui b/lib/recomp-ui index 119ce601c..298d8e652 160000 --- a/lib/recomp-ui +++ b/lib/recomp-ui @@ -1 +1 @@ -Subproject commit 119ce601c0127e724ba7bf11298350f754384207 +Subproject commit 298d8e65282c14f2cd5564d375a74225c4dfe27a diff --git a/runtime/include/psx_lobby_client.h b/runtime/include/psx_lobby_client.h index fdd62a681..85445e0fe 100644 --- a/runtime/include/psx_lobby_client.h +++ b/runtime/include/psx_lobby_client.h @@ -12,7 +12,7 @@ extern "C" { #define PSX_LOBBY_VERSION_LEN 32 #define PSX_LOBBY_ENDPOINT_LEN 64 #define PSX_LOBBY_MAX_LIST 32 -#define PSX_LOBBY_MAX_MEMBERS 4 +#define PSX_LOBBY_MAX_MEMBERS 5 #define PSX_LOBBY_LANG_LEN 16 #ifndef PSX_GAME_VERSION @@ -90,6 +90,9 @@ void psx_lobby_pump(void); void psx_lobby_set_game_identity(const char *game_name, const char *game_version); const char *psx_lobby_game_version(void); +/* Default max_slots for create (clamped 2..5, default 2). */ +void psx_lobby_set_max_slots(int max_slots); + void psx_lobby_request_list(void); int psx_lobby_list_count(void); int psx_lobby_list_get(int index, PsxLobbyRow *out); diff --git a/runtime/include/psx_netplay.h b/runtime/include/psx_netplay.h index 8f8a0344b..dce467149 100644 --- a/runtime/include/psx_netplay.h +++ b/runtime/include/psx_netplay.h @@ -20,10 +20,11 @@ extern "C" { * - Each peer stages one local device sample; recomp-net maps it onto * that peer's local_slot (slot 0 = sim P1, slot 1 = sim P2). Transport * host/client role is determined by peer_hostport, not by local_slot. - * - input_player selects which host PlayerInput (0/1) to sample; -1 = auto + * - input_player selects which host PlayerInput to sample; -1 = auto * (prefer g_players[local_slot] if assigned, else player 0). * - While active, publish / release_pads is the sole SIO writer. - * - Every session slot stays plugged for in-game 2P/VS detect. + * - Every session slot stays plugged for in-game N-player detect. + * - slot_count >= 3 enables SCPH-1070 multitap (sio_set_multitap). * * Pad blob (8 bytes): * [0..1] buttons LE u16 (PSX active-low) @@ -43,8 +44,9 @@ typedef struct PsxNetPad { typedef struct PsxNetplayConfig { int enabled; - int local_slot; /* 0 or 1 */ - int input_player; /* 0/1 host device index; -1 = auto */ + int local_slot; /* 0 .. slot_count-1 */ + int slot_count; /* 2 .. PSX_MAX_PLAYERS (session pad count) */ + int input_player; /* host device index; -1 = auto */ int input_delay; uint32_t session_id; char bind_hostport[64]; @@ -57,7 +59,7 @@ void psx_netplay_apply_env(PsxNetplayConfig *cfg); int psx_netplay_active(void); int psx_netplay_is_running(void); int psx_netplay_local_slot(void); -/* Resolved host player index (0/1) used for local capture. */ +/* Resolved host player index used for local capture. */ int psx_netplay_input_player(void); uint32_t psx_netplay_sim_tick(void); diff --git a/runtime/include/sio.h b/runtime/include/sio.h index 7363a018d..7ecf8dd55 100644 --- a/runtime/include/sio.h +++ b/runtime/include/sio.h @@ -7,6 +7,16 @@ extern "C" { #endif +/* Logical pad count. Default 2 (port1=pad0, port2=pad1). With multitap + * (sio_set_multitap) and PSX_MAX_PLAYERS>=5: port1 hosts SCPH-1070 pads + * A–D as logical 0–3, port2 is logical pad 4. Absolute max for this pass. */ +#ifndef PSX_MAX_PLAYERS +#define PSX_MAX_PLAYERS 2 +#endif +#if PSX_MAX_PLAYERS < 1 || PSX_MAX_PLAYERS > 5 +#error "PSX_MAX_PLAYERS must be in 1..5" +#endif + /* SIO0 register base: 0x1F801040 */ #define SIO_BASE 0x1F801040 @@ -57,14 +67,22 @@ uint32_t sio_cycles_to_irq(uint32_t i_mask); uint64_t sio_get_advance_called(void); uint64_t sio_get_advance_with_work(void); +/* SCPH-1070 multitap on physical port 1 (SIO slot bit 0). Off by default. + * When enabled (and PSX_MAX_PLAYERS>=5): port1 bulk-polls logical pads 0–3; + * port2 is a single pad at logical index 4. When disabled / MAX==2: today's + * mapping (port1=pad0, port2=pad1). */ +void sio_set_multitap(int enabled); +int sio_get_multitap(void); + /* Update pad button state. Buttons use PS1 convention: 0=pressed, 1=released. Bit layout: SELECT, L3, R3, START, UP, RIGHT, DOWN, LEFT, L2, R2, L1, R1, TRIANGLE, CIRCLE, CROSS, SQUARE - sio_set_pad_state targets port 1 (slot 0); the _slot form targets either. */ + sio_set_pad_state targets logical pad 0; the _slot form targets + logical pad 0 .. PSX_MAX_PLAYERS-1. */ void sio_set_pad_state(uint16_t buttons); void sio_set_pad_state_slot(int slot, uint16_t buttons); -/* Set the analog stick state + pad type for a slot. enabled selects the +/* Set the analog stick state + pad type for a logical pad. enabled selects the * emulated pad: 0 = digital (poll id 0x41), 1 = DualShock/analog (poll id * 0x73, with the four 0..255 stick axes appended; 0x80 = centred). */ void sio_set_pad_analog(int slot, int enabled, @@ -79,22 +97,23 @@ void sio_set_pad_analog(int slot, int enabled, void sio_set_pad_sticks(int slot, uint8_t lx, uint8_t ly, uint8_t rx, uint8_t ry); void sio_request_pad_type(int slot, int analog); -/* Connect / disconnect a pad on a slot (0=port1, 1=port2). By default no pads - * are connected during initial BIOS boot. */ +/* Connect / disconnect a logical pad (0 .. PSX_MAX_PLAYERS-1). By default no + * pads are connected during initial BIOS boot. */ void sio_connect_pad(int slot); void sio_set_pad_connected(int slot, int connected); -/* Declare whether the pad on a slot is a config-capable DualShock (1) or a - * plain digital controller (0). A real digital controller (SCPH-1080, poll id - * 0x41) does NOT answer the config-mode commands (0x43/0x44/0x45/0x46/0x47/ - * 0x4C/0x4D/0x4F) — it returns hi-z / no ACK, so a game's pad driver classifies - * it as digital-only and just polls with 0x42. A DualShock answers them. Set - * from the per-player pad mode (DIGITAL => 0, ANALOG/HYBRID => 1) at boot/ - * hotplug. Default is 1 (config-capable) so existing analog/hybrid behaviour is - * unchanged. */ +/* Declare whether the pad on a logical slot is a config-capable DualShock (1) + * or a plain digital controller (0). A real digital controller (SCPH-1080, + * poll id 0x41) does NOT answer the config-mode commands (0x43/0x44/0x45/ + * 0x46/0x47/0x4C/0x4D/0x4F) — it returns hi-z / no ACK, so a game's pad + * driver classifies it as digital-only and just polls with 0x42. A DualShock + * answers them. Set from the per-player pad mode (DIGITAL => 0, ANALOG/HYBRID + * => 1) at boot/hotplug. Default is 1 (config-capable) so existing + * analog/hybrid behaviour is unchanged. */ void sio_set_pad_config_capable(int slot, int capable); -/* Return current pad button state (for debug server). _slot targets either. */ +/* Return current pad button state (for debug server). _slot targets a logical + * pad 0 .. PSX_MAX_PLAYERS-1. */ uint16_t sio_get_pad_buttons(void); uint16_t sio_get_pad_buttons_slot(int slot); @@ -106,7 +125,7 @@ uint16_t sio_peek_stat(void); uint16_t sio_peek_ctrl(void); uint8_t sio_peek_rx_data(void); -/* Debug accessors: is a pad connected on the slot, and is it in analog mode. */ +/* Debug accessors: is a logical pad connected, and is it in analog mode. */ int sio_get_pad_connected(int slot); int sio_get_pad_analog(int slot); void sio_get_pad_sticks(int slot, uint8_t out[4]); diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 5f74eb10d..6a6e5f80e 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -282,6 +282,7 @@ function(psxrecomp_add_runtime_target target) LAUNCHER_BOXART EXE_NAME GAME_VERSION + MAX_PLAYERS ) # GAME_GENERATED_FULL_C is a list (not a single value): the split-TU build # writes the recompiled game as N full_NN.c shards instead of one @@ -473,6 +474,22 @@ function(psxrecomp_add_runtime_target target) endif() endif() + # Per-game netplay/local pad ceiling. Default 2 (MotK / dual-shock path). + # Games that need multitap N-player (e.g. Bomberman Party Edition) pass + # MAX_PLAYERS 5. Clamped to the framework absolute max of 5. + if(NOT PSXRT_MAX_PLAYERS) + if(DEFINED PSX_MAX_PLAYERS AND NOT PSX_MAX_PLAYERS STREQUAL "") + set(PSXRT_MAX_PLAYERS "${PSX_MAX_PLAYERS}") + else() + set(PSXRT_MAX_PLAYERS 2) + endif() + endif() + if(PSXRT_MAX_PLAYERS LESS 2 OR PSXRT_MAX_PLAYERS GREATER 5) + message(FATAL_ERROR + "MAX_PLAYERS must be in 2..5 (got ${PSXRT_MAX_PLAYERS})") + endif() + message(STATUS "psxrecomp ${target}: PSX_MAX_PLAYERS=${PSXRT_MAX_PLAYERS}") + target_compile_definitions(${target} PRIVATE DEFAULT_DEBUG_PORT=${PSXRT_DEBUG_PORT} PSX_DEFAULT_BIOS_PATH="${PSXRT_DEFAULT_BIOS_PATH}" @@ -480,6 +497,7 @@ function(psxrecomp_add_runtime_target target) PSX_WINDOW_TITLE="${PSXRT_WINDOW_TITLE}" PSX_BUILD_REV="${PSX_GIT_REV}" PSX_GAME_VERSION="${PSXRT_GAME_VERSION}" + PSX_MAX_PLAYERS=${PSXRT_MAX_PLAYERS} FMT_HEADER_ONLY=1 $<$:SDL_MAIN_HANDLED> ) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index fc557f663..c733f8f2b 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -35,6 +35,9 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "frame_pacing.h" #include "latency_ring.h" #include "sio.h" +#ifndef PSX_MAX_PLAYERS +#define PSX_MAX_PLAYERS 2 +#endif #include "psx_netplay.h" #include "psx_lobby_client.h" #include "spu.h" @@ -294,7 +297,9 @@ struct PlayerInput { SDL_GameController* handle = nullptr; SDL_JoystickID instance = -1; }; -static PlayerInput g_players[2]; +static PlayerInput g_players[PSX_MAX_PLAYERS]; +/* Offline SIO sample loop bound (from game.toml players; clamped). */ +static int g_offline_pad_count = 2; /* ARGB8888 staging buffer. Sized for the active internal resolution: * 640*scale x 512*scale. Allocated once the supersampling scale is known * (sized for the native 640x512 when supersampling is off). */ @@ -2116,15 +2121,23 @@ static void close_player(PlayerInput& p) { } static void close_controller(void) { - close_player(g_players[0]); - close_player(g_players[1]); + for (int s = 0; s < PSX_MAX_PLAYERS; s++) + close_player(g_players[s]); +} + +static int device_claimed_by_other(int self_slot, SDL_JoystickID inst) { + for (int o = 0; o < PSX_MAX_PLAYERS; o++) { + if (o == self_slot) continue; + if (g_players[o].handle && g_players[o].instance == inst) return 1; + } + return 0; } /* Open the SDL controller whose GUID matches p.guid. If no exact GUID match * exists (e.g. a different physical unit of the same model, or Steam's virtual * pad at an unpredictable slot), fall back to the first controller not already - * claimed by the other player. */ -static void open_player(PlayerInput& p, const PlayerInput& other) { + * claimed by another player. */ +static void open_player(PlayerInput& p, int self_slot) { if (p.kind != 2 || p.handle) return; int chosen = -1, fallback = -1; @@ -2134,9 +2147,9 @@ static void open_player(PlayerInput& p, const PlayerInput& other) { SDL_JoystickGUID g = SDL_JoystickGetDeviceGUID(i); char buf[40] = {0}; SDL_JoystickGetGUIDString(g, buf, sizeof(buf)); - /* Skip a device already opened by the other player. */ + /* Skip a device already opened by another player. */ SDL_JoystickID inst = SDL_JoystickGetDeviceInstanceID(i); - if (other.handle && other.instance == inst) continue; + if (device_claimed_by_other(self_slot, inst)) continue; if (p.guid[0] && std::strcmp(buf, p.guid) == 0) { chosen = i; break; } if (fallback < 0) fallback = i; } @@ -2170,10 +2183,10 @@ static int pad_mode_boot_analog(int mode) { * psx_netplay (session slots stay plugged); only refresh host SDL handles. */ static void refresh_player_devices(void) { const int netplay = psx_netplay_active(); - for (int s = 0; s < 2; s++) { + for (int s = 0; s < PSX_MAX_PLAYERS; s++) { PlayerInput& p = g_players[s]; if (p.kind != 2) close_player(p); /* keyboard/none: no handle */ - else open_player(p, g_players[s ^ 1]); + else open_player(p, s); if (netplay) continue; sio_set_pad_connected(s, p.kind != 0 ? 1 : 0); sio_set_pad_analog(s, pad_mode_boot_analog(p.mode), 0x80, 0x80, 0x80, 0x80); @@ -2634,9 +2647,9 @@ static void apply_pad_slot_to_sio(int s, const PsxNetPad& pad) { * capture — no keyboard-all / all-controllers merge — so peers hash-agree. */ static void capture_local_human_pad(PsxNetPad* out) { int idx = psx_netplay_input_player(); - if (idx < 0 || idx > 1) idx = 0; + if (idx < 0 || idx >= PSX_MAX_PLAYERS) idx = 0; if (!capture_pad_slot_exclusive(idx, out)) { - /* Fallback: if auto picked empty P2, try P1 (two-machine guest). */ + /* Fallback: if auto picked empty local slot, try P1 (two-machine guest). */ if (idx != 0 && capture_pad_slot_exclusive(0, out)) { out->connected = 1; psx_netplay_normalize_pad(out); @@ -2803,7 +2816,10 @@ static void sample_pad_into_sio(int override) { apply_input_override_to_sio(override); return; } - for (int s = 0; s < 2; s++) { + int n = g_offline_pad_count; + if (n < 1) n = 1; + if (n > PSX_MAX_PLAYERS) n = PSX_MAX_PLAYERS; + for (int s = 0; s < n; s++) { PsxNetPad pad; if (!capture_pad_slot(s, &pad)) continue; /* no device in this port */ /* Push sticks every frame; request the pad type (digital/analog) through @@ -3148,8 +3164,11 @@ static void sdl_vblank_present(void) { } else if (ev.type == SDL_CONTROLLERDEVICEADDED) { refresh_player_devices(); } else if (ev.type == SDL_CONTROLLERDEVICEREMOVED) { - if (ev.cdevice.which == g_players[0].instance || - ev.cdevice.which == g_players[1].instance) { + bool ours = false; + for (int s = 0; s < PSX_MAX_PLAYERS; s++) { + if (ev.cdevice.which == g_players[s].instance) { ours = true; break; } + } + if (ours) { close_controller(); refresh_player_devices(); } @@ -3806,6 +3825,7 @@ namespace { } std::string g_lnch_netplay_game_name; + int g_lnch_game_players = 2; /* from game.toml; lobby max_slots default */ std::filesystem::path g_lnch_settings_path; std::string g_lnch_lobby_url; RecompLauncherCNetplayLaunch g_lnch_pending_direct_launch{}; @@ -4079,7 +4099,8 @@ namespace { std::snprintf(out->game_name, sizeof(out->game_name), "%s", state.game.empty() ? "PSX" : state.game.c_str()); out->player_count = state.joiner_name.empty() ? 1 : 2; - out->max_slots = 2; + out->max_slots = g_lnch_game_players >= 2 ? g_lnch_game_players : 2; + if (out->max_slots > PSX_MAX_PLAYERS) out->max_slots = PSX_MAX_PLAYERS; out->has_password = state.password.empty() ? 0 : 1; return 1; } @@ -4314,6 +4335,7 @@ namespace { int ae_np_connect(void*) { psx_lobby_set_game_identity(g_lnch_netplay_game_name.c_str(), PSX_GAME_VERSION); + psx_lobby_set_max_slots(g_lnch_game_players); return psx_lobby_connect(ae_np_default_url(nullptr)); } @@ -4723,7 +4745,8 @@ namespace { * port when the requested one is busy. Returns 0 ok, -4 port unavailable. */ int ae_np_create(void*, const char* lobby_name, char* host_endpoint, const char* password, - const RecompLauncherCSettings* settings) { + const RecompLauncherCSettings* settings, + int lan_only) { PsxLobbyMatchCaps caps = ae_netplay_caps_from_settings(settings); char endpoint[96]; if (host_endpoint && host_endpoint[0]) @@ -4732,7 +4755,9 @@ namespace { std::snprintf(endpoint, sizeof(endpoint), "0.0.0.0:7777"); const int want_port = ae_np_lan_endpoint_port(endpoint); - if (!ae_np_endpoint_is_any_bind(endpoint)) { + /* lan_only: publish only the local LAN registry (no lobby server). + * Also take this path when the UI already bound a concrete LAN IP. */ + if (lan_only || !ae_np_endpoint_is_any_bind(endpoint)) { /* LAN/Direct IP: exact port required — fail if busy. */ if (psx_lobby_in_lobby()) (void)psx_lobby_leave(); @@ -4763,6 +4788,7 @@ namespace { g_lnch_remote_lan = false; g_lnch_remote_lan_state = {}; g_lnch_lan_endpoint.clear(); + psx_lobby_set_max_slots(g_lnch_game_players); return psx_lobby_create(lobby_name && lobby_name[0] ? lobby_name : "Netplay Lobby", g_lnch_netplay_game_name.c_str(), PSX_GAME_VERSION, password ? password : "", endpoint, &caps); @@ -5024,6 +5050,10 @@ namespace { g_lnch_pending_direct_launch.input_player = 0; g_lnch_pending_direct_launch.session_id = g_lnch_lan_session_id; g_lnch_pending_direct_launch.input_delay = 2; + g_lnch_pending_direct_launch.max_slots = + g_lnch_game_players >= 2 ? g_lnch_game_players : 2; + if (g_lnch_pending_direct_launch.max_slots > PSX_MAX_PLAYERS) + g_lnch_pending_direct_launch.max_slots = PSX_MAX_PLAYERS; if (g_lnch_hosting_lan) { const size_t colon = state.endpoint.rfind(':'); const char* port = colon == std::string::npos @@ -5090,6 +5120,9 @@ namespace { std::snprintf(out->peer_hostport, sizeof(out->peer_hostport), "%s", ji->peer_hostport); out->session_id = ji->session_id; out->input_delay = (caps && caps->valid) ? caps->input_delay : 2; + out->max_slots = ji->max_slots >= 2 ? ji->max_slots + : (g_lnch_game_players >= 2 ? g_lnch_game_players : 2); + if (out->max_slots > PSX_MAX_PLAYERS) out->max_slots = PSX_MAX_PLAYERS; return 1; } @@ -5338,6 +5371,9 @@ int main(int argc, char** argv) { game_id = gc.id; game_region = gc.region; game_players = gc.players; + g_offline_pad_count = game_players > 0 ? game_players : 1; + if (g_offline_pad_count > PSX_MAX_PLAYERS) + g_offline_pad_count = PSX_MAX_PLAYERS; game_has_disc_crc = gc.has_disc_crc; game_disc_crc = gc.disc_crc; if (!gc.discs.empty()) resolved_disc = gc.discs.front(); @@ -6124,7 +6160,13 @@ int main(int argc, char** argv) { gi.memcard_inspect = ae_memcard_inspect; #if defined(PSX_HAS_RECOMP_NET) && defined(PSX_HAS_LOBBY_CLIENT) g_lnch_netplay_game_name = game_name.empty() ? "PSX" : game_name; - gi.netplay_supported = (game_players == 2) ? 1 : 0; + g_lnch_game_players = game_players; + g_offline_pad_count = game_players > 0 ? game_players : 1; + if (g_offline_pad_count > PSX_MAX_PLAYERS) + g_offline_pad_count = PSX_MAX_PLAYERS; + psx_lobby_set_max_slots(game_players); + gi.netplay_supported = + (game_players >= 2 && game_players <= PSX_MAX_PLAYERS) ? 1 : 0; gi.netplay = &g_lnch_netplay_callbacks; #endif @@ -6226,15 +6268,21 @@ int main(int argc, char** argv) { net_cfg.input_player = ls.netplay_launch.input_player; net_cfg.session_id = ls.netplay_launch.session_id; net_cfg.input_delay = ls.netplay_launch.input_delay; + net_cfg.slot_count = ls.netplay_launch.max_slots >= 2 + ? ls.netplay_launch.max_slots + : game_players; + if (net_cfg.slot_count < 2) net_cfg.slot_count = 2; + if (net_cfg.slot_count > PSX_MAX_PLAYERS) + net_cfg.slot_count = PSX_MAX_PLAYERS; std::snprintf(net_cfg.bind_hostport, sizeof(net_cfg.bind_hostport), "%s", ls.netplay_launch.bind_hostport); std::snprintf(net_cfg.peer_hostport, sizeof(net_cfg.peer_hostport), "%s", ls.netplay_launch.peer_hostport); g_netplay_from_lobby = 1; std::fprintf(stdout, - "psxrecomp: launcher netplay slot=%d bind=%s peer=%s session=%u\n", - net_cfg.local_slot, net_cfg.bind_hostport, net_cfg.peer_hostport, - (unsigned)net_cfg.session_id); + "psxrecomp: launcher netplay slot=%d slots=%d bind=%s peer=%s session=%u\n", + net_cfg.local_slot, net_cfg.slot_count, net_cfg.bind_hostport, + net_cfg.peer_hostport, (unsigned)net_cfg.session_id); std::fflush(stdout); } else { g_netplay_from_lobby = 0; @@ -6408,7 +6456,10 @@ int main(int argc, char** argv) { * ports during early boot. */ set_player_device(g_players[0], p1_device, p1_mode); set_player_device(g_players[1], p2_device, p2_mode); - for (int s = 0; s < 2; s++) { + /* Slots 2+ stay kind=0 until assigned; still size SIO for the build ceiling. */ + if (game_players >= 3) + sio_set_multitap(1); + for (int s = 0; s < PSX_MAX_PLAYERS; s++) { /* Dev-any-input keeps P1 connected even with no assigned controller so the * keyboard / any plugged-in controller can drive port 1 standalone. */ const bool dev_p1 = (dev_any_input_enabled() && s == 0); @@ -6746,13 +6797,17 @@ int main(int argc, char** argv) { /* Resolve which host PlayerInput feeds this peer's net sample. * Auto: prefer g_players[local_slot] when assigned (same-PC: host * C40 on P1 + guest keyboard on P2); else player 0 (two-machine). */ - if (net_cfg.input_player != 0 && net_cfg.input_player != 1) { - const int prefer = (net_cfg.local_slot == 1) ? 1 : 0; - if (prefer == 1 && g_players[1].kind != 0) - net_cfg.input_player = 1; + if (net_cfg.input_player < 0 || net_cfg.input_player >= PSX_MAX_PLAYERS) { + const int prefer = net_cfg.local_slot; + if (prefer >= 0 && prefer < PSX_MAX_PLAYERS && g_players[prefer].kind != 0) + net_cfg.input_player = prefer; else net_cfg.input_player = 0; } + if (net_cfg.slot_count < 2) + net_cfg.slot_count = game_players >= 2 ? game_players : 2; + if (net_cfg.slot_count > PSX_MAX_PLAYERS) + net_cfg.slot_count = PSX_MAX_PLAYERS; const int nrc = psx_netplay_start(&net_cfg); if (nrc != 0) { std::fprintf(stderr, @@ -7124,7 +7179,10 @@ int main(int argc, char** argv) { launcher_profile_apply("psx", &gi); gi.name = game_name.empty() ? nullptr : game_name.c_str(); gi.num_players = game_players; - gi.netplay_supported = 1; + g_lnch_game_players = game_players; + psx_lobby_set_max_slots(game_players); + gi.netplay_supported = + (game_players >= 2 && game_players <= PSX_MAX_PLAYERS) ? 1 : 0; gi.netplay = &g_lnch_netplay_callbacks; gi.resume_netplay_room = 1; gi.resume_netplay_endpoint = ae_np_lan_endpoint_cstr(); @@ -7159,6 +7217,12 @@ int main(int argc, char** argv) { net_cfg.input_player = ls.netplay_launch.input_player; net_cfg.session_id = ls.netplay_launch.session_id; net_cfg.input_delay = ls.netplay_launch.input_delay; + net_cfg.slot_count = ls.netplay_launch.max_slots >= 2 + ? ls.netplay_launch.max_slots + : game_players; + if (net_cfg.slot_count < 2) net_cfg.slot_count = 2; + if (net_cfg.slot_count > PSX_MAX_PLAYERS) + net_cfg.slot_count = PSX_MAX_PLAYERS; std::snprintf(net_cfg.bind_hostport, sizeof(net_cfg.bind_hostport), "%s", ls.netplay_launch.bind_hostport); std::snprintf(net_cfg.peer_hostport, sizeof(net_cfg.peer_hostport), "%s", diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index 151da476f..f91c33d1b 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -25,6 +25,7 @@ int psx_lobby_list_count(void) { return 0; } int psx_lobby_list_get(int index, PsxLobbyRow *out) { (void)index; (void)out; return 0; } void psx_lobby_set_game_identity(const char *a, const char *b) { (void)a; (void)b; } const char *psx_lobby_game_version(void) { return PSX_GAME_VERSION; } +void psx_lobby_set_max_slots(int max_slots) { (void)max_slots; } int psx_lobby_create(const char *a, const char *b, const char *c, const char *d, const char *e, const PsxLobbyMatchCaps *f) { (void)a; (void)b; (void)c; (void)d; (void)e; (void)f; return -1; } @@ -113,6 +114,16 @@ static LobbyClient g_lc = { .filter_game_version = PSX_GAME_VERSION, }; +/* Default max_slots for create (clamped 2..5). */ +static int g_lobby_max_slots = 2; + +void psx_lobby_set_max_slots(int max_slots) +{ + if (max_slots < 2) max_slots = 2; + if (max_slots > 5) max_slots = 5; + g_lobby_max_slots = max_slots; +} + static const char *effective_game_version(const char *override_ver) { if (override_ver && override_ver[0]) { @@ -649,7 +660,7 @@ static void handle_server_json(const char *json) json_get_str(json, "host_endpoint", g_lc.join.host_endpoint, sizeof(g_lc.join.host_endpoint)); json_get_str(json, "guest_endpoint", g_lc.join.guest_endpoint, sizeof(g_lc.join.guest_endpoint)); g_lc.join.player_count = 1; - g_lc.join.max_slots = 2; + g_lc.join.max_slots = json_get_int(json, "max_slots", g_lobby_max_slots); g_lc.join.last_error[0] = '\0'; if (g_lc.player_id[0]) { strncpy(g_lc.host_player_id, g_lc.player_id, sizeof(g_lc.host_player_id) - 1); @@ -1075,9 +1086,9 @@ int psx_lobby_create(const char *name, const char *game_name, const char *game_v } n = snprintf(msg, sizeof(msg), "{\"op\":\"create\",\"name\":\"%s\",\"game_name\":\"%s\",\"game_version\":\"%s\"," - "\"password\":\"%s\",\"max_slots\":2,\"host_bind\":\"%s\",\"display_name\":\"%s\"%s}", + "\"password\":\"%s\",\"max_slots\":%d,\"host_bind\":\"%s\",\"display_name\":\"%s\"%s}", name && name[0] ? name : "Lobby", gn, gv, - password ? password : "", g_lc.my_bind, + password ? password : "", g_lobby_max_slots, g_lc.my_bind, g_lc.display_name[0] ? g_lc.display_name : "Host", caps_json); if (n < 0 || (size_t)n >= sizeof(msg)) return -1; queue_send(msg); diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index 0fe896ea3..0fc79637f 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -21,11 +21,19 @@ #include "recomp_net/recomp_net.h" #endif +#ifndef PSX_MAX_PLAYERS +#define PSX_MAX_PLAYERS 2 +#endif + +/* Session pad count mirrored for release_pads (available without recomp-net). */ +static int g_np_slot_count = 2; + void psx_netplay_config_defaults(PsxNetplayConfig *cfg) { if (!cfg) return; memset(cfg, 0, sizeof(*cfg)); cfg->local_slot = 0; + cfg->slot_count = 2; cfg->input_player = -1; cfg->input_delay = 2; cfg->session_id = 1; @@ -48,6 +56,8 @@ void psx_netplay_apply_env(PsxNetplayConfig *cfg) if (v && v[0] && v[0] != '0') cfg->enabled = 1; v = getenv("PSX_NET_SLOT"); if (v && v[0]) cfg->local_slot = (int)strtol(v, NULL, 10); + v = getenv("PSX_NET_SLOTS"); + if (v && v[0]) cfg->slot_count = (int)strtol(v, NULL, 10); v = getenv("PSX_NET_INPUT_PLAYER"); if (v && v[0]) cfg->input_player = (int)strtol(v, NULL, 10); v = getenv("PSX_NET_DELAY"); @@ -83,7 +93,11 @@ static void force_session_pads_connected(int slot_count) { int i; if (slot_count < 2) slot_count = 2; - if (slot_count > 2) slot_count = 2; + if (slot_count > PSX_MAX_PLAYERS) slot_count = PSX_MAX_PLAYERS; + if (slot_count >= 3) + sio_set_multitap(1); + else + sio_set_multitap(0); for (i = 0; i < slot_count; ++i) { sio_connect_pad(i); sio_set_pad_config_capable(i, 1); @@ -92,13 +106,16 @@ static void force_session_pads_connected(int slot_count) void psx_netplay_release_pads(void) { - force_session_pads_connected(2); - sio_set_pad_state_slot(0, 0xFFFFu); - sio_set_pad_state_slot(1, 0xFFFFu); - sio_set_pad_sticks(0, 0x80, 0x80, 0x80, 0x80); - sio_set_pad_sticks(1, 0x80, 0x80, 0x80, 0x80); - sio_request_pad_type(0, 1); - sio_request_pad_type(1, 1); + int i; + int n = g_np_slot_count; + if (n < 2) n = 2; + if (n > PSX_MAX_PLAYERS) n = PSX_MAX_PLAYERS; + force_session_pads_connected(n); + for (i = 0; i < n; ++i) { + sio_set_pad_state_slot(i, 0xFFFFu); + sio_set_pad_sticks(i, 0x80, 0x80, 0x80, 0x80); + sio_request_pad_type(i, 1); + } } #if !defined(PSX_HAS_RECOMP_NET) @@ -164,7 +181,7 @@ typedef struct { int active; int slot_count; int local_slot; - int input_player; /* resolved 0/1 */ + int input_player; /* resolved host PlayerInput index */ int needs_advance; int latched_for_tick; /* 1 if staged pad frozen for current sim_tick */ uint32_t latched_sim_tick; @@ -771,7 +788,7 @@ static void decode_pad(const RNetInputSample *in, PsxNetPad *pad) static void apply_pad_slot(int slot, const PsxNetPad *pad) { - if (slot < 0 || slot > 1 || !pad) return; + if (slot < 0 || slot >= g_np.slot_count || slot >= PSX_MAX_PLAYERS || !pad) return; sio_set_pad_connected(slot, 1); sio_set_pad_config_capable(slot, 1); sio_set_pad_state_slot(slot, pad->buttons); @@ -796,11 +813,15 @@ static void host_sample_local(rnet_u32 tick, RNetInputSample *out, void *ctx) static void host_publish(rnet_u32 tick, const RNetInputSample *by_slot, int slots, void *ctx) { int i; + int n; (void)tick; (void)ctx; if (!by_slot || slots <= 0) return; - force_session_pads_connected(slots); - for (i = 0; i < slots && i < 2; ++i) { + n = g_np.slot_count; + if (n > slots) n = slots; + if (n > PSX_MAX_PLAYERS) n = PSX_MAX_PLAYERS; + force_session_pads_connected(n); + for (i = 0; i < n; ++i) { PsxNetPad pad; decode_pad(&by_slot[i], &pad); apply_pad_slot(i, &pad); @@ -922,18 +943,30 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) RNetConfig rcfg; RNetHostVTable host; int in_player; + int slots; + int local; if (!cfg || !cfg->enabled) return -1; if (g_np.session) psx_netplay_shutdown(); + slots = cfg->slot_count; + if (slots < 2) slots = 2; + if (slots > PSX_MAX_PLAYERS) slots = PSX_MAX_PLAYERS; + if (slots > RNET_MAX_SLOTS) slots = RNET_MAX_SLOTS; + + local = cfg->local_slot; + if (local < 0) local = 0; + if (local >= slots) local = slots - 1; + rnet_config_init_defaults(&rcfg); - rcfg.slot_count = 2; - rcfg.local_slot = (rnet_u8)(cfg->local_slot < 0 ? 0 : (cfg->local_slot > 1 ? 1 : cfg->local_slot)); + rcfg.slot_count = (rnet_u8)slots; + rcfg.local_slot = (rnet_u8)local; rcfg.input_delay = (rnet_u8)(cfg->input_delay < 0 ? 0 : (cfg->input_delay > 16 ? 16 : cfg->input_delay)); rcfg.session_id = cfg->session_id ? cfg->session_id : 1u; - /* Host resolves auto (-1) before start; accept only 0/1 here. */ - in_player = (cfg->input_player == 1) ? 1 : 0; + /* Host resolves auto (-1) before start; accept 0..PSX_MAX_PLAYERS-1. */ + in_player = cfg->input_player; + if (in_player < 0 || in_player >= PSX_MAX_PLAYERS) in_player = 0; memset(&host, 0, sizeof(host)); host.sample_local = host_sample_local; @@ -949,8 +982,13 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) } g_np.active = 1; g_np.slot_count = (int)rcfg.slot_count; + g_np_slot_count = g_np.slot_count; g_np.local_slot = (int)rcfg.local_slot; g_np.input_player = in_player; + if (g_np.slot_count >= 3) + sio_set_multitap(1); + else + sio_set_multitap(0); g_np.staged_valid = 0; g_np.needs_advance = 0; g_np.latched_for_tick = 0; diff --git a/runtime/src/sio.c b/runtime/src/sio.c index e7376967f..3aefaf28e 100644 --- a/runtime/src/sio.c +++ b/runtime/src/sio.c @@ -41,27 +41,32 @@ static void sio_debug_poll_maybe(void) { } } -/* Pad state: 0=pressed, 1=released (PS1 convention). Per slot (port). */ -static uint16_t pad_buttons[2] = { 0xFFFF, 0xFFFF }; /* all released */ +/* Pad state: 0=pressed, 1=released (PS1 convention). Indexed by LOGICAL pad + * 0 .. PSX_MAX_PLAYERS-1 (not physical SIO slot). */ +static uint16_t pad_buttons[PSX_MAX_PLAYERS] = { [0 ... PSX_MAX_PLAYERS - 1] = 0xFFFF }; -/* Per-slot pad type + analog stick state. analog: 0=digital pad (poll id +/* Per-logical-pad type + analog stick state. analog: 0=digital pad (poll id * 0x41), 1=DualShock/analog (poll id 0x73). Sticks are 0..255, 0x80 centred. */ -static uint8_t pad_analog[2] = { 0, 0 }; -static uint8_t pad_stick[2][4] = { { 0x80, 0x80, 0x80, 0x80 }, - { 0x80, 0x80, 0x80, 0x80 } }; /* lx,ly,rx,ry */ - -/* Analog-mode lock, per slot. A real DualShock's config command 0x44 0x..02/0x03 - * locks/unlocks the mode (dualshock.cpp:714-725); a locked pad ignores the - * physical analog button (dualshock.cpp:203). We emulate the analog button via - * the host hybrid heuristic (pad_type_req), so when a game LOCKS the mode the - * hybrid auto-flip must not override it — else the type flips underneath a game - * that pinned DualShock, the exact desync the deferred-request machinery cannot - * otherwise prevent. */ -static uint8_t analog_mode_locked[2] = { 0, 0 }; - -/* Which slots have devices connected */ +static uint8_t pad_analog[PSX_MAX_PLAYERS]; +static uint8_t pad_stick[PSX_MAX_PLAYERS][4] = { + [0 ... PSX_MAX_PLAYERS - 1] = { 0x80, 0x80, 0x80, 0x80 } +}; /* lx,ly,rx,ry */ + +/* Analog-mode lock, per logical pad. A real DualShock's config command 0x44 + * 0x..02/0x03 locks/unlocks the mode (dualshock.cpp:714-725); a locked pad + * ignores the physical analog button (dualshock.cpp:203). We emulate the + * analog button via the host hybrid heuristic (pad_type_req), so when a game + * LOCKS the mode the hybrid auto-flip must not override it — else the type + * flips underneath a game that pinned DualShock, the exact desync the + * deferred-request machinery cannot otherwise prevent. */ +static uint8_t analog_mode_locked[PSX_MAX_PLAYERS]; + +/* Which logical pads have devices connected (bit i = pad i). Fits 5 pads. */ static uint8_t pad_connected = 0; +/* Host-side SCPH-1070 enable. Only meaningful when PSX_MAX_PLAYERS >= 5. */ +static int sio_multitap_enabled = 0; + /* Pad communication state machine */ typedef enum { PAD_IDLE, @@ -69,33 +74,39 @@ typedef enum { PAD_SEND_RESPONSE, /* sending command response bytes */ } PadState; +/* Multitap 0x42 bulk: ID(0x80)+0x5A + 4×8 pad status bytes. */ +#define PAD_RESPONSE_MAX 34 + static PadState pad_state = PAD_IDLE; -static int selected_slot = 0; -static uint8_t pad_response[8]; +static int selected_slot = 0; /* physical SIO slot (CTRL bit13): 0 or 1 */ +static int pad_active_logical = 0; /* logical pad for single-pad / config cmds */ +static uint8_t pad_response[PAD_RESPONSE_MAX]; static uint8_t pad_response_len = 0; static uint8_t pad_response_idx = 0; static uint8_t pad_current_cmd = 0; -/* DualShock config-mode latch, per slot. A real controller only answers the - * config commands (0x44/0x45/0x46/0x47/0x4C/0x4D/0x4F) and reports the config - * ID 0xF3 while it is IN config mode; outside config it reports its normal ID - * (0x41 digital / 0x73 analog) and ignores config commands. Config is entered/ - * exited by command 0x43 with the data byte 0x01(enter)/0x00(exit). Faking - * "always in config" (constant 0xF3) wedges games that probe the pad type via - * 0x43 before polling — e.g. Mega Man X6 loops 01 43 00 00 forever and never - * reaches 0x42. (MMX6 ISSUES.md #2.) */ -static uint8_t pad_in_config[2] = { 0, 0 }; - -/* Whether the pad on a slot is a config-capable DualShock (1) or a plain - * digital controller (0). A real SCPH-1080 digital pad (poll id 0x41) does NOT - * answer the config-mode commands (0x43/0x44/.../0x4F): it returns hi-z and the - * transaction ends. A game's pad driver that probes with 0x43 to detect a - * DualShock therefore classifies a digital pad as digital-only and just polls - * it with 0x42. Tomba 2's driver probes this way every frame; when the SM - * (wrongly) answered 0x43 for its digital pad it went down the DualShock config - * path and read the 0x00 config-response bytes as buttons -> phantom "all - * pressed" input. Default 1 keeps analog/hybrid pads unchanged; main.cpp sets 0 - * for PAD_MODE_DIGITAL. */ -static uint8_t pad_supports_config[2] = { 1, 1 }; +/* DualShock config-mode latch, per logical pad. A real controller only answers + * the config commands (0x44/0x45/0x46/0x47/0x4C/0x4D/0x4F) and reports the + * config ID 0xF3 while it is IN config mode; outside config it reports its + * normal ID (0x41 digital / 0x73 analog) and ignores config commands. Config is + * entered/exited by command 0x43 with the data byte 0x01(enter)/0x00(exit). + * Faking "always in config" (constant 0xF3) wedges games that probe the pad + * type via 0x43 before polling — e.g. Mega Man X6 loops 01 43 00 00 forever + * and never reaches 0x42. (MMX6 ISSUES.md #2.) */ +static uint8_t pad_in_config[PSX_MAX_PLAYERS]; + +/* Whether the pad on a logical slot is a config-capable DualShock (1) or a + * plain digital controller (0). A real SCPH-1080 digital pad (poll id 0x41) + * does NOT answer the config-mode commands (0x43/0x44/.../0x4F): it returns + * hi-z and the transaction ends. A game's pad driver that probes with 0x43 to + * detect a DualShock therefore classifies a digital pad as digital-only and + * just polls it with 0x42. Tomba 2's driver probes this way every frame; when + * the SM (wrongly) answered 0x43 for its digital pad it went down the + * DualShock config path and read the 0x00 config-response bytes as buttons -> + * phantom "all pressed" input. Default 1 keeps analog/hybrid pads unchanged; + * main.cpp sets 0 for PAD_MODE_DIGITAL. */ +static uint8_t pad_supports_config[PSX_MAX_PLAYERS] = { + [0 ... PSX_MAX_PLAYERS - 1] = 1 +}; /* Coherent-DualShock model (Tomba phantom-input fix). A real controller never * changes its reported type (0x41 digital <-> 0x73 analog) in the middle of a @@ -108,7 +119,69 @@ static uint8_t pad_supports_config[2] = { 1, 1 }; * host REQUESTS a type via pad_type_req[] and the change is applied atomically * only when the bus is idle (PAD_IDLE) and the pad is NOT in config mode. A * request raised during config is held until config exits. -1 = no request. */ -static int8_t pad_type_req[2] = { -1, -1 }; +static int8_t pad_type_req[PSX_MAX_PLAYERS] = { + [0 ... PSX_MAX_PLAYERS - 1] = -1 +}; + +/* ---- Logical pad ↔ physical SIO port mapping ---- + * + * Multitap off (default / PSX_MAX_PLAYERS==2): + * physical 0 → logical 0, physical 1 → logical 1 + * Multitap on (SCPH-1070 on port 1, single pad on port 2): + * physical 0 → multitap (bulk pads 0–3 on 0x42; config/other → pad A = 0) + * physical 1 → logical 4 + */ +static int sio_multitap_active(void) { +#if PSX_MAX_PLAYERS >= 5 + return sio_multitap_enabled; +#else + return 0; +#endif +} + +static int pad_logical_for_port(int phys_port) { + if (phys_port < 0 || phys_port > 1) return -1; + if (sio_multitap_active()) + return (phys_port == 0) ? 0 : 4; + return phys_port; +} + +/* Physical port answers 0x01 when a device is present. Multitap itself is + * present whenever enabled (individual tap slots may still be empty). */ +static int pad_port_has_device(int phys_port) { + if (phys_port < 0 || phys_port > 1) return 0; + if (sio_multitap_active()) { + if (phys_port == 0) return 1; + return (pad_connected & (1u << 4)) ? 1 : 0; + } + return (pad_connected & (1u << phys_port)) ? 1 : 0; +} + +/* Fill 8-byte per-pad status block used in multitap bulk 0x42 responses. + * Disconnected → all 0xFF. Digital → 0x41 0x5A btnL btnH + 0xFF pad. + * Analog/config → 0x73/0xF3 0x5A btn + stick bytes. */ +static void pad_fill_status8(int logical, uint8_t out[8]) { + if (logical < 0 || logical >= PSX_MAX_PLAYERS || + !(pad_connected & (1u << logical))) { + memset(out, 0xFF, 8); + return; + } + const uint8_t id = pad_in_config[logical] ? 0xF3 + : (pad_analog[logical] ? 0x73 : 0x41); + const uint16_t btn = pad_buttons[logical]; + out[0] = id; + out[1] = 0x5A; + out[2] = (uint8_t)(btn & 0xFF); + out[3] = (uint8_t)(btn >> 8); + if (pad_analog[logical] || pad_in_config[logical]) { + out[4] = pad_stick[logical][2]; /* right X */ + out[5] = pad_stick[logical][3]; /* right Y */ + out[6] = pad_stick[logical][0]; /* left X */ + out[7] = pad_stick[logical][1]; /* left Y */ + } else { + out[4] = out[5] = out[6] = out[7] = 0xFF; + } +} /* Memory card SIO state machine */ typedef enum { @@ -541,11 +614,19 @@ void sio_init(void) { pad_response_len = 0; pad_response_idx = 0; pad_current_cmd = 0; - pad_buttons[0] = pad_buttons[1] = 0xFFFF; - pad_in_config[0] = pad_in_config[1] = 0; /* clear stale config latch on reset */ - pad_type_req[0] = pad_type_req[1] = -1; /* no pending host type change */ - analog_mode_locked[0] = analog_mode_locked[1] = 0; /* unlocked on reset */ + pad_active_logical = 0; + for (int i = 0; i < PSX_MAX_PLAYERS; i++) { + pad_buttons[i] = 0xFFFF; + pad_analog[i] = 0; + pad_stick[i][0] = pad_stick[i][1] = pad_stick[i][2] = pad_stick[i][3] = 0x80; + pad_in_config[i] = 0; + pad_type_req[i] = -1; + analog_mode_locked[i] = 0; + pad_supports_config[i] = 1; + } pad_connected = 0; + /* Multitap enable is a host preference — leave sio_multitap_enabled alone + * across sio_init so a soft reset does not drop the tap configuration. */ mc_state = MC_IDLE; for (int i = 0; i < 2; i++) { mc_slots[i].state = MC_IDLE; @@ -601,19 +682,32 @@ uint32_t sio_cycles_to_irq(uint32_t i_mask) { return best; } +void sio_set_multitap(int enabled) { +#if PSX_MAX_PLAYERS >= 5 + sio_multitap_enabled = enabled ? 1 : 0; +#else + (void)enabled; + sio_multitap_enabled = 0; +#endif +} + +int sio_get_multitap(void) { + return sio_multitap_active(); +} + void sio_connect_pad(int slot) { - if (slot >= 0 && slot <= 1) - pad_connected |= (1 << slot); + if (slot >= 0 && slot < PSX_MAX_PLAYERS) + pad_connected |= (uint8_t)(1u << slot); } void sio_set_pad_connected(int slot, int connected) { - if (slot < 0 || slot > 1) return; - if (connected) pad_connected |= (uint8_t)(1 << slot); - else pad_connected &= (uint8_t)~(1 << slot); + if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; + if (connected) pad_connected |= (uint8_t)(1u << slot); + else pad_connected &= (uint8_t)~(1u << slot); } void sio_set_pad_config_capable(int slot, int capable) { - if (slot < 0 || slot > 1) return; + if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; pad_supports_config[slot] = capable ? 1 : 0; /* A plain digital pad can never be in config mode; clear any stale latch so * the next poll reports the digital id (0x41), not the config id (0xF3). */ @@ -625,7 +719,7 @@ void sio_set_pad_state(uint16_t buttons) { } void sio_set_pad_state_slot(int slot, uint16_t buttons) { - if (slot >= 0 && slot <= 1) pad_buttons[slot] = buttons; + if (slot >= 0 && slot < PSX_MAX_PLAYERS) pad_buttons[slot] = buttons; } /* Direct set of pad type + sticks. Used at boot/hotplug (refresh_player_devices) @@ -635,7 +729,7 @@ void sio_set_pad_state_slot(int slot, uint16_t buttons) { * coherently (see pad_type_req[] above). */ void sio_set_pad_analog(int slot, int enabled, uint8_t lx, uint8_t ly, uint8_t rx, uint8_t ry) { - if (slot < 0 || slot > 1) return; + if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; pad_analog[slot] = enabled ? 1 : 0; pad_type_req[slot] = -1; /* explicit set supersedes any pending request */ pad_stick[slot][0] = lx; pad_stick[slot][1] = ly; @@ -644,7 +738,7 @@ void sio_set_pad_analog(int slot, int enabled, /* Per-frame stick update (does not touch the reported pad type). */ void sio_set_pad_sticks(int slot, uint8_t lx, uint8_t ly, uint8_t rx, uint8_t ry) { - if (slot < 0 || slot > 1) return; + if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; pad_stick[slot][0] = lx; pad_stick[slot][1] = ly; pad_stick[slot][2] = rx; pad_stick[slot][3] = ry; } @@ -653,7 +747,7 @@ void sio_set_pad_sticks(int slot, uint8_t lx, uint8_t ly, uint8_t rx, uint8_t ry * deferred and applied atomically at the next idle, non-config boundary, so it * can never split a poll or a config handshake. A no-op if already that type. */ void sio_request_pad_type(int slot, int analog) { - if (slot < 0 || slot > 1) return; + if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; int want = analog ? 1 : 0; pad_type_req[slot] = (pad_analog[slot] == want) ? -1 : (int8_t)want; } @@ -663,21 +757,21 @@ uint16_t sio_get_pad_buttons(void) { } uint16_t sio_get_pad_buttons_slot(int slot) { - return (slot >= 0 && slot <= 1) ? pad_buttons[slot] : 0xFFFF; + return (slot >= 0 && slot < PSX_MAX_PLAYERS) ? pad_buttons[slot] : 0xFFFF; } int sio_get_pad_connected(int slot) { - if (slot < 0 || slot > 1) return 0; - return (pad_connected & (1 << slot)) ? 1 : 0; + if (slot < 0 || slot >= PSX_MAX_PLAYERS) return 0; + return (pad_connected & (1u << slot)) ? 1 : 0; } int sio_get_pad_analog(int slot) { - return (slot >= 0 && slot <= 1) ? pad_analog[slot] : 0; + return (slot >= 0 && slot < PSX_MAX_PLAYERS) ? pad_analog[slot] : 0; } void sio_get_pad_sticks(int slot, uint8_t out[4]) { if (!out) return; - if (slot < 0 || slot > 1) { + if (slot < 0 || slot >= PSX_MAX_PLAYERS) { out[0] = out[1] = out[2] = out[3] = 0x80; return; } @@ -744,7 +838,8 @@ void sio_set_legacy_cfg(int v) { g_pad_legacy_cfg = v ? 1 : 0; /* Clear any in-flight config latch so a mid-session toggle can't carry a * stale 0xF3/8-byte poll into the other mode's dispatch. */ - pad_in_config[0] = pad_in_config[1] = 0; + for (int s = 0; s < PSX_MAX_PLAYERS; s++) + pad_in_config[s] = 0; } static void pad_process_byte(uint8_t tx_byte) { @@ -754,7 +849,7 @@ static void pad_process_byte(uint8_t tx_byte) { * handshake — a hybrid stick/d-pad flip can never desync the game's driver * mid-transaction. A request raised during config stays pending until exit. */ if (pad_state == PAD_IDLE) { - for (int s = 0; s < 2; s++) { + for (int s = 0; s < PSX_MAX_PLAYERS; s++) { /* A game-LOCKED analog mode (0x44 ..03) ignores the physical analog * button — and our hybrid auto-flip IS that button — so a locked slot * drops the pending host request instead of applying it. */ @@ -766,7 +861,8 @@ static void pad_process_byte(uint8_t tx_byte) { } switch (pad_state) { case PAD_IDLE: - if (tx_byte == 0x01 && (pad_connected & (1 << selected_slot))) { + if (tx_byte == 0x01 && pad_port_has_device(selected_slot)) { + pad_active_logical = pad_logical_for_port(selected_slot); pad_state = PAD_WAIT_ACCESS; sio_rx_data = 0xFF; sio_stat |= SIO_STAT_ACK; @@ -778,31 +874,57 @@ static void pad_process_byte(uint8_t tx_byte) { case PAD_WAIT_ACCESS: pad_current_cmd = tx_byte; pad_response_idx = 1; + /* SCPH-1070 multitap bulk poll on physical port 0: ID 0x80, 0x5A, then + * concatenated 8-byte status for logical pads 0–3 (Beetle/DuckStation/ + * BlueRetro SCPH-1070). */ + if (sio_multitap_active() && selected_slot == 0 && tx_byte == 0x42) { + pad_response[0] = 0x80; + pad_response[1] = 0x5A; + for (int i = 0; i < 4; i++) + pad_fill_status8(i, &pad_response[2 + i * 8]); + pad_response_len = PAD_RESPONSE_MAX; + pad_state = PAD_SEND_RESPONSE; + sio_rx_data = pad_response[0]; + sio_stat |= SIO_STAT_ACK; + break; + } + /* Single-pad path (port2 / multitap-off / non-0x42 on multitap port A). */ + { + const int lp = pad_active_logical; + if (lp < 0 || lp >= PSX_MAX_PLAYERS || !(pad_connected & (1u << lp))) { + /* No pad on this logical slot (e.g. empty multitap A during a + * non-bulk command): hi-z, end transaction. */ + pad_state = PAD_IDLE; + pad_response_len = 0; + pad_response_idx = 0; + pad_current_cmd = 0; + sio_rx_data = 0xFF; + break; + } /* Controller ID reported as the first response byte. Real hardware * reports the config ID (0xF3) ONLY while in config mode; otherwise the * normal mode ID (0x41 digital / 0x73 analog). */ - { - const uint8_t cur_id = pad_in_config[selected_slot] ? 0xF3 - : (pad_analog[selected_slot] ? 0x73 : 0x41); + const uint8_t cur_id = pad_in_config[lp] ? 0xF3 + : (pad_analog[lp] ? 0x73 : 0x41); /* A plain digital controller (SCPH-1080) answers ONLY the 0x42 poll; it * ignores every config-mode command (returns hi-z, no ACK). A driver * that probes with 0x43 to detect a DualShock then classifies it as * digital-only and just polls. Gate all config branches on this so a * digital-mode pad behaves like real hardware (see pad_supports_config). */ - const int ds = pad_supports_config[selected_slot]; + const int ds = pad_supports_config[lp]; if (tx_byte == 0x42) { /* Read poll. Analog (or in-config) uses the 8-byte format with the * four stick axes; a plain digital pad uses the 4-byte format. */ - const uint16_t btn = pad_buttons[selected_slot]; + const uint16_t btn = pad_buttons[lp]; pad_response[0] = cur_id; pad_response[1] = 0x5A; pad_response[2] = (uint8_t)(btn & 0xFF); pad_response[3] = (uint8_t)(btn >> 8); - if (pad_analog[selected_slot] || pad_in_config[selected_slot]) { - pad_response[4] = pad_stick[selected_slot][2]; /* right X */ - pad_response[5] = pad_stick[selected_slot][3]; /* right Y */ - pad_response[6] = pad_stick[selected_slot][0]; /* left X */ - pad_response[7] = pad_stick[selected_slot][1]; /* left Y */ + if (pad_analog[lp] || pad_in_config[lp]) { + pad_response[4] = pad_stick[lp][2]; /* right X */ + pad_response[5] = pad_stick[lp][3]; /* right Y */ + pad_response[6] = pad_stick[lp][0]; /* left X */ + pad_response[7] = pad_stick[lp][1]; /* left Y */ pad_response_len = 8; } else { pad_response_len = 4; @@ -814,7 +936,7 @@ static void pad_process_byte(uint8_t tx_byte) { /* Enter/exit config mode. The ID byte reflects the CURRENT mode; the * enter(0x01)/exit(0x00) flag is the second data byte, latched in * PAD_SEND_RESPONSE so it takes effect after this transaction. */ - const uint16_t btn = pad_buttons[selected_slot]; + const uint16_t btn = pad_buttons[lp]; pad_response[1] = 0x5A; if (g_pad_legacy_cfg) { /* LEGACY (pre-98aa688): always config ID 0xF3, zero frame, no @@ -824,7 +946,7 @@ static void pad_process_byte(uint8_t tx_byte) { pad_response[4] = 0x00; pad_response[5] = 0x00; pad_response[6] = 0x00; pad_response[7] = 0x00; pad_response_len = 8; - } else if (!pad_in_config[selected_slot]) { + } else if (!pad_in_config[lp]) { /* ENTER attempt (normal mode): a real DualShock transmits the LIVE * poll frame here — identical framing to 0x42 (dualshock.cpp:471-490) * — and only latches config entry from the 0x01 data byte AFTERWARD. @@ -835,11 +957,11 @@ static void pad_process_byte(uint8_t tx_byte) { pad_response[0] = cur_id; pad_response[2] = (uint8_t)(btn & 0xFF); pad_response[3] = (uint8_t)(btn >> 8); - if (pad_analog[selected_slot]) { - pad_response[4] = pad_stick[selected_slot][2]; /* right X */ - pad_response[5] = pad_stick[selected_slot][3]; /* right Y */ - pad_response[6] = pad_stick[selected_slot][0]; /* left X */ - pad_response[7] = pad_stick[selected_slot][1]; /* left Y */ + if (pad_analog[lp]) { + pad_response[4] = pad_stick[lp][2]; /* right X */ + pad_response[5] = pad_stick[lp][3]; /* right Y */ + pad_response[6] = pad_stick[lp][0]; /* left X */ + pad_response[7] = pad_stick[lp][1]; /* left Y */ pad_response_len = 8; } else { pad_response_len = 4; @@ -876,12 +998,12 @@ static void pad_process_byte(uint8_t tx_byte) { /* 0x45 status byte must report the LIVE analog mode, not a fixed * analog-on (dualshock.cpp:743) — see fix below for the modern path. */ if (tx_byte == 0x45) - pad_response[3] = pad_analog[selected_slot] ? 0x01 : 0x00; + pad_response[3] = pad_analog[lp] ? 0x01 : 0x00; pad_response_len = 8; pad_state = PAD_SEND_RESPONSE; sio_rx_data = pad_response[0]; sio_stat |= SIO_STAT_ACK; - } else if (ds && !g_pad_legacy_cfg && pad_in_config[selected_slot] && + } else if (ds && !g_pad_legacy_cfg && pad_in_config[lp] && (tx_byte == 0x44 || tx_byte == 0x45 || tx_byte == 0x46 || tx_byte == 0x47 || tx_byte == 0x4C || tx_byte == 0x4D || tx_byte == 0x4F)) { @@ -906,7 +1028,7 @@ static void pad_process_byte(uint8_t tx_byte) { * driver mis-parse the poll frame length → off-by-frame garbage buttons * (axis5_sio_controller.md D8). */ if (tx_byte == 0x45) - pad_response[3] = pad_analog[selected_slot] ? 0x01 : 0x00; + pad_response[3] = pad_analog[lp] ? 0x01 : 0x00; pad_response_len = 8; pad_state = PAD_SEND_RESPONSE; sio_rx_data = pad_response[0]; @@ -928,24 +1050,27 @@ static void pad_process_byte(uint8_t tx_byte) { * exit(0x00) arrives paired with response index 2. Latch the new config * state; it takes effect from the next transaction (the ID byte already * reported the mode that was current at the start of this one). */ - if (!g_pad_legacy_cfg && pad_current_cmd == 0x43 && pad_response_idx == 2) - pad_in_config[selected_slot] = (tx_byte == 0x01) ? 1 : 0; + if (!g_pad_legacy_cfg && pad_current_cmd == 0x43 && pad_response_idx == 2 && + pad_active_logical >= 0 && pad_active_logical < PSX_MAX_PLAYERS) + pad_in_config[pad_active_logical] = (tx_byte == 0x01) ? 1 : 0; /* 0x44 set-mode (game owns the analog/digital mode): the mode byte rides * in the same slot as 0x43's enter/exit flag (data position 3). 0x01 => * analog (0x73), 0x00 => digital (0x41). Honouring it makes the pad * coherent — the type the game just selected is the type it then polls, * instead of the host hybrid silently winning. Drop any stale host * request so it can't immediately undo the game's choice. */ - if (!g_pad_legacy_cfg && pad_current_cmd == 0x44 && pad_response_idx == 2) { - pad_analog[selected_slot] = (tx_byte == 0x01) ? 1 : 0; - pad_type_req[selected_slot] = -1; + if (!g_pad_legacy_cfg && pad_current_cmd == 0x44 && pad_response_idx == 2 && + pad_active_logical >= 0 && pad_active_logical < PSX_MAX_PLAYERS) { + pad_analog[pad_active_logical] = (tx_byte == 0x01) ? 1 : 0; + pad_type_req[pad_active_logical] = -1; } /* 0x44 lock byte (data position 4, the byte after the mode byte): 0x03 => * lock analog mode, 0x02 => unlock (dualshock.cpp:714-725). A locked slot * ignores the host hybrid auto-flip (see analog_mode_locked). */ - if (!g_pad_legacy_cfg && pad_current_cmd == 0x44 && pad_response_idx == 3) { - if (tx_byte == 0x03) analog_mode_locked[selected_slot] = 1; - else if (tx_byte == 0x02) analog_mode_locked[selected_slot] = 0; + if (!g_pad_legacy_cfg && pad_current_cmd == 0x44 && pad_response_idx == 3 && + pad_active_logical >= 0 && pad_active_logical < PSX_MAX_PLAYERS) { + if (tx_byte == 0x03) analog_mode_locked[pad_active_logical] = 1; + else if (tx_byte == 0x02) analog_mode_locked[pad_active_logical] = 0; } if (pad_response_idx < pad_response_len) { sio_rx_data = pad_response[pad_response_idx++]; @@ -2201,13 +2326,19 @@ static int sio_snap_emit(PstW *w) { !pst_w_u16(w, sio_stat) || !pst_w_u16(w, sio_mode) || !pst_w_u16(w, sio_ctrl) || !pst_w_u16(w, sio_baud)) return 0; - if (!pst_w_bytes(w, pad_analog, 2) || !pst_w_u8(w, pad_connected) || + /* Pad arrays sized by PSX_MAX_PLAYERS. Default MAX=2 keeps the historical + * 2-pad snap layout byte-identical. pad_response runtime buffer is larger + * for multitap bulk (34); snap still stores the first 8 bytes. */ + if (!pst_w_bytes(w, pad_analog, PSX_MAX_PLAYERS) || !pst_w_u8(w, pad_connected) || !pst_w_u32(w, (uint32_t)pad_state) || !pst_w_i32(w, (int32_t)selected_slot) || !pst_w_bytes(w, pad_response, 8) || !pst_w_u8(w, pad_response_len) || !pst_w_u8(w, pad_response_idx) || !pst_w_u8(w, pad_current_cmd) || - !pst_w_bytes(w, pad_in_config, 2) || - !pst_w_i16(w, (int16_t)pad_type_req[0]) || !pst_w_i16(w, (int16_t)pad_type_req[1])) + !pst_w_bytes(w, pad_in_config, PSX_MAX_PLAYERS)) return 0; + for (int s = 0; s < PSX_MAX_PLAYERS; s++) { + if (!pst_w_i16(w, (int16_t)pad_type_req[s])) + return 0; + } if (!pst_w_u32(w, (uint32_t)mc_state) || !pst_w_i32(w, (int32_t)mc_slot) || !pst_w_u8(w, mc_cmd) || !pst_w_u16(w, mc_sector) || !pst_w_u8(w, mc_sector_msb) || !pst_w_u8(w, mc_sector_lsb) || @@ -2244,22 +2375,33 @@ static int sio_snap_emit(PstW *w) { static int sio_snap_parse(PstR *r) { uint32_t u; int32_t i; - int16_t tr0, tr1; + int16_t tr; if (!pst_r_u8(r, &sio_tx_data) || !pst_r_u8(r, &sio_rx_data) || !pst_r_u16(r, &sio_stat) || !pst_r_u16(r, &sio_mode) || !pst_r_u16(r, &sio_ctrl) || !pst_r_u16(r, &sio_baud)) return 0; - if (!pst_r_bytes(r, pad_analog, 2) || !pst_r_u8(r, &pad_connected) || + if (!pst_r_bytes(r, pad_analog, PSX_MAX_PLAYERS) || !pst_r_u8(r, &pad_connected) || !pst_r_u32(r, &u) || !pst_r_i32(r, &i) || !pst_r_bytes(r, pad_response, 8) || !pst_r_u8(r, &pad_response_len) || !pst_r_u8(r, &pad_response_idx) || !pst_r_u8(r, &pad_current_cmd) || - !pst_r_bytes(r, pad_in_config, 2) || - !pst_r_i16(r, &tr0) || !pst_r_i16(r, &tr1)) + !pst_r_bytes(r, pad_in_config, PSX_MAX_PLAYERS)) return 0; pad_state = (PadState)u; selected_slot = (int)i; - pad_type_req[0] = (int8_t)tr0; - pad_type_req[1] = (int8_t)tr1; + pad_active_logical = pad_logical_for_port(selected_slot); + /* Multitap bulk responses are 34 bytes; snap only stores 8. Abort an + * in-flight bulk restore rather than feed a truncated frame. */ + if (pad_response_len > 8) { + pad_state = PAD_IDLE; + pad_response_len = 0; + pad_response_idx = 0; + pad_current_cmd = 0; + } + for (int s = 0; s < PSX_MAX_PLAYERS; s++) { + if (!pst_r_i16(r, &tr)) + return 0; + pad_type_req[s] = (int8_t)tr; + } if (!pst_r_u32(r, &u) || !pst_r_i32(r, &i) || !pst_r_u8(r, &mc_cmd) || !pst_r_u16(r, &mc_sector) || !pst_r_u8(r, &mc_sector_msb) || !pst_r_u8(r, &mc_sector_lsb) || !pst_r_bytes(r, mc_data, 128)) From 00302383e10825e243a89e2f185112935525de8c Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Wed, 22 Jul 2026 23:09:41 -0400 Subject: [PATCH 08/38] Point nested recomp-net/ui at published 5P feature tips. Use the standalone feat/max-slots-5 and feat/netplay-5p commits instead of detached local bumps so submodule fetches resolve. Co-authored-by: Cursor --- lib/recomp-net | 2 +- lib/recomp-ui | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/recomp-net b/lib/recomp-net index 6a2c7b866..5b23c93c0 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit 6a2c7b866c4a1a80e8ce15ad4bef0bcadea9d8ab +Subproject commit 5b23c93c015e612835f800c0ab60eb2518fdc916 diff --git a/lib/recomp-ui b/lib/recomp-ui index 298d8e652..7511bc97b 160000 --- a/lib/recomp-ui +++ b/lib/recomp-ui @@ -1 +1 @@ -Subproject commit 298d8e65282c14f2cd5564d375a74225c4dfe27a +Subproject commit 7511bc97b49c5fd89a1dab96583eff5978018ff0 From 20cfcc144b6a92d15ccf963dd675e3f56cc80b5e Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Wed, 22 Jul 2026 23:42:18 -0400 Subject: [PATCH 09/38] Defer offline multitap until after game entry. Enabling SCPH-1070 during BIOS boot breaks pad/LoadExe bring-up for N-player titles. Offline builds arm multitap once fntrace sees game start; netplay still enables from slot_count >= 3. Co-authored-by: Cursor --- runtime/src/main.cpp | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index c733f8f2b..336fb329f 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -3259,12 +3259,21 @@ static void sdl_vblank_present(void) { } } netplay_tail(override); + /* Turbo-active / multitap arming share game-started detection. */ + extern int fntrace_is_game_started(void); + if (psx_netplay_active()) { psx_netplay_finish_frame(); - } else if (g_headless) { - sample_headless_pad_into_sio(override); } else { - sample_pad_into_sio(override); + /* Offline N-pad: enable multitap only once the game EXE is running. */ + if (g_offline_pad_count >= 3 && fntrace_is_game_started() && + !sio_get_multitap()) { + sio_set_multitap(1); + } + if (g_headless) + sample_headless_pad_into_sio(override); + else + sample_pad_into_sio(override); } /* Latency ring: open this present cycle's slot, stamping when input was @@ -3273,7 +3282,6 @@ static void sdl_vblank_present(void) { /* Turbo-active test shared by the pacing/present gate below. */ int turbo_loads_active = 0; - extern int fntrace_is_game_started(void); int logical_load_active = fntrace_is_game_started() && cdrom_load_in_progress(); int load_run_value = 0; static int load_run = 0; @@ -6456,9 +6464,10 @@ int main(int argc, char** argv) { * ports during early boot. */ set_player_device(g_players[0], p1_device, p1_mode); set_player_device(g_players[1], p2_device, p2_mode); - /* Slots 2+ stay kind=0 until assigned; still size SIO for the build ceiling. */ - if (game_players >= 3) - sio_set_multitap(1); + /* Multitap stays OFF through BIOS boot: SCPH-1070 on port 1 breaks shell / + * LoadExe pad bring-up for titles that expect a lone digital pad. Offline + * 3+ player builds arm it after game entry (see vblank path); netplay arms + * it from psx_netplay when slot_count >= 3. */ for (int s = 0; s < PSX_MAX_PLAYERS; s++) { /* Dev-any-input keeps P1 connected even with no assigned controller so the * keyboard / any plugged-in controller can drive port 1 standalone. */ From 626a7d613e6eab24aa7759fa40dc5bb3bf40d51b Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Thu, 23 Jul 2026 00:27:15 -0400 Subject: [PATCH 10/38] build: drop lib/recomp-ui; games vendor UI at repo root Discover recomp-ui from CMAKE_SOURCE_DIR/recomp-ui (or RECOMP_UI_ROOT). Keep lib/recomp-net in the engine. Co-authored-by: Cursor --- .gitmodules | 4 ---- lib/recomp-ui | 1 - runtime/runtime.cmake | 27 ++++++++++++++++++++------- 3 files changed, 20 insertions(+), 12 deletions(-) delete mode 160000 lib/recomp-ui diff --git a/.gitmodules b/.gitmodules index e8564d501..c467250f0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,3 @@ [submodule "lib/recomp-net"] path = lib/recomp-net url = https://github.com/TechnicallyComputers/recomp-net.git -[submodule "lib/recomp-ui"] - path = lib/recomp-ui - url = https://github.com/mstan/recomp-ui.git - branch = master diff --git a/lib/recomp-ui b/lib/recomp-ui deleted file mode 160000 index 7511bc97b..000000000 --- a/lib/recomp-ui +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7511bc97b49c5fd89a1dab96583eff5978018ff0 diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 6a6e5f80e..2d73ae1e8 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -95,8 +95,18 @@ else() option(PSX_STATIC_RUNTIME "Statically link SDL2 + libgcc/libstdc++ for a self-contained exe" OFF) endif() -# PSX_RECOMP_UI: build the shared Dear ImGui launcher from lib/recomp-ui. +# PSX_RECOMP_UI: wire the shared Dear ImGui launcher from the *game* repo's +# root recomp-ui submodule (CMAKE_SOURCE_DIR/recomp-ui). Not vendored in +# psxrecomp — games that need the launcher own the pin. option(PSX_RECOMP_UI "Build the shared recomp-ui Dear ImGui launcher" ON) +set(RECOMP_UI_ROOT "" CACHE PATH + "Path to recomp-ui; empty = /recomp-ui") +if(PSX_RECOMP_UI AND (NOT RECOMP_UI_ROOT OR RECOMP_UI_ROOT STREQUAL "")) + if(EXISTS "${CMAKE_SOURCE_DIR}/recomp-ui/recomp_ui.cmake") + set(RECOMP_UI_ROOT "${CMAKE_SOURCE_DIR}/recomp-ui" CACHE PATH + "Path to recomp-ui; empty = /recomp-ui" FORCE) + endif() +endif() set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/main.cpp @@ -551,15 +561,18 @@ function(psxrecomp_add_runtime_target target) endif() # Shared recomp-ui Dear ImGui launcher (not in the oracle build — that's headless). + # Lives at the game repo root (RECOMP_UI_ROOT / CMAKE_SOURCE_DIR/recomp-ui), + # not under psxrecomp/lib/. if(PSX_RECOMP_UI AND NOT PSXRT_ORACLE) - if(NOT EXISTS "${PSXRECOMP_ROOT}/lib/recomp-ui/recomp_ui.cmake") + if(NOT RECOMP_UI_ROOT OR NOT EXISTS "${RECOMP_UI_ROOT}/recomp_ui.cmake") message(FATAL_ERROR - "PSX_RECOMP_UI=ON but lib/recomp-ui is missing. " - "Run: git submodule update --init --recursive") + "PSX_RECOMP_UI=ON but recomp-ui is missing.\n" + "Add at the game repo root:\n" + " git submodule add -b master " + "https://github.com/mstan/recomp-ui.git recomp-ui\n" + "Or set -DRECOMP_UI_ROOT=/path/to/recomp-ui") endif() - set(RECOMP_UI_ROOT "${PSXRECOMP_ROOT}/lib/recomp-ui" CACHE PATH - "Root directory of recomp-ui" FORCE) - include("${PSXRECOMP_ROOT}/lib/recomp-ui/recomp_ui.cmake") + include("${RECOMP_UI_ROOT}/recomp_ui.cmake") set(_psx_recomp_ui_args) if(PSXRT_LAUNCHER_BOXART) From 9b44327793b4e3e0c4f28d43a4a75dd5bcd7f63f Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Thu, 23 Jul 2026 20:22:54 -0400 Subject: [PATCH 11/38] launcher BIOS + ROM prompt --- runtime/src/main.cpp | 140 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 336fb329f..786184567 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -3803,6 +3803,107 @@ namespace { std::string g_lnch_expected_serial; uint32_t g_lnch_expected_crc = 0; bool g_lnch_has_crc = false; + const char* g_lnch_argv0 = nullptr; + + int ae_bios_verify(const char* bios_path, RecompLauncherCBiosVerify* out) { + if (!bios_path || !bios_path[0] || !out) return 0; + std::memset(out, 0, sizeof(*out)); + std::ifstream f(bios_path, std::ios::binary | std::ios::ate); + if (!f.is_open()) { + std::snprintf(out->detail, sizeof(out->detail), "BIOS file not found."); + return 1; + } + const std::streamoff size = f.tellg(); + if (size != 512 * 1024) { + std::snprintf(out->detail, sizeof(out->detail), + "BIOS must be exactly 512 KiB (got %lld). Use SCPH1001.BIN.", + (long long)size); + return 1; + } + std::vector data((size_t)size); + if (!read_at(f, 0, data.data(), data.size())) { + std::snprintf(out->detail, sizeof(out->detail), "Failed to read BIOS file."); + return 1; + } + const uint32_t crc = crc32_compute(data.data(), data.size()); + out->ok = 1; + if (crc != 0x37157331u) { + out->warn = 1; + std::snprintf(out->detail, sizeof(out->detail), + "CRC32 %08X (validated dump is SCPH1001 CRC32 37157331). " + "Boot may still work.", + crc); + } else { + std::snprintf(out->detail, sizeof(out->detail), "SCPH1001.BIN (CRC OK)."); + } + return 1; + } + + int ae_prepare_disc(const char* source_path, char* out_disc_path, size_t out_cap, + char* err_msg, size_t err_cap) { + if (!source_path || !source_path[0] || !out_disc_path || out_cap == 0) return 0; + out_disc_path[0] = '\0'; + if (err_msg && err_cap) err_msg[0] = '\0'; + namespace fs = std::filesystem; + std::error_code ec; + if (!fs::is_regular_file(source_path, ec)) { + if (err_msg && err_cap) + std::snprintf(err_msg, err_cap, "Source dump not found."); + return 0; + } + const fs::path exe_dir = exe_dir_from_argv(g_lnch_argv0 ? g_lnch_argv0 : ""); + const fs::path root = find_upward(exe_dir, "tools/prepare_disc.py"); + if (root.empty()) { + if (err_msg && err_cap) + std::snprintf(err_msg, err_cap, + "tools/prepare_disc.py not found near the executable."); + return 0; + } + const fs::path script = root / "tools" / "prepare_disc.py"; + const fs::path out_dir = root / "motk"; + fs::create_directories(out_dir, ec); + /* Prefer python3; fall back to python (Windows). */ + const char* py = "python3"; +#if defined(_WIN32) + /* On Windows `python` is the usual launcher; python3 may be absent. */ + py = "python"; +#endif + std::string cmd = std::string(py) + " \"" + script.string() + "\" \"" + + source_path + "\" --out-dir \"" + out_dir.string() + "\""; +#if !defined(_WIN32) + /* If python3 missing, retry with python. */ + int rc = std::system(cmd.c_str()); + if (rc != 0) { + cmd = std::string("python \"") + script.string() + "\" \"" + source_path + + "\" --out-dir \"" + out_dir.string() + "\""; + rc = std::system(cmd.c_str()); + } +#else + int rc = std::system(cmd.c_str()); +#endif + if (rc != 0) { + if (err_msg && err_cap) + std::snprintf(err_msg, err_cap, + "prepare_disc.py failed (exit %d). Check the dump is " + "2448 bytes/sector.", + rc); + return 0; + } + const fs::path cue = + out_dir / "Star Wars - Masters of Teras Kasi (USA).cue"; + const fs::path bin = + out_dir / "Star Wars - Masters of Teras Kasi (USA).bin"; + fs::path playable = fs::exists(cue, ec) ? cue : bin; + if (!fs::exists(playable, ec)) { + if (err_msg && err_cap) + std::snprintf(err_msg, err_cap, + "prepare_disc finished but no .cue/.bin was written."); + return 0; + } + playable = normalize_disc_path_for_launch(playable); + std::snprintf(out_disc_path, out_cap, "%s", playable.string().c_str()); + return 1; + } int ae_disc_verify(const char* disc_path, RecompLauncherCDiscVerify* out) { if (!disc_path || !disc_path[0] || !out) return 0; @@ -6164,8 +6265,45 @@ int main(int argc, char** argv) { g_lnch_expected_serial = game_id; g_lnch_expected_crc = game_disc_crc; g_lnch_has_crc = game_has_disc_crc; + g_lnch_argv0 = argv[0]; gi.disc_verify = ae_disc_verify; gi.memcard_inspect = ae_memcard_inspect; + gi.bios_verify = ae_bios_verify; + /* MotK ships tools/prepare_disc.py (2448→2352). Offer it in the + * first-run wizard so players need not run the script by hand. */ + { + const auto root = find_upward(exe_dir_from_argv(argv[0]), + "tools/prepare_disc.py"); + if (!root.empty()) { + gi.prepare_disc = ae_prepare_disc; + gi.prepare_disc_label = "Convert 2448-byte dump…"; + gi.prepare_disc_note = + "If you have a 2448-byte/sector MotK ISO dump, convert it " + "to a MODE2/2352 .bin/.cue under motk/ (same as " + "tools/prepare_disc.py)."; + } + } + /* Quiet first-run detection: missing/unreadable BIOS or disc opens + * the setup wizard inside recomp-ui (cross-platform file pickers). */ + { + bool bios_ok = false; + if (ls.bios_path[0]) { + RecompLauncherCBiosVerify bv{}; + if (ae_bios_verify(ls.bios_path, &bv) && bv.ok) bios_ok = true; + else ls.bios_path[0] = '\0'; + } + bool disc_ok = false; + if (!rui_initial_disc.empty()) { + std::error_code ec; + if (std::filesystem::exists(rui_initial_disc, ec)) { + const DiscValidation dv = + validate_disc_image(rui_initial_disc, game_id); + disc_ok = dv.opened && dv.has_header; + } + if (!disc_ok) rui_initial_disc.clear(); + } + gi.needs_setup = (!bios_ok || !disc_ok) ? 1 : 0; + } #if defined(PSX_HAS_RECOMP_NET) && defined(PSX_HAS_LOBBY_CLIENT) g_lnch_netplay_game_name = game_name.empty() ? "PSX" : game_name; g_lnch_game_players = game_players; @@ -6314,10 +6452,12 @@ int main(int argc, char** argv) { if (seed.has_bios_path) { settings_bios_storage = seed.bios_path.string(); bios_path = settings_bios_storage.c_str(); + write_cached_path(argv[0], "bios.cfg", seed.bios_path); } if (seed.has_disc_path) { seed.disc_path = normalize_disc_path_for_launch(seed.disc_path); resolved_disc = seed.disc_path; + write_cached_path(argv[0], "disc.cfg", resolved_disc); } memcard1_enabled = seed.memcard1_enabled; memcard2_enabled = seed.memcard2_enabled; From 0897932587803505f816548dadad47d7d80c26b5 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Thu, 23 Jul 2026 20:26:08 -0400 Subject: [PATCH 12/38] Update recomp-net --- lib/recomp-net | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/recomp-net b/lib/recomp-net index 5b23c93c0..add67b44b 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit 5b23c93c015e612835f800c0ab60eb2518fdc916 +Subproject commit add67b44bc08f8c377b4138cae45a865c665dfe2 From fc51dcc74270101fc215bcbd29d242de312f8586 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Thu, 23 Jul 2026 20:27:44 -0400 Subject: [PATCH 13/38] bump sub --- lib/recomp-net | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/recomp-net b/lib/recomp-net index add67b44b..5b23c93c0 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit add67b44bc08f8c377b4138cae45a865c665dfe2 +Subproject commit 5b23c93c015e612835f800c0ab60eb2518fdc916 From 0a1414f3789913e5bb29dce5350d14f8a2e5fd4a Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Thu, 23 Jul 2026 21:57:31 -0400 Subject: [PATCH 14/38] Update main.cpp --- runtime/src/main.cpp | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 786184567..967c7c8a3 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -4903,7 +4903,21 @@ namespace { password ? password : "", endpoint, &caps); } - int ae_np_join(void*, const char* lobby_id, const char* password) { + /* guest_bind is in/out (capacity >= 64). recomp-ui fills a real UDP port + * (prefer 7778); never advertise :0 to the lobby. */ + int ae_np_join(void*, const char* lobby_id, const char* password, + char* guest_bind) { + char bind_buf[64]; + const char* bind = guest_bind; + const char* colon = (bind && bind[0]) ? std::strrchr(bind, ':') : nullptr; + const unsigned port = (colon && colon[1]) + ? static_cast(std::strtoul(colon + 1, nullptr, 10)) : 0u; + if (!bind || !bind[0] || port == 0u) { + std::snprintf(bind_buf, sizeof(bind_buf), "0.0.0.0:7778"); + bind = bind_buf; + if (guest_bind) + std::snprintf(guest_bind, 64, "%s", bind_buf); + } if (lobby_id && strncmp(lobby_id, "lan:", 4) == 0) { const char* endpoint = lobby_id + 4; if (!endpoint[0]) return -1; @@ -4975,7 +4989,17 @@ namespace { g_lnch_remote_lan = false; g_lnch_remote_lan_state = {}; g_lnch_lan_endpoint.clear(); - return psx_lobby_join(lobby_id, password ? password : "", "0.0.0.0:0"); + return psx_lobby_join(lobby_id, password ? password : "", bind); + } + + const char* ae_np_last_error(void*) { + const PsxLobbyJoinInfo* ji = psx_lobby_join_info(); + return (ji && ji->last_error[0]) ? ji->last_error : nullptr; + } + + void ae_np_clear_last_error(void*) { + PsxLobbyJoinInfo* ji = const_cast(psx_lobby_join_info()); + if (ji) ji->last_error[0] = '\0'; } int ae_np_leave(void*) { @@ -5266,6 +5290,8 @@ namespace { ae_np_fill_launch, ae_np_local_address_get, ae_np_kick_member, + ae_np_last_error, + ae_np_clear_last_error, }; } // namespace #endif From 50334aea61d842de6991f73facd9fc7fbcae8b9d Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Fri, 24 Jul 2026 14:41:53 -0400 Subject: [PATCH 15/38] psx 5p networking patch --- lib/recomp-net | 2 +- recompiler/src/config_loader.cpp | 130 ++- recompiler/src/config_loader.h | 39 +- runtime/include/psx_cyc.h | 40 +- runtime/include/psx_cycles.h | 8 +- runtime/include/psx_keybinds.h | 18 +- runtime/include/psx_lobby_client.h | 5 +- runtime/include/psx_netplay.h | 11 + runtime/include/sio.h | 14 +- runtime/src/gpu_gl_renderer.c | 37 +- runtime/src/main.cpp | 1279 ++++++++++++++++++++++------ runtime/src/psx_cycles.c | 3 + runtime/src/psx_keybinds.c | 116 ++- runtime/src/psx_lobby_client.c | 146 +++- runtime/src/psx_netplay.c | 54 +- runtime/src/sio.c | 144 +++- runtime/src/starvation_ring.c | 33 +- 17 files changed, 1602 insertions(+), 477 deletions(-) diff --git a/lib/recomp-net b/lib/recomp-net index 5b23c93c0..b06b847d6 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit 5b23c93c015e612835f800c0ab60eb2518fdc916 +Subproject commit b06b847d67f1b45b1b62d30aeb7758ffe70fe7bd diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 94ed4bcf9..555c6c156 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -447,6 +447,14 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& rt.deadzone = static_cast(n); rt.has_deadzone = true; } + if (ct.contains("multitap_port")) { + const auto n = toml::find(ct, "multitap_port"); + if (n != 1 && n != 2) + throw std::runtime_error(fmt::format( + "[controller] multitap_port must be 1 or 2, got {}", n)); + rt.multitap_port = static_cast(n); + rt.has_multitap_port = true; + } // LEGACY per-game pad-config opt-in (default modern). Tomba sets this so // its launcher Hybrid mode's analog<->digital type flip doesn't make libpad // manufacture a disconnect; no other title is affected. Full history and @@ -1506,39 +1514,56 @@ UserSettings load_user_settings(const fs::path& path) { } if (doc.contains("controller")) { const toml::value& ct = toml::find(doc, "controller"); - if (ct.contains("p1_device")) try_get([&]{ - const auto v = toml::find(ct, "p1_device"); - if (!v.empty()) { s.p1_device = v; s.has_p1_device = true; } - }); - if (ct.contains("p2_device")) try_get([&]{ - const auto v = toml::find(ct, "p2_device"); - if (!v.empty()) { s.p2_device = v; s.has_p2_device = true; } - }); - // Legacy boolean form first (true->analog, false->digital); the new - // string `*_mode` keys override when present. - if (ct.contains("p1_analog")) try_get([&]{ - s.p1_mode = toml::find(ct, "p1_analog") - ? PAD_MODE_ANALOG : PAD_MODE_DIGITAL; - s.has_p1_mode = true; - }); - if (ct.contains("p2_analog")) try_get([&]{ - s.p2_mode = toml::find(ct, "p2_analog") - ? PAD_MODE_ANALOG : PAD_MODE_DIGITAL; - s.has_p2_mode = true; - }); - if (ct.contains("p1_mode")) try_get([&]{ - s.p1_mode = pad_mode_from_string( - toml::find(ct, "p1_mode"), PAD_MODE_HYBRID); - s.has_p1_mode = true; - }); - if (ct.contains("p2_mode")) try_get([&]{ - s.p2_mode = pad_mode_from_string( - toml::find(ct, "p2_mode"), PAD_MODE_HYBRID); - s.has_p2_mode = true; - }); + static const char* kDevKeys[] = { + "p1_device", "p2_device", "p3_device", "p4_device", "p5_device"}; + static const char* kModeKeys[] = { + "p1_mode", "p2_mode", "p3_mode", "p4_mode", "p5_mode"}; + static const char* kDzKeys[] = { + "p1_deadzone", "p2_deadzone", "p3_deadzone", "p4_deadzone", + "p5_deadzone"}; + static const char* kAnalogKeys[] = { + "p1_analog", "p2_analog", "p3_analog", "p4_analog", "p5_analog"}; + for (int i = 0; i < UserSettings::kMaxControllerPlayers; ++i) { + if (ct.contains(kDevKeys[i])) try_get([&]{ + const auto v = toml::find(ct, kDevKeys[i]); + if (!v.empty()) { + s.p_device[i] = v; + s.has_p_device[i] = true; + } + }); + // Legacy boolean form first (true->analog, false->digital); the + // string `*_mode` keys override when present. + if (ct.contains(kAnalogKeys[i])) try_get([&]{ + s.p_mode[i] = toml::find(ct, kAnalogKeys[i]) + ? PAD_MODE_ANALOG : PAD_MODE_DIGITAL; + s.has_p_mode[i] = true; + }); + if (ct.contains(kModeKeys[i])) try_get([&]{ + s.p_mode[i] = pad_mode_from_string( + toml::find(ct, kModeKeys[i]), PAD_MODE_HYBRID); + s.has_p_mode[i] = true; + }); + if (ct.contains(kDzKeys[i])) try_get([&]{ + const auto n = toml::find(ct, kDzKeys[i]); + if (n >= 0 && n <= 32767) { + s.p_deadzone[i] = (int)n; + s.has_p_deadzone[i] = true; + } + }); + } if (ct.contains("deadzone")) try_get([&]{ const auto n = toml::find(ct, "deadzone"); - if (n >= 0 && n <= 32767) { s.deadzone = (int)n; s.has_deadzone = true; } + if (n >= 0 && n <= 32767) { + s.deadzone = (int)n; + s.has_deadzone = true; + /* Legacy global: fill any slot that was not given pN_deadzone. */ + for (int i = 0; i < UserSettings::kMaxControllerPlayers; ++i) { + if (!s.has_p_deadzone[i]) { + s.p_deadzone[i] = s.deadzone; + s.has_p_deadzone[i] = true; + } + } + } }); } return s; @@ -1632,19 +1657,36 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) { f << "enable2 = " << (s.memcard2_enabled ? "true" : "false") << "\n"; } - if (s.has_p1_device || s.has_p2_device || s.has_p1_mode || s.has_p2_mode || - s.has_deadzone) { - f << "\n[controller]\n"; - if (s.has_p1_device) - f << "p1_device = \"" << s.p1_device << "\"\n"; - if (s.has_p1_mode) - f << "p1_mode = \"" << pad_mode_to_string(s.p1_mode) << "\"\n"; - if (s.has_p2_device) - f << "p2_device = \"" << s.p2_device << "\"\n"; - if (s.has_p2_mode) - f << "p2_mode = \"" << pad_mode_to_string(s.p2_mode) << "\"\n"; - if (s.has_deadzone) - f << "deadzone = " << s.deadzone << "\n"; + { + bool any_ctrl = s.has_deadzone; + for (int i = 0; i < UserSettings::kMaxControllerPlayers; ++i) { + if (s.has_p_device[i] || s.has_p_mode[i] || s.has_p_deadzone[i]) + any_ctrl = true; + } + if (any_ctrl) { + static const char* kDevKeys[] = { + "p1_device", "p2_device", "p3_device", "p4_device", "p5_device"}; + static const char* kModeKeys[] = { + "p1_mode", "p2_mode", "p3_mode", "p4_mode", "p5_mode"}; + static const char* kDzKeys[] = { + "p1_deadzone", "p2_deadzone", "p3_deadzone", "p4_deadzone", + "p5_deadzone"}; + f << "\n[controller]\n"; + for (int i = 0; i < UserSettings::kMaxControllerPlayers; ++i) { + if (s.has_p_device[i]) + f << kDevKeys[i] << " = \"" << s.p_device[i] << "\"\n"; + if (s.has_p_mode[i]) + f << kModeKeys[i] << " = \"" + << pad_mode_to_string(s.p_mode[i]) << "\"\n"; + if (s.has_p_deadzone[i]) + f << kDzKeys[i] << " = " << s.p_deadzone[i] << "\n"; + } + /* Keep a global deadzone= for older readers (mirrors P1). */ + if (s.has_deadzone || s.has_p_deadzone[0]) + f << "deadzone = " + << (s.has_p_deadzone[0] ? s.p_deadzone[0] : s.deadzone) + << "\n"; + } } if (s.has_language) { diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index c1922e320..60a23a076 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -355,6 +355,12 @@ struct RuntimeConfig { bool has_deadzone = false; int deadzone = 0; + // multitap_port: console port that hosts the SCPH-1070 when offline/netplay + // arms multitap (players/slot_count >= 3). 1 = Port 1 (default, most games), + // 2 = Port 2 (Bomberman Party Edition, Jigsaw Madness, S.C.A.R.S., …). + bool has_multitap_port = false; + int multitap_port = 1; + // legacy_pad_config: per-game pad-protocol compatibility opt-in. false (default) // = the modern DualShock config state machine (proper 0x43 enter/exit, config id // 0xF3 only while in config) — required by MMX6 and the correct default for every @@ -823,22 +829,29 @@ struct UserSettings { bool has_memcard1_enabled = false; bool memcard1_enabled = true; bool has_memcard2_enabled = false; bool memcard2_enabled = true; - // [controller] — per-player input device + pad type. device is one of: + // [controller] — per-player input device + pad type + deadzone. + // device is one of: // "none" — no pad in this port (port not connected) // "keyboard" — driven by the keyboard map (input.ini) // "" — an SDL game-controller GUID (SDL_JoystickGetGUIDString) - // p1_mode/p2_mode select the emulated pad behaviour (see PadMode): - // hybrid (default) / analog / digital. Defaults: P1 keyboard, P2 none. - bool has_p1_device = false; std::string p1_device = "keyboard"; - bool has_p2_device = false; std::string p2_device = "none"; - // Pad input mode per player (see PadMode): hybrid (default) / analog / - // digital. Persisted as p1_mode/p2_mode strings. Legacy p1_analog/p2_analog - // booleans are still read for back-compat (true->analog, false->digital). - bool has_p1_mode = false; int p1_mode = PAD_MODE_HYBRID; - bool has_p2_mode = false; int p2_mode = PAD_MODE_HYBRID; - // Analog-stick deadzone, raw SDL axis units (0..32767). The launcher edits - // this as 0-100% (raw = pct*32767/100), mirroring snesrecomp's GamepadDeadzone. - bool has_deadzone = false; int deadzone = 12000; + // Modes (see PadMode): hybrid / analog / digital. Defaults: P1 keyboard, + // P2–P5 none. Deadzone default is 10% (3277/32767). TOML keys: + // pN_device / pN_mode / pN_deadzone (N=1..5). Legacy bare `deadzone` + // still fills any slot that lacks pN_deadzone. + static constexpr int kMaxControllerPlayers = 5; + bool has_p_device[kMaxControllerPlayers] = {}; + std::string p_device[kMaxControllerPlayers] = { + "keyboard", "none", "none", "none", "none"}; + bool has_p_mode[kMaxControllerPlayers] = {}; + int p_mode[kMaxControllerPlayers] = { + PAD_MODE_HYBRID, PAD_MODE_HYBRID, PAD_MODE_HYBRID, + PAD_MODE_HYBRID, PAD_MODE_HYBRID}; + bool has_p_deadzone[kMaxControllerPlayers] = {}; + int p_deadzone[kMaxControllerPlayers] = { + 3277, 3277, 3277, 3277, 3277}; /* ~10% of 32767 */ + // Legacy global deadzone (settings.toml `deadzone=`). Applied to slots + // that do not have an explicit pN_deadzone. Default 10%. + bool has_deadzone = false; int deadzone = 3277; // Localization: the launcher's chosen language code (feeds RuntimeConfig // .language / g_lang). "off"/"jp"/"" = untranslated native game. Persisted to // settings.toml [localization].language. diff --git a/runtime/include/psx_cyc.h b/runtime/include/psx_cyc.h index 158542f71..771465308 100644 --- a/runtime/include/psx_cyc.h +++ b/runtime/include/psx_cyc.h @@ -38,14 +38,10 @@ extern "C" { #endif -/* Load-charge batching (MotK VLC): accumulate into g_psx_cyc_batch instead of - * storing psx_cycle_count every insn. Absorb/fudge still update per insn — - * only the host counter publish is deferred until: - * - psx_cyc_batch_flush (IRQ / MMIO / savestate), or - * - the deferred batch grows past PSX_CYC_BATCH_SOFT (device deadline check), - * - or emitter BB-defer is active (g_psx_cyc_bb_defer): no mid-BB deadline - * probe at all; compiled branches already flush via psx_check_interrupts. - * Guest totals at those barriers are unchanged. */ +/* Load-charge batching (MotK VLC): cache the nearer of the next device + * deadline or a 64-cycle soft publication limit once per batch. Generated + * GCC/Clang blocks may defer to their IRQ edge; MMIO also flushes. Pipeline + * state still updates per instruction, and guest totals at barriers match. */ enum { PSX_CYC_BATCH_SOFT = 64u }; static inline void psx_cyc_bb_defer_begin(void) { g_psx_cyc_bb_defer++; } @@ -77,17 +73,25 @@ static inline void psx_cyc_charge(uint32_t cycles) { } #if !defined(PSX_COSIM) { - uint32_t sum = g_psx_cyc_batch + cycles; - if (sum >= g_psx_cyc_batch) { /* no uint32 wrap */ - g_psx_cyc_batch = sum; - /* Compiled BB defer: IRQ edges publish. Otherwise probe deadline - * only after a soft quantum so MotK VLC doesn't 64-bit-compare - * on every LW. */ - if (g_psx_cyc_bb_defer > 0) return; - if (sum < (uint32_t)PSX_CYC_BATCH_SOFT) return; - uint64_t next = psx_cycle_count + (uint64_t)sum; - if (psx_next_service_cycle != 0u && next < psx_next_service_cycle) + uint32_t prior = g_psx_cyc_batch; + uint32_t sum = prior + cycles; + if (sum >= prior) { /* no uint32 wrap */ + if (g_psx_cyc_bb_defer > 0) { + g_psx_cyc_batch = sum; return; + } + if (prior == 0u) { + uint64_t room = 0u; + if (psx_next_service_cycle > psx_cycle_count) + room = psx_next_service_cycle - psx_cycle_count; + g_psx_cyc_batch_limit = room == 0u ? 1u : + (room < (uint64_t)PSX_CYC_BATCH_SOFT + ? (uint32_t)room : (uint32_t)PSX_CYC_BATCH_SOFT); + } + if (sum < g_psx_cyc_batch_limit) { + g_psx_cyc_batch = sum; + return; + } } } #endif diff --git a/runtime/include/psx_cycles.h b/runtime/include/psx_cycles.h index 737030808..a47549118 100644 --- a/runtime/include/psx_cycles.h +++ b/runtime/include/psx_cycles.h @@ -54,8 +54,10 @@ extern int g_ls_replay_active; * psx_advance_cycles before IRQ checks, MMIO, or any cycle read that must * match the published counter. Guest totals at those barriers are unchanged. */ extern uint32_t g_psx_cyc_batch; -/* Emitter BB-defer depth: when >0, psx_cyc_charge skips mid-BB deadline - * probes (compiled IRQ edges / psx_cyc_bb_defer_end publish). */ +extern uint32_t g_psx_cyc_batch_limit; + +/* GCC/Clang-generated functions can defer deadline probes within a basic + * block. Interrupt/MMIO edges still publish the accumulated guest cycles. */ extern int g_psx_cyc_bb_defer; /* Advance guest time. The common production path is inlined: bump the @@ -71,6 +73,7 @@ static inline void psx_advance_cycles(uint32_t cycles) { if (g_psx_cyc_batch) { uint32_t b = g_psx_cyc_batch; g_psx_cyc_batch = 0; + g_psx_cyc_batch_limit = 0; if (cycles <= UINT32_MAX - b) cycles += b; else { /* Extreme: publish b first, then continue with cycles. */ @@ -114,6 +117,7 @@ static inline void psx_cyc_batch_flush(void) { uint32_t b = g_psx_cyc_batch; if (!b) return; g_psx_cyc_batch = 0; + g_psx_cyc_batch_limit = 0; psx_advance_cycles(b); #endif } diff --git a/runtime/include/psx_keybinds.h b/runtime/include/psx_keybinds.h index a9d186ba0..c5e86a171 100644 --- a/runtime/include/psx_keybinds.h +++ b/runtime/include/psx_keybinds.h @@ -19,15 +19,18 @@ extern "C" { * This is the KEYBOARD map only. Game-controller (gamepad) button mapping is a * separate concern handled by input.ini (see main.cpp load_input_config). * - * Two players. Player 1 defaults reproduce the framework's historical hardcoded - * keyboard mapping (so out-of-the-box behaviour is unchanged); Player 2 is - * unbound by default (fill it in to drive a second keyboard player). + * Up to PSXKB_MAX_PLAYERS keyboard players (P1..P5). Every player ships with the + * same default keyboard map (the framework's historical P1 layout); "Reset to + * Defaults" restores that map for any slot. Simultaneous multi-keyboard play + * still needs distinct binds per slot — only one physical keyboard. * * The launcher's Controls page edits these live through the rebind API below * (get/set/reset/save) and persists to the same keybinds.ini; the runtime * re-reads the file at startup via psx_keybinds_init. */ +#define PSXKB_MAX_PLAYERS 5 + /* Button indices — stable order, matches the order keybinds.ini writes and the * order the launcher's rebind chips are laid out. Keep in sync with kButtons[] * in psx_keybinds.c. */ @@ -53,8 +56,7 @@ typedef struct { } PsxPlayerBinds; typedef struct { - PsxPlayerBinds p1; - PsxPlayerBinds p2; + PsxPlayerBinds player[PSXKB_MAX_PLAYERS]; /* [0]=P1 .. [4]=P5 */ } PsxKeyBinds; /* Initialize from /keybinds.ini. Generates a default file if one does @@ -65,7 +67,7 @@ void psx_keybinds_init(const char *exe_path); /* Read-only view of the current bindings. */ const PsxKeyBinds *psx_keybinds_get(void); -/* ── Runtime read helpers (keyboard -> PSX pad), player is 1 or 2 ──────────── */ +/* ── Runtime read helpers (keyboard -> PSX pad), player is 1..PSXKB_MAX_PLAYERS */ /* Build the 16-bit ACTIVE-LOW PSX button word for `player` from the SDL * keyboard state (bits per the standard PSX pad word; unbound inputs never @@ -85,13 +87,13 @@ int psx_keybinds_dpad_active(const uint8_t *keys, int player); /* ── Rebind API (launcher Controls page) ──────────────────────────────────── */ /* Buttons are indexed 0..PSX_KB_COUNT-1 (see PsxKeybindButton). Scancodes, not - * keycodes (keybinds.ini stores SDL scancode names). */ + * keycodes (keybinds.ini stores SDL scancode names). Players are 1-based. */ int psx_keybinds_button_count(void); const char *psx_keybinds_button_name(int button); /* "up".."rs_right" */ const char *psx_keybinds_button_label(int button); /* pretty, e.g. "Cross" */ SDL_Scancode psx_keybinds_get_button(int player, int button); void psx_keybinds_set_button(int player, int button, SDL_Scancode sc); -/* Reset one player's bindings to the built-in defaults (P2 = all unbound). */ +/* Reset one player's bindings to the built-in defaults (same map for every slot). */ void psx_keybinds_reset_player(int player); /* Persist the current bindings to keybinds.ini (path resolved by * psx_keybinds_init; call that first). */ diff --git a/runtime/include/psx_lobby_client.h b/runtime/include/psx_lobby_client.h index 85445e0fe..66d83ef28 100644 --- a/runtime/include/psx_lobby_client.h +++ b/runtime/include/psx_lobby_client.h @@ -12,7 +12,7 @@ extern "C" { #define PSX_LOBBY_VERSION_LEN 32 #define PSX_LOBBY_ENDPOINT_LEN 64 #define PSX_LOBBY_MAX_LIST 32 -#define PSX_LOBBY_MAX_MEMBERS 5 +#define PSX_LOBBY_MAX_MEMBERS 8 #define PSX_LOBBY_LANG_LEN 16 #ifndef PSX_GAME_VERSION @@ -49,6 +49,7 @@ typedef struct PsxLobbyMatchCaps { int fast_boot; /* 0/1 */ int auto_skip_fmv; /* 0/1 */ int input_delay; /* recomp-net delay frames */ + int force_input_relay; /* 0/1 — server input relay (vs P2P) */ char language[PSX_LOBBY_LANG_LEN]; } PsxLobbyMatchCaps; @@ -90,7 +91,7 @@ void psx_lobby_pump(void); void psx_lobby_set_game_identity(const char *game_name, const char *game_version); const char *psx_lobby_game_version(void); -/* Default max_slots for create (clamped 2..5, default 2). */ +/* Default max_slots for create (clamped 2..8, default 2). */ void psx_lobby_set_max_slots(int max_slots); void psx_lobby_request_list(void); diff --git a/runtime/include/psx_netplay.h b/runtime/include/psx_netplay.h index dce467149..3d3e56255 100644 --- a/runtime/include/psx_netplay.h +++ b/runtime/include/psx_netplay.h @@ -1,6 +1,7 @@ #ifndef PSX_NETPLAY_H #define PSX_NETPLAY_H +#include #include #ifdef __cplusplus @@ -46,8 +47,10 @@ typedef struct PsxNetplayConfig { int enabled; int local_slot; /* 0 .. slot_count-1 */ int slot_count; /* 2 .. PSX_MAX_PLAYERS (session pad count) */ + int player_count; /* seated players at launch (0 = use slot_count) */ int input_player; /* host device index; -1 = auto */ int input_delay; + int force_input_relay; /* 1 = lobby-server UDP input relay */ uint32_t session_id; char bind_hostport[64]; char peer_hostport[64]; @@ -63,6 +66,14 @@ int psx_netplay_local_slot(void); int psx_netplay_input_player(void); uint32_t psx_netplay_sim_tick(void); +/* + * Snapshot for diagnostic dumps (starvation_dump.jsonl meta, etc.). + * arch_out: "off" | "p2p" | "host_relay" | "server_relay" (never NULL when + * arch_cap > 0). Returns 1 when netplay is/was configured this run. + */ +int psx_netplay_diag_snapshot(char *arch_out, size_t arch_cap, + int *max_players_out, int *player_count_out); + int psx_netplay_start(const PsxNetplayConfig *cfg); void psx_netplay_shutdown(void); diff --git a/runtime/include/sio.h b/runtime/include/sio.h index 7ecf8dd55..49e146f4d 100644 --- a/runtime/include/sio.h +++ b/runtime/include/sio.h @@ -67,12 +67,18 @@ uint32_t sio_cycles_to_irq(uint32_t i_mask); uint64_t sio_get_advance_called(void); uint64_t sio_get_advance_with_work(void); -/* SCPH-1070 multitap on physical port 1 (SIO slot bit 0). Off by default. - * When enabled (and PSX_MAX_PLAYERS>=5): port1 bulk-polls logical pads 0–3; - * port2 is a single pad at logical index 4. When disabled / MAX==2: today's - * mapping (port1=pad0, port2=pad1). */ +/* SCPH-1070 multitap. Off by default. When enabled (PSX_MAX_PLAYERS>=5): + * multitap_port==0 (console Port 1): tap on phys0 → logical pads 0–3, + * phys1 → logical 4. + * multitap_port==1 (console Port 2): phys0 → logical 0, tap on phys1 → + * logical pads 1–4 (Bomberman Party Edition and a few others). + * Bulk 0x80 responses follow the real TAP/REQ latch (psx-spx): REQ=1 in the + * third command byte arms the *next* transfer; empty tap slots are fine. */ void sio_set_multitap(int enabled); int sio_get_multitap(void); +/* phys_port: 0 = console Port 1, 1 = console Port 2. Default 0. */ +void sio_set_multitap_port(int phys_port); +int sio_get_multitap_port(void); /* Update pad button state. Buttons use PS1 convention: 0=pressed, 1=released. Bit layout: SELECT, L3, R3, START, UP, RIGHT, DOWN, LEFT, diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 9b723529f..0d928b4e7 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -2555,7 +2555,10 @@ void gl_renderer_present(const uint32_t *pixels, int src_w, int src_h, int linea * with the right edge clipped. Letterbox within the present rect instead. * Apply whenever the source is short — not only when force_4_3 — so a * misclassified FMV frame still keeps correct pixel aspect. */ - if (src_h > 0 && src_h < 240) { + /* Genuinely windowed video bands only (<80% of the 240-line field, e.g. + * MotK's 128-line FMV). A game's native short display mode (216/224) + * fills the rect as on hardware. */ + if (src_h > 0 && src_h < 192) { int content_h = (lh * src_h) / 240; if (content_h < 1) content_h = 1; ly += (lh - content_h) / 2; @@ -2575,10 +2578,16 @@ void gl_renderer_present(const uint32_t *pixels, int src_w, int src_h, int linea upload_present_tex(pixels, src_w, src_h, linear); p_glUseProgram(s_present_prog); p_glUniform1i(s_present_uTex, 0); if (crop) { - p_glUniform4f(s_present_uUvRect, 0.f, 0.f, uv_x1, 1.f); - } else if (!linear && src_w > 0 && src_h > 0) { - /* Nearest: half-texel UV inset so UV=1.0 never grazes past the last - * column into undefined border samples on some drivers. */ + /* Cropped present keeps left-aligned content; still inset so linear + * AA does not blend the cut column with undefined border texels. */ + float u0 = (src_w > 0) ? (0.5f / (float)src_w) : 0.f; + float v0 = (src_h > 0) ? (0.5f / (float)src_h) : 0.f; + p_glUniform4f(s_present_uUvRect, u0, v0, uv_x1 - u0, 1.f - v0); + } else if (src_w > 0 && src_h > 0) { + /* Half-texel UV inset for both nearest and linear. Corner-mapped + * UV=1.0 grazes past the last texel (driver-dependent border sample); + * with GL_LINEAR that also blends an edge stripe into the image. + * Matches present_target_quad / MotK present UV edge-bleed fix. */ float u0 = 0.5f / (float)src_w, v0 = 0.5f / (float)src_h; p_glUniform4f(s_present_uUvRect, u0, v0, 1.f - u0, 1.f - v0); } else { @@ -3488,9 +3497,13 @@ static void present_target_quad(GLuint tex, int tex_w, int tex_h, glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, linear ? GL_LINEAR : GL_NEAREST); p_glUseProgram(s_present_prog); p_glUniform1i(s_present_uTex, 0); + /* Half-texel inset: with GL_LINEAR, corner-mapped UVs make the outermost + * dest pixels blend the border texel with VRAM outside the content rect + * (visible edge stripe with AA on). Center-mapped UVs keep edge samples + * inside the rect; interior sampling is unchanged. */ p_glUniform4f(s_present_uUvRect, - (float)x / (float)tex_w, (float)y / (float)tex_h, - (float)(x + w) / (float)tex_w, (float)(y + h) / (float)tex_h); + ((float)x + 0.5f) / (float)tex_w, ((float)y + 0.5f) / (float)tex_h, + ((float)(x + w) - 0.5f) / (float)tex_w, ((float)(y + h) - 0.5f) / (float)tex_h); p_glBindVertexArray(s_present_vao); glDrawArrays(GL_TRIANGLES, 0, 3); p_glBindVertexArray(0); @@ -3519,13 +3532,9 @@ void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); else letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); - /* Match CPU present: short GP1(07h) bands letterbox inside the rect. */ - if (h > 0 && h < 240) { - int content_h = (lh * h) / 240; - if (content_h < 1) content_h = 1; - ly += (lh - content_h) / 2; - lh = content_h; - } + /* No short-band adjustment on the 15-bit FBO path: a game's native short + * display mode (e.g. 216/224-line menus) must fill the rect as before. + * The FMV band fix lives in the depth24/CPU present paths only. */ p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); glDisable(GL_SCISSOR_TEST); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 967c7c8a3..da038036d 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -97,9 +97,11 @@ extern "C" void psx_event_step_conservative_env_init(void); #include #include #include +#include #include #include #include +#include #include #include #endif @@ -294,6 +296,7 @@ struct PlayerInput { * input), false => currently presenting a digital pad (D-pad was last). */ int mode = PSXRecompV4::PAD_MODE_HYBRID; bool hybrid_analog = false; + int deadzone = 3277; /* raw SDL axis units, ~10% default */ SDL_GameController* handle = nullptr; SDL_JoystickID instance = -1; }; @@ -1779,7 +1782,9 @@ struct PsxButtonMap { }; static int controller_device_index = 0; -static int controller_deadzone = 12000; +/* Default ~10% of SDL axis range (32767). Overridden per-player via settings. */ +static int controller_deadzone = 3277; +static constexpr int kDefaultDeadzoneRaw = 3277; static std::array controller_map = {{ { PAD_UP, "up", {} }, { PAD_DOWN, "down", {} }, @@ -2030,7 +2035,7 @@ static std::string default_input_ini_text(void) { "[controller]\n" "enabled = true\n" "device = 0\n" - "deadzone = 12000\n" + "deadzone = 3277\n" "\n" "[mapping]\n" "up = dpup,lefty-\n" @@ -2177,6 +2182,15 @@ static int pad_mode_boot_analog(int mode) { mode == PSXRecompV4::PAD_MODE_HYBRID) ? 1 : 0; } +/* Keyboard has no DualShock sticks/config handshake — always present as a + * plain digital pad (SCPH-1080). Leaving keyboard slots in ANALOG/HYBRID made + * P2–P5 show as connected while games that expect digital multitap pads never + * saw usable button input. */ +static int effective_player_mode(const PlayerInput& p) { + if (p.kind == 1) return (int)PSXRecompV4::PAD_MODE_DIGITAL; + return p.mode; +} + /* Open/close SDL handles so they match g_players, and (re)assert each slot's * PSX connection + pad type. Safe to call repeatedly (hotplug, boot). * While delay-sync netplay is active, SIO connection/type are owned by @@ -2188,13 +2202,14 @@ static void refresh_player_devices(void) { if (p.kind != 2) close_player(p); /* keyboard/none: no handle */ else open_player(p, s); if (netplay) continue; + const int mode = effective_player_mode(p); sio_set_pad_connected(s, p.kind != 0 ? 1 : 0); - sio_set_pad_analog(s, pad_mode_boot_analog(p.mode), 0x80, 0x80, 0x80, 0x80); + sio_set_pad_analog(s, pad_mode_boot_analog(mode), 0x80, 0x80, 0x80, 0x80); /* DIGITAL mode == a plain digital controller that ignores the DualShock * config-mode commands (real SCPH-1080 behaviour); ANALOG/HYBRID == a * config-capable DualShock. A digital pad that wrongly answered 0x43 * sent Tomba 2's pad driver down the config path -> phantom 0x00 reads. */ - sio_set_pad_config_capable(s, p.mode != PSXRecompV4::PAD_MODE_DIGITAL); + sio_set_pad_config_capable(s, mode != PSXRecompV4::PAD_MODE_DIGITAL); } } @@ -2227,8 +2242,10 @@ static void set_player_device(PlayerInput& p, const std::string& dev, int mode) * component became a D-Pad bit. Analog stick bytes still use radial rescale * in axes_to_pad_pair; this path is digital buttons only. Triggers share the * same per-axis threshold. */ -static bool controller_source_pressed_h(SDL_GameController* h, const ControllerSource& source) { +static bool controller_source_pressed_h(SDL_GameController* h, const ControllerSource& source, + int deadzone_raw) { if (!h) return false; + const int dz = deadzone_raw > 0 ? deadzone_raw : controller_deadzone; switch (source.kind) { case ControllerSource::Kind::Button: @@ -2238,8 +2255,8 @@ static bool controller_source_pressed_h(SDL_GameController* h, const ControllerS case ControllerSource::Kind::AxisNegative: { const int16_t v = SDL_GameControllerGetAxis(h, (SDL_GameControllerAxis)source.id); if (source.kind == ControllerSource::Kind::AxisPositive) - return v > controller_deadzone; - return v < -controller_deadzone; + return v > dz; + return v < -dz; } case ControllerSource::Kind::None: default: @@ -2278,13 +2295,14 @@ static bool source_is_stick_axis(const ControllerSource& s) { * left/right analog-stick axes do NOT contribute button bits — see * source_is_stick_axis above. Digital mode passes false, so the stick still * folds onto the D-pad (its only outlet there). */ -static uint16_t controller_pad_buttons(SDL_GameController* h, bool suppress_stick_axes) { +static uint16_t controller_pad_buttons(SDL_GameController* h, bool suppress_stick_axes, + int deadzone_raw) { uint16_t buttons = 0xFFFF; /* all released */ if (!h) return buttons; for (const auto& entry : controller_map) { for (const auto& source : entry.sources) { if (suppress_stick_axes && source_is_stick_axis(source)) continue; - if (controller_source_pressed_h(h, source)) { + if (controller_source_pressed_h(h, source, deadzone_raw)) { buttons &= (uint16_t)~entry.bit; break; } @@ -2302,8 +2320,9 @@ static uint16_t controller_pad_buttons(SDL_GameController* h, bool suppress_stic * capped at 32767 before rescale so a full-diagonal push (raw mag ~46341) maps * to ~0x9E/0x9E per axis — the circular gate a real DualShock stick reports, * not 0xFF/0xFF. At dz==0 it reduces to a plain magnitude-preserving map. */ -static void axes_to_pad_pair(int16_t vx, int16_t vy, uint8_t* obx, uint8_t* oby) { - const double dz = controller_deadzone; +static void axes_to_pad_pair(int16_t vx, int16_t vy, uint8_t* obx, uint8_t* oby, + int deadzone_raw) { + const double dz = (double)(deadzone_raw > 0 ? deadzone_raw : controller_deadzone); double x = vx, y = vy; double mag = std::sqrt(x * x + y * y); /* 0 .. ~46341 */ if (mag <= dz || mag <= 0.0) { *obx = 0x80; *oby = 0x80; return; } @@ -2323,10 +2342,10 @@ static void axes_to_pad_pair(int16_t vx, int16_t vy, uint8_t* obx, uint8_t* oby) } /* Buttons for a player's selected device (0xFFFF = none pressed). `player` is - * 1 or 2 — selects which keybinds.ini section drives a keyboard port. */ + * 1..5 — selects which keybinds.ini section drives a keyboard port. */ static uint16_t pad_buttons_for(const PlayerInput& p, int player, bool suppress_stick_axes) { if (p.kind == 1) return pad_from_keyboard(player); - if (p.kind == 2) return controller_pad_buttons(p.handle, suppress_stick_axes); + if (p.kind == 2) return controller_pad_buttons(p.handle, suppress_stick_axes, p.deadzone); return 0xFFFF; } @@ -2356,10 +2375,10 @@ static void pad_sticks_for(const PlayerInput& p, int player, uint8_t out[4], boo if (p.kind == 2 && p.handle) { axes_to_pad_pair(SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_LEFTX), SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_LEFTY), - &out[0], &out[1]); + &out[0], &out[1], p.deadzone); axes_to_pad_pair(SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_RIGHTX), SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_RIGHTY), - &out[2], &out[3]); + &out[2], &out[3], p.deadzone); if (fold_dpad) { if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_LEFT)) out[0] = 0x00; if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_RIGHT)) out[0] = 0xFF; @@ -2378,7 +2397,8 @@ static bool hybrid_stick_active(const PlayerInput& p) { if (p.kind != 2 || !p.handle) return false; const double lx = SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_LEFTX); const double ly = SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_LEFTY); - return std::sqrt(lx * lx + ly * ly) > (double)controller_deadzone; + const double dz = (double)(p.deadzone > 0 ? p.deadzone : controller_deadzone); + return std::sqrt(lx * lx + ly * ly) > dz; } static bool hybrid_dpad_active(const PlayerInput& p, int player, bool kb_always) { if (p.kind == 2 && p.handle) { @@ -2412,13 +2432,16 @@ static bool hybrid_dpad_active(const PlayerInput& p, int player, bool kb_always) * pad TYPE is left unchanged: a launcher-assigned analog DualShock still presents * as analog (so the game's analog input path / SIO handshake cadence is preserved * exactly), and merged sources only contribute button/stick STATE, never a type - * downgrade. Controlled by PSX_DEV_INPUT (default ON for the dev workflow); set - * PSX_DEV_INPUT=0 to restore strict single-device-per-port routing. */ + * downgrade. + * + * Default OFF: each player slot accepts input only from its launcher-assigned + * device (keyboard XOR that controller). Opt in with PSX_DEV_INPUT=1 for the + * old "any device drives P1" workflow. */ static bool dev_any_input_enabled() { static int cached = -1; if (cached < 0) { const char* e = std::getenv("PSX_DEV_INPUT"); - cached = (e && (e[0] == '0' || e[0] == 'n' || e[0] == 'N' || e[0] == 'f' || e[0] == 'F')) ? 0 : 1; + cached = (e && (e[0] == '1' || e[0] == 'y' || e[0] == 'Y' || e[0] == 't' || e[0] == 'T')) ? 1 : 0; } return cached != 0; } @@ -2437,7 +2460,7 @@ static uint16_t dev_all_controllers_buttons(bool suppress_stick_axes) { SDL_JoystickID inst = SDL_JoystickGetDeviceInstanceID(i); SDL_GameController* h = SDL_GameControllerFromInstanceID(inst); if (!h) h = SDL_GameControllerOpen(i); /* open once; SDL keeps it */ - if (h) btn &= controller_pad_buttons(h, suppress_stick_axes); + if (h) btn &= controller_pad_buttons(h, suppress_stick_axes, controller_deadzone); } return btn; } @@ -2456,9 +2479,11 @@ static void dev_any_controller_sticks(uint8_t st[4]) { if (!h) continue; uint8_t lx, ly, rx, ry; axes_to_pad_pair(SDL_GameControllerGetAxis(h, SDL_CONTROLLER_AXIS_LEFTX), - SDL_GameControllerGetAxis(h, SDL_CONTROLLER_AXIS_LEFTY), &lx, &ly); + SDL_GameControllerGetAxis(h, SDL_CONTROLLER_AXIS_LEFTY), + &lx, &ly, controller_deadzone); axes_to_pad_pair(SDL_GameControllerGetAxis(h, SDL_CONTROLLER_AXIS_RIGHTX), - SDL_GameControllerGetAxis(h, SDL_CONTROLLER_AXIS_RIGHTY), &rx, &ry); + SDL_GameControllerGetAxis(h, SDL_CONTROLLER_AXIS_RIGHTY), + &rx, &ry, controller_deadzone); if (lx != 0x80 || ly != 0x80) { st[0] = lx; st[1] = ly; } if (rx != 0x80 || ry != 0x80) { st[2] = rx; st[3] = ry; } } @@ -2488,7 +2513,7 @@ static void apply_input_override_to_sio(int override_word) { const bool dpad_live = ((uint16_t)~w & 0x00F0u) != 0; /* up/right/down/left */ int mode; - if (p.kind != 0) mode = p.mode; + if (p.kind != 0) mode = effective_player_mode(p); else if (dev_any_input_enabled()) mode = (int)PSXRecompV4::PAD_MODE_HYBRID; else mode = (int)PSXRecompV4::PAD_MODE_DIGITAL; @@ -2517,10 +2542,9 @@ static int capture_pad_slot(int s, PsxNetPad* out) { out->connected = 0; PlayerInput& p = g_players[s]; - const int player = s + 1; /* keybinds.ini section (1|2) */ - /* Dev input: P1 is driven by the keyboard AND every connected controller, - * so a tester can navigate from whatever is plugged in (P2 keeps strict - * per-port routing). */ + const int player = s + 1; /* keybinds.ini section (1..5) */ + /* Opt-in dev merge: P1 is driven by the keyboard AND every connected + * controller (PSX_DEV_INPUT=1). Default is strict per-slot routing. */ const bool dev_here = (dev_any_input_enabled() && s == 0); if (p.kind == 0 && !dev_here) return 0; /* no device in this port */ @@ -2528,13 +2552,10 @@ static int capture_pad_slot(int s, PsxNetPad* out) { * state gates how the left stick is read for BOTH the button word and the * analog axes below. An assigned device keeps its configured mode (a * launcher-selected analog DualShock stays analog, so its input path / SIO - * handshake cadence is preserved exactly). A P1 with no assigned device but - * dev-any-input on presents as HYBRID — boots analog like a DualShock and - * auto-drops to digital on the d-pad — so any plugged controller and the - * keyboard both navigate. The hybrid latch reads raw device state, so it is - * safe to resolve here before the button word is built. */ + * handshake cadence is preserved exactly). Keyboard is always digital. + * A P1 with no assigned device but dev-any-input on presents as HYBRID. */ int mode; - if (p.kind != 0) mode = p.mode; + if (p.kind != 0) mode = effective_player_mode(p); else if (dev_here) mode = (int)PSXRecompV4::PAD_MODE_HYBRID; else mode = (int)PSXRecompV4::PAD_MODE_DIGITAL; int eff_analog; @@ -2602,11 +2623,11 @@ static int capture_pad_slot_exclusive(int s, PsxNetPad* out) { out->connected = 0; PlayerInput& p = g_players[s]; - const int player = s + 1; /* keybinds.ini section (1|2) */ + const int player = s + 1; /* keybinds.ini section (1..5) */ const bool dev_here = false; if (p.kind == 0) return 0; /* no device in this port */ - int mode = p.mode; + int mode = effective_player_mode(p); int eff_analog; if (mode == PSXRecompV4::PAD_MODE_DIGITAL) { eff_analog = 0; @@ -2677,7 +2698,7 @@ static void capture_override_pad(int override_word, PsxNetPad* out) { const bool dpad_live = ((uint16_t)~w & 0x00F0u) != 0; int mode; - if (p.kind != 0) mode = p.mode; + if (p.kind != 0) mode = effective_player_mode(p); else if (dev_any_input_enabled()) mode = (int)PSXRecompV4::PAD_MODE_HYBRID; else mode = (int)PSXRecompV4::PAD_MODE_DIGITAL; @@ -3265,10 +3286,16 @@ static void sdl_vblank_present(void) { if (psx_netplay_active()) { psx_netplay_finish_frame(); } else { - /* Offline N-pad: enable multitap only once the game EXE is running. */ + /* Offline N-pad: enable multitap only once the game EXE is running. + * Port comes from game.toml [controller] multitap_port (default Port 1; + * BPE uses Port 2). Empty tap slots are OK — P1 may be the standalone + * pad on the other port. */ if (g_offline_pad_count >= 3 && fntrace_is_game_started() && !sio_get_multitap()) { sio_set_multitap(1); + std::fprintf(stdout, + "psxrecomp: multitap armed (console Port %d)\n", + sio_get_multitap_port() + 1); } if (g_headless) sample_headless_pad_into_sio(override); @@ -3938,6 +3965,23 @@ namespace { std::filesystem::path g_lnch_settings_path; std::string g_lnch_lobby_url; RecompLauncherCNetplayLaunch g_lnch_pending_direct_launch{}; + int g_lnch_lobby_input_delay = 2; + int g_lnch_force_input_relay = 0; + int g_lnch_host_max_slots = 2; + + /* Delay-sync READY/START waits for every seat in slot_count. Use seated + * players (not lobby max_slots) so a 3/5 room can start. Sparse seats + * (moved) still need width covering the highest occupied index. */ + static int ae_np_session_slot_count(int player_count, int max_slots, + int local_slot, int game_fallback) { + int slots = player_count >= 2 ? player_count + : (max_slots >= 2 ? max_slots + : (game_fallback >= 2 ? game_fallback : 2)); + if (local_slot + 1 > slots) slots = local_slot + 1; + if (slots < 2) slots = 2; + if (slots > PSX_MAX_PLAYERS) slots = PSX_MAX_PLAYERS; + return slots; + } bool g_lnch_hosting_lan = false; bool g_lnch_joined_lan = false; /* Join Direct / cross-machine: membership via UDP, not the local file. */ @@ -3945,18 +3989,24 @@ namespace { std::string g_lnch_lan_endpoint; uint32_t g_lnch_lan_session_id = 1; + static constexpr int kAeLanMaxSlots = RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS; struct AeLanLobbyState { std::string name; std::string game; std::string endpoint; std::string host_name; - std::string joiner_name; + std::string joiner_name; /* legacy / first guest mirror */ std::string password; bool started = false; int host_slot = 0; + int max_slots = 2; uint32_t session_id = 1; + std::string slot_name[kAeLanMaxSlots]; + std::string slot_id[kAeLanMaxSlots]; /* stable per-seat player id */ }; AeLanLobbyState g_lnch_remote_lan_state{}; + int g_lnch_lan_my_slot = -1; + std::string g_lnch_lan_player_id; /* local process identity for LAN seats */ #ifdef _WIN32 using AeLanSock = SOCKET; @@ -3966,8 +4016,8 @@ namespace { static constexpr AeLanSock kAeLanSockInvalid = -1; #endif AeLanSock g_lnch_lan_udp = kAeLanSockInvalid; - sockaddr_in g_lnch_lan_peer{}; - bool g_lnch_lan_peer_valid = false; + sockaddr_in g_lnch_lan_peers[kAeLanMaxSlots]{}; + bool g_lnch_lan_peer_ok[kAeLanMaxSlots]{}; uint32_t g_lnch_lan_join_pulse_ms = 0; std::filesystem::path ae_np_lan_file() { @@ -3986,7 +4036,150 @@ namespace { static void ae_np_lan_udp_close(void) { ae_np_lan_sock_close(&g_lnch_lan_udp); - g_lnch_lan_peer_valid = false; + for (int i = 0; i < kAeLanMaxSlots; ++i) + g_lnch_lan_peer_ok[i] = false; + } + + static void ae_np_lan_sync_legacy_names(AeLanLobbyState& state) { + if (state.max_slots < 2) state.max_slots = 2; + if (state.max_slots > kAeLanMaxSlots) state.max_slots = kAeLanMaxSlots; + if (state.host_slot < 0 || state.host_slot >= state.max_slots) + state.host_slot = 0; + if (!state.slot_name[state.host_slot].empty()) + state.host_name = state.slot_name[state.host_slot]; + else if (!state.host_name.empty()) + state.slot_name[state.host_slot] = state.host_name; + state.joiner_name.clear(); + for (int i = 0; i < state.max_slots; ++i) { + if (i == state.host_slot) continue; + if (!state.slot_name[i].empty()) { + state.joiner_name = state.slot_name[i]; + break; + } + } + } + + static int ae_np_lan_occupied(const AeLanLobbyState& state) { + int n = 0; + for (int i = 0; i < state.max_slots && i < kAeLanMaxSlots; ++i) + if (!state.slot_name[i].empty()) ++n; + return n; + } + + static int ae_np_lan_find_free_slot(const AeLanLobbyState& state) { + for (int i = 0; i < state.max_slots && i < kAeLanMaxSlots; ++i) { + if (i == state.host_slot) continue; + if (state.slot_name[i].empty()) return i; + } + return -1; + } + + /* Guest seat by display name. Never match host_slot — same-machine + * instances often share settings.toml player_name, and reclaiming the + * host seat made JOIN "succeed" with no visible joiner. */ + static int ae_np_lan_find_guest_slot_by_name(const AeLanLobbyState& state, + const char* name) { + if (!name || !name[0]) return -1; + for (int i = 0; i < state.max_slots && i < kAeLanMaxSlots; ++i) { + if (i == state.host_slot) continue; + if (state.slot_name[i] == name) return i; + } + return -1; + } + + static int ae_np_lan_find_slot_by_id(const AeLanLobbyState& state, + const char* player_id) { + if (!player_id || !player_id[0]) return -1; + for (int i = 0; i < state.max_slots && i < kAeLanMaxSlots; ++i) { + if (state.slot_id[i] == player_id) return i; + } + return -1; + } + + /* Stable per-process LAN identity (prefer WS player_id when connected). */ + static const char* ae_np_lan_local_player_id(void) { + if (!g_lnch_lan_player_id.empty()) return g_lnch_lan_player_id.c_str(); + const char* ws = psx_lobby_player_id(); + if (ws && ws[0]) { + g_lnch_lan_player_id = ws; + return g_lnch_lan_player_id.c_str(); + } + unsigned char b[16]; + bool ok = false; +#if !defined(_WIN32) + FILE* ur = std::fopen("/dev/urandom", "rb"); + if (ur) { + ok = std::fread(b, 1, sizeof(b), ur) == sizeof(b); + std::fclose(ur); + } +#endif + if (!ok) { + uint64_t t = (uint64_t)SDL_GetTicks() + ^ ((uint64_t)(uintptr_t)&g_lnch_lan_player_id << 32) + ^ ((uint64_t)SDL_GetPerformanceCounter() << 17); + for (size_t i = 0; i < sizeof(b); ++i) { + t = t * 6364136223846793005ull + 1ull; + b[i] = (unsigned char)(t >> 56); + } + } + char hex[40]; + std::snprintf(hex, sizeof(hex), + "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], + b[10], b[11], b[12], b[13], b[14], b[15]); + g_lnch_lan_player_id = hex; + return g_lnch_lan_player_id.c_str(); + } + + /* Exact-name uniqueness among seats: Alex, Alex (2), Alex (3), … */ + static std::string ae_np_lan_unique_display_name(const AeLanLobbyState& state, + const char* requested, + int skip_slot) { + std::string base = (requested && requested[0]) ? requested : "Player"; + auto taken = [&](const std::string& n) { + for (int i = 0; i < state.max_slots && i < kAeLanMaxSlots; ++i) { + if (i == skip_slot) continue; + if (state.slot_name[i] == n) return true; + } + return false; + }; + if (!taken(base)) return base; + for (int n = 2; n <= 64; ++n) { + char cand[96]; + std::snprintf(cand, sizeof(cand), "%s (%d)", base.c_str(), n); + if (!taken(cand)) return cand; + } + return base + " (" + std::string(ae_np_lan_local_player_id()).substr(0, 8) + ")"; + } + + /* Seat or reconnect a guest by player_id; uniquify display name on insert. */ + static int ae_np_lan_seat_guest(AeLanLobbyState& st, const char* player_id, + const char* requested_name) { + if (!player_id || !player_id[0]) return -1; + int slot = ae_np_lan_find_slot_by_id(st, player_id); + if (slot >= 0) { + if (slot == st.host_slot) return -1; + st.slot_name[slot] = + ae_np_lan_unique_display_name(st, requested_name, slot); + return slot; + } + slot = ae_np_lan_find_free_slot(st); + if (slot < 0) return -1; + st.slot_id[slot] = player_id; + st.slot_name[slot] = + ae_np_lan_unique_display_name(st, requested_name, -1); + return slot; + } + + static void ae_np_lan_clear_peer_slot(int slot) { + if (slot < 0 || slot >= kAeLanMaxSlots) return; + g_lnch_lan_peer_ok[slot] = false; + } + + static void ae_np_lan_set_peer_slot(int slot, const sockaddr_in& addr) { + if (slot < 0 || slot >= kAeLanMaxSlots) return; + g_lnch_lan_peers[slot] = addr; + g_lnch_lan_peer_ok[slot] = true; } static int ae_np_lan_endpoint_port(const std::string& endpoint) { @@ -4101,11 +4294,12 @@ namespace { if (!state) return false; if (g_lnch_remote_lan) { *state = g_lnch_remote_lan_state; + ae_np_lan_sync_legacy_names(*state); return !state->endpoint.empty(); } std::ifstream f(ae_np_lan_file()); if (!f) return false; - std::string started, host_slot, session; + std::string started, host_slot, session, max_slots_s; std::getline(f, state->name); std::getline(f, state->game); std::getline(f, state->endpoint); @@ -4116,16 +4310,40 @@ namespace { std::getline(f, state->password); std::getline(f, session); state->started = started == "1"; - state->host_slot = host_slot == "1" ? 1 : 0; + state->host_slot = std::atoi(host_slot.c_str()); + if (state->host_slot < 0) state->host_slot = 0; state->session_id = 1; if (!session.empty()) { const unsigned v = (unsigned)std::strtoul(session.c_str(), nullptr, 10); if (v) state->session_id = (uint32_t)v; } + state->max_slots = 2; + for (int i = 0; i < kAeLanMaxSlots; ++i) { + state->slot_name[i].clear(); + state->slot_id[i].clear(); + } + if (std::getline(f, max_slots_s)) { + int ms = std::atoi(max_slots_s.c_str()); + if (ms >= 2 && ms <= kAeLanMaxSlots) state->max_slots = ms; + for (int i = 0; i < state->max_slots; ++i) + std::getline(f, state->slot_name[i]); + for (int i = 0; i < state->max_slots; ++i) { + if (!std::getline(f, state->slot_id[i])) + state->slot_id[i].clear(); + } + } else { + /* Legacy 2P file: host + single joiner. */ + state->slot_name[state->host_slot == 1 ? 1 : 0] = state->host_name; + const int guest = state->host_slot == 1 ? 0 : 1; + state->slot_name[guest] = state->joiner_name; + } + ae_np_lan_sync_legacy_names(*state); return !state->endpoint.empty(); } - bool ae_np_write_lan_state(const AeLanLobbyState& state) { + bool ae_np_write_lan_state(const AeLanLobbyState& state_in) { + AeLanLobbyState state = state_in; + ae_np_lan_sync_legacy_names(state); if (g_lnch_remote_lan) { g_lnch_remote_lan_state = state; return true; @@ -4141,20 +4359,35 @@ namespace { << (state.started ? "1" : "0") << "\n" << state.host_slot << "\n" << state.password << "\n" - << sid << "\n"; + << sid << "\n" + << state.max_slots << "\n"; + for (int i = 0; i < state.max_slots && i < kAeLanMaxSlots; ++i) + f << state.slot_name[i] << "\n"; + for (int i = 0; i < state.max_slots && i < kAeLanMaxSlots; ++i) + f << state.slot_id[i] << "\n"; return (bool)f; } - static void ae_np_lan_send_update_to_peer(const AeLanLobbyState& state) { - if (!g_lnch_lan_peer_valid) return; - char msg[384]; - std::snprintf(msg, sizeof(msg), - "MOTK1 UPDATE\n%s\n%s\n%d\n%d\n", - state.host_name.c_str(), - state.joiner_name.c_str(), - state.host_slot, - state.started ? 1 : 0); - ae_np_lan_udp_sendto(g_lnch_lan_peer, msg); + static void ae_np_lan_send_update_to_peers(const AeLanLobbyState& state_in) { + AeLanLobbyState state = state_in; + ae_np_lan_sync_legacy_names(state); + char msg[1536]; + int off = std::snprintf(msg, sizeof(msg), + "MOTK3 UPDATE\n%d\n%d\n%d\n%u\n", + state.max_slots, state.host_slot, + state.started ? 1 : 0, + (unsigned)(state.session_id ? state.session_id : 1u)); + for (int i = 0; i < state.max_slots && i < kAeLanMaxSlots && off > 0 && + off < (int)sizeof(msg) - 128; ++i) { + off += std::snprintf(msg + off, sizeof(msg) - (size_t)off, "%s\n%s\n", + state.slot_id[i].c_str(), + state.slot_name[i].c_str()); + } + if (off <= 0) return; + for (int i = 0; i < kAeLanMaxSlots; ++i) { + if (!g_lnch_lan_peer_ok[i]) continue; + ae_np_lan_udp_sendto(g_lnch_lan_peers[i], msg); + } } static void ae_np_lan_atexit_cleanup(void) { @@ -4166,10 +4399,11 @@ namespace { /* Returns false if the lobby UDP port cannot be bound (in use). */ bool ae_np_write_lan_lobby(const char* name, const char* endpoint, - const char* password) { + const char* password, int max_slots) { ae_np_lan_udp_close(); g_lnch_remote_lan = false; g_lnch_remote_lan_state = {}; + g_lnch_lan_my_slot = 0; AeLanLobbyState state; state.name = name && name[0] ? name : "LAN Lobby"; state.game = g_lnch_netplay_game_name.empty() ? "PSX" : g_lnch_netplay_game_name; @@ -4177,6 +4411,12 @@ namespace { state.host_name = psx_lobby_display_name(); if (state.host_name.empty()) state.host_name = "Host"; state.password = password ? password : ""; + if (max_slots < 2) max_slots = 2; + if (max_slots > kAeLanMaxSlots) max_slots = kAeLanMaxSlots; + state.max_slots = max_slots; + state.host_slot = 0; + state.slot_name[0] = state.host_name; + state.slot_id[0] = ae_np_lan_local_player_id(); const int port = ae_np_lan_endpoint_port(state.endpoint); if (!ae_np_udp_port_available(port) || !ae_np_lan_udp_ensure(true, port)) { @@ -4207,9 +4447,12 @@ namespace { state.name.empty() ? "Lobby" : state.name.c_str()); std::snprintf(out->game_name, sizeof(out->game_name), "%s", state.game.empty() ? "PSX" : state.game.c_str()); - out->player_count = state.joiner_name.empty() ? 1 : 2; - out->max_slots = g_lnch_game_players >= 2 ? g_lnch_game_players : 2; + out->player_count = ae_np_lan_occupied(state); + out->max_slots = state.max_slots >= 2 ? state.max_slots + : (g_lnch_host_max_slots >= 2 ? g_lnch_host_max_slots + : (g_lnch_game_players >= 2 ? g_lnch_game_players : 2)); if (out->max_slots > PSX_MAX_PLAYERS) out->max_slots = PSX_MAX_PLAYERS; + if (out->max_slots > kAeLanMaxSlots) out->max_slots = kAeLanMaxSlots; out->has_password = state.password.empty() ? 0 : 1; return 1; } @@ -4230,26 +4473,11 @@ namespace { static bool ae_np_read_lan_file_state(AeLanLobbyState* state) { if (!state) return false; - std::ifstream f(ae_np_lan_file()); - if (!f) return false; - std::string started, host_slot, session; - std::getline(f, state->name); - std::getline(f, state->game); - std::getline(f, state->endpoint); - std::getline(f, state->host_name); - std::getline(f, state->joiner_name); - std::getline(f, started); - std::getline(f, host_slot); - std::getline(f, state->password); - std::getline(f, session); - state->started = started == "1"; - state->host_slot = host_slot == "1" ? 1 : 0; - state->session_id = 1; - if (!session.empty()) { - const unsigned v = (unsigned)std::strtoul(session.c_str(), nullptr, 10); - if (v) state->session_id = (uint32_t)v; - } - return !state->endpoint.empty(); + const bool prior = g_lnch_remote_lan; + g_lnch_remote_lan = false; + const bool ok = ae_np_read_lan_state(state); + g_lnch_remote_lan = prior; + return ok; } /* Probe whether a LAN host is still answering on endpoint. */ @@ -4310,6 +4538,95 @@ namespace { return ae_np_lan_probe_host_ms(endpoint, 200u); } + + static int ae_np_lan_parse_motk2_update(char* body, AeLanLobbyState* out) { + if (!body || !out) return -1; + char* lines[8] = {}; + char* p = body; + for (int i = 0; i < 4; ++i) { + lines[i] = p; + char* nl = std::strchr(p, '\n'); + if (!nl) return -1; + *nl = '\0'; + p = nl + 1; + } + int max_slots = std::atoi(lines[0]); + int host_slot = std::atoi(lines[1]); + int started = std::atoi(lines[2]); + unsigned sid = (unsigned)std::strtoul(lines[3], nullptr, 10); + if (max_slots < 2) max_slots = 2; + if (max_slots > kAeLanMaxSlots) max_slots = kAeLanMaxSlots; + if (host_slot < 0 || host_slot >= max_slots) host_slot = 0; + out->max_slots = max_slots; + out->host_slot = host_slot; + out->started = started != 0; + out->session_id = sid ? (uint32_t)sid : 1u; + for (int i = 0; i < kAeLanMaxSlots; ++i) { + out->slot_name[i].clear(); + out->slot_id[i].clear(); + } + for (int i = 0; i < max_slots; ++i) { + char* nl = std::strchr(p, '\n'); + if (nl) { + *nl = '\0'; + out->slot_name[i] = p; + p = nl + 1; + } else { + out->slot_name[i] = p; + p = p + std::strlen(p); + } + } + ae_np_lan_sync_legacy_names(*out); + return 0; + } + + /* MOTK3 UPDATE: header + (player_id, display_name) per slot. */ + static int ae_np_lan_parse_motk3_update(char* body, AeLanLobbyState* out) { + if (!body || !out) return -1; + char* lines[8] = {}; + char* p = body; + for (int i = 0; i < 4; ++i) { + lines[i] = p; + char* nl = std::strchr(p, '\n'); + if (!nl) return -1; + *nl = '\0'; + p = nl + 1; + } + int max_slots = std::atoi(lines[0]); + int host_slot = std::atoi(lines[1]); + int started = std::atoi(lines[2]); + unsigned sid = (unsigned)std::strtoul(lines[3], nullptr, 10); + if (max_slots < 2) max_slots = 2; + if (max_slots > kAeLanMaxSlots) max_slots = kAeLanMaxSlots; + if (host_slot < 0 || host_slot >= max_slots) host_slot = 0; + out->max_slots = max_slots; + out->host_slot = host_slot; + out->started = started != 0; + out->session_id = sid ? (uint32_t)sid : 1u; + for (int i = 0; i < kAeLanMaxSlots; ++i) { + out->slot_name[i].clear(); + out->slot_id[i].clear(); + } + for (int i = 0; i < max_slots; ++i) { + char* nl = std::strchr(p, '\n'); + if (!nl) return -1; + *nl = '\0'; + out->slot_id[i] = p; + p = nl + 1; + nl = std::strchr(p, '\n'); + if (nl) { + *nl = '\0'; + out->slot_name[i] = p; + p = nl + 1; + } else { + out->slot_name[i] = p; + p = p + std::strlen(p); + } + } + ae_np_lan_sync_legacy_names(*out); + return 0; + } + /* Send JOIN and wait for UPDATE / ERR. Returns 0, -1 full, -2 password, -3 timeout. */ static int ae_np_lan_wait_join_ack(const std::string& endpoint, const char* password, AeLanLobbyState* out) { @@ -4324,14 +4641,15 @@ namespace { std::string me = psx_lobby_display_name(); if (me.empty()) me = "Player"; - char msg[320]; - std::snprintf(msg, sizeof(msg), "MOTK1 JOIN\n%s\n%s\n", me.c_str(), + const char* my_id = ae_np_lan_local_player_id(); + char msg[384]; + std::snprintf(msg, sizeof(msg), "MOTK3 JOIN\n%s\n%s\n%s\n", my_id, me.c_str(), password ? password : ""); ae_np_lan_udp_sendto(to, msg); const uint32_t deadline = SDL_GetTicks() + 1000u; while ((int32_t)(deadline - SDL_GetTicks()) > 0) { - char buf[512]; + char buf[1536]; sockaddr_in from{}; #ifdef _WIN32 int fromlen = (int)sizeof(from); @@ -4352,6 +4670,33 @@ namespace { if (std::strncmp(code, "bad_password", 12) == 0) return -2; return -1; } + if (std::strncmp(buf, "MOTK3 UPDATE\n", 13) == 0) { + if (ae_np_lan_parse_motk3_update(buf + 13, out) != 0) continue; + out->endpoint = endpoint; + const int my_slot = ae_np_lan_find_slot_by_id(*out, my_id); + if (my_slot < 0) return -1; + g_lnch_lan_my_slot = my_slot; + return 0; + } + if (std::strncmp(buf, "MOTK2 UPDATE\n", 13) == 0) { + if (ae_np_lan_parse_motk2_update(buf + 13, out) != 0) continue; + out->endpoint = endpoint; + /* Legacy host: uniquified name may differ from requested. */ + int my_slot = ae_np_lan_find_guest_slot_by_name(*out, me.c_str()); + if (my_slot < 0) { + for (int i = 0; i < out->max_slots; ++i) { + if (i == out->host_slot) continue; + if (!out->slot_name[i].empty() && + out->slot_name[i].rfind(me, 0) == 0) { + my_slot = i; + break; + } + } + } + if (my_slot < 0) return -1; + g_lnch_lan_my_slot = my_slot; + return 0; + } if (std::strncmp(buf, "MOTK1 UPDATE\n", 13) == 0) { char* p = buf + 13; char* lines[4] = {}; @@ -4367,8 +4712,14 @@ namespace { out->joiner_name = lines[1]; out->host_slot = (std::atoi(lines[2]) == 1) ? 1 : 0; out->started = std::atoi(lines[3]) != 0; + out->max_slots = 2; + out->slot_name[0].clear(); + out->slot_name[1].clear(); + out->slot_name[out->host_slot] = out->host_name; + out->slot_name[1 - out->host_slot] = out->joiner_name; out->endpoint = endpoint; if (out->joiner_name != me) return -1; + g_lnch_lan_my_slot = 1 - out->host_slot; return 0; } SDL_Delay(5); @@ -4413,10 +4764,58 @@ namespace { } caps.turbo_loads = s ? (s->turbo_loads != 0) : 0; caps.auto_skip_fmv = s ? (s->auto_skip_fmv != 0) : 0; - caps.input_delay = 2; + caps.input_delay = g_lnch_lobby_input_delay; + if (caps.input_delay < 2) caps.input_delay = 2; + if (caps.input_delay > 20) caps.input_delay = 20; + caps.force_input_relay = g_lnch_force_input_relay != 0; return caps; } + static void ae_np_push_match_caps(const RecompLauncherCSettings* settings) { + if (!psx_lobby_in_lobby() || !psx_lobby_is_host()) return; + const PsxLobbyMatchCaps* cur = psx_lobby_match_caps(); + PsxLobbyMatchCaps caps = (cur && cur->valid) + ? *cur + : ae_netplay_caps_from_settings(settings); + caps.valid = 1; + caps.input_delay = g_lnch_lobby_input_delay; + if (caps.input_delay < 2) caps.input_delay = 2; + if (caps.input_delay > 20) caps.input_delay = 20; + caps.force_input_relay = g_lnch_force_input_relay != 0; + (void)psx_lobby_set_match_caps(&caps); + } + + int ae_np_input_delay_get(void*) { return g_lnch_lobby_input_delay; } + int ae_np_input_delay_set(void*, int delay_frames) { + if (delay_frames < 2) delay_frames = 2; + if (delay_frames > 20) delay_frames = 20; + g_lnch_lobby_input_delay = delay_frames; + ae_np_push_match_caps(nullptr); + return 0; + } + int ae_np_force_input_relay_get(void*) { return g_lnch_force_input_relay; } + int ae_np_force_input_relay_set(void*, int force) { + g_lnch_force_input_relay = force ? 1 : 0; + ae_np_push_match_caps(nullptr); + return 0; + } + + /* Seat ceiling for the active room (listing / LOBBY UI). 0 if unknown. */ + int ae_np_lobby_max_slots(void*) { + if (g_lnch_hosting_lan || g_lnch_joined_lan) { + AeLanLobbyState state; + if (ae_np_read_lan_state(&state) && state.max_slots >= 2) + return state.max_slots; + return g_lnch_host_max_slots >= 2 ? g_lnch_host_max_slots : 0; + } + if (psx_lobby_in_lobby()) { + const PsxLobbyJoinInfo* ji = psx_lobby_join_info(); + if (ji && ji->max_slots >= 2) return ji->max_slots; + return g_lnch_host_max_slots >= 2 ? g_lnch_host_max_slots : 0; + } + return 0; + } + const char* ae_np_default_url(void*) { return g_lnch_lobby_url.empty() ? psx_lobby_default_url() : g_lnch_lobby_url.c_str(); } @@ -4476,9 +4875,9 @@ namespace { if (inet_pton(AF_INET, host, &to.sin_addr) == 1) { std::string me = psx_lobby_display_name(); if (me.empty()) me = "Player"; - char msg[320]; - std::snprintf(msg, sizeof(msg), "MOTK1 JOIN\n%s\n%s\n", - me.c_str(), + char msg[384]; + std::snprintf(msg, sizeof(msg), "MOTK3 JOIN\n%s\n%s\n%s\n", + ae_np_lan_local_player_id(), me.c_str(), g_lnch_remote_lan_state.password.c_str()); ae_np_lan_udp_sendto(to, msg); } @@ -4489,7 +4888,7 @@ namespace { if (g_lnch_lan_udp == kAeLanSockInvalid) return; for (;;) { - char buf[512]; + char buf[1536]; sockaddr_in from{}; #ifdef _WIN32 int fromlen = (int)sizeof(from); @@ -4508,6 +4907,40 @@ namespace { continue; } + if (std::strncmp(buf, "MOTK3 JOIN\n", 11) == 0 && g_lnch_hosting_lan) { + char* p = buf + 11; + char* nl = std::strchr(p, '\n'); + if (!nl) continue; + *nl = '\0'; + const char* player_id = p; + p = nl + 1; + nl = std::strchr(p, '\n'); + if (!nl) continue; + *nl = '\0'; + const char* name = p; + p = nl + 1; + nl = std::strchr(p, '\n'); + if (nl) *nl = '\0'; + const char* pass = p; + AeLanLobbyState st; + if (!ae_np_read_lan_state(&st)) continue; + if (st.password != pass) { + ae_np_lan_udp_sendto(from, "MOTK1 ERR\nbad_password\n"); + continue; + } + const int slot = ae_np_lan_seat_guest(st, player_id, name); + if (slot < 0) { + ae_np_lan_udp_sendto(from, "MOTK1 ERR\nfull\n"); + continue; + } + st.started = false; + ae_np_lan_sync_legacy_names(st); + if (!ae_np_write_lan_state(st)) continue; + ae_np_lan_set_peer_slot(slot, from); + ae_np_lan_send_update_to_peers(st); + continue; + } + if (std::strncmp(buf, "MOTK1 JOIN\n", 11) == 0 && g_lnch_hosting_lan) { char* p = buf + 11; char* nl = std::strchr(p, '\n'); @@ -4524,31 +4957,120 @@ namespace { ae_np_lan_udp_sendto(from, "MOTK1 ERR\nbad_password\n"); continue; } - if (!st.joiner_name.empty() && st.joiner_name != name) { + /* Legacy JOIN: synthesize an id from peer addr so same-name + * clients still get distinct seats + (2)/(3) labels. */ + char synth_id[48]; + std::snprintf(synth_id, sizeof(synth_id), "motk1-%08x-%04x", + (unsigned)ntohl(from.sin_addr.s_addr), + (unsigned)ntohs(from.sin_port)); + const int slot = ae_np_lan_seat_guest(st, synth_id, name); + if (slot < 0) { ae_np_lan_udp_sendto(from, "MOTK1 ERR\nfull\n"); continue; } - st.joiner_name = name; st.started = false; + ae_np_lan_sync_legacy_names(st); if (!ae_np_write_lan_state(st)) continue; - g_lnch_lan_peer = from; - g_lnch_lan_peer_valid = true; - ae_np_lan_send_update_to_peer(st); + ae_np_lan_set_peer_slot(slot, from); + ae_np_lan_send_update_to_peers(st); continue; } - if (std::strncmp(buf, "MOTK1 LEAVE\n", 12) == 0 && g_lnch_hosting_lan) { + if ((std::strncmp(buf, "MOTK3 LEAVE\n", 12) == 0 || + std::strncmp(buf, "MOTK1 LEAVE\n", 12) == 0) && + g_lnch_hosting_lan) { + const bool by_id = std::strncmp(buf, "MOTK3 LEAVE\n", 12) == 0; + char* p = buf + 12; + char* nl = std::strchr(p, '\n'); + if (nl) *nl = '\0'; + const char* leave_key = (p && p[0]) ? p : nullptr; AeLanLobbyState st; if (!ae_np_read_lan_state(&st)) continue; - st.joiner_name.clear(); + int cleared = -1; + if (leave_key) { + if (by_id) { + cleared = ae_np_lan_find_slot_by_id(st, leave_key); + if (cleared == st.host_slot) cleared = -1; + } else { + for (int i = 0; i < st.max_slots; ++i) { + if (i == st.host_slot) continue; + if (st.slot_name[i] == leave_key) { + cleared = i; + break; + } + } + } + if (cleared >= 0) { + st.slot_name[cleared].clear(); + st.slot_id[cleared].clear(); + } + } else { + /* Legacy leave without name: drop first guest. */ + for (int i = 0; i < st.max_slots; ++i) { + if (i == st.host_slot) continue; + if (!st.slot_name[i].empty()) { + st.slot_name[i].clear(); + st.slot_id[i].clear(); + cleared = i; + break; + } + } + } st.started = false; + ae_np_lan_sync_legacy_names(st); ae_np_write_lan_state(st); - g_lnch_lan_peer_valid = false; + if (cleared >= 0) ae_np_lan_clear_peer_slot(cleared); + ae_np_lan_send_update_to_peers(st); continue; } if (!g_lnch_remote_lan) continue; + if (std::strncmp(buf, "MOTK3 UPDATE\n", 13) == 0) { + AeLanLobbyState st = g_lnch_remote_lan_state; + if (ae_np_lan_parse_motk3_update(buf + 13, &st) != 0) continue; + st.endpoint = g_lnch_lan_endpoint; + g_lnch_remote_lan_state = st; + const int my_slot = + ae_np_lan_find_slot_by_id(st, ae_np_lan_local_player_id()); + if (my_slot < 0) { + g_lnch_joined_lan = false; + g_lnch_remote_lan = false; + g_lnch_lan_endpoint.clear(); + g_lnch_lan_my_slot = -1; + ae_np_lan_udp_close(); + } else { + g_lnch_lan_my_slot = my_slot; + } + continue; + } + + if (std::strncmp(buf, "MOTK2 UPDATE\n", 13) == 0) { + AeLanLobbyState st = g_lnch_remote_lan_state; + if (ae_np_lan_parse_motk2_update(buf + 13, &st) != 0) continue; + st.endpoint = g_lnch_lan_endpoint; + g_lnch_remote_lan_state = st; + if (g_lnch_lan_my_slot >= 0 && + g_lnch_lan_my_slot < st.max_slots && + !st.slot_name[g_lnch_lan_my_slot].empty()) { + /* keep assigned slot */ + } else { + std::string me = psx_lobby_display_name(); + if (me.empty()) me = "Player"; + const int my_slot = + ae_np_lan_find_guest_slot_by_name(st, me.c_str()); + if (my_slot < 0) { + g_lnch_joined_lan = false; + g_lnch_remote_lan = false; + g_lnch_lan_endpoint.clear(); + g_lnch_lan_my_slot = -1; + ae_np_lan_udp_close(); + } else { + g_lnch_lan_my_slot = my_slot; + } + } + continue; + } if (std::strncmp(buf, "MOTK1 UPDATE\n", 13) == 0) { char* p = buf + 13; char* lines[4] = {}; @@ -4564,13 +5086,23 @@ namespace { g_lnch_remote_lan_state.joiner_name = lines[1]; g_lnch_remote_lan_state.host_slot = (std::atoi(lines[2]) == 1) ? 1 : 0; g_lnch_remote_lan_state.started = std::atoi(lines[3]) != 0; + g_lnch_remote_lan_state.max_slots = 2; + g_lnch_remote_lan_state.slot_name[0].clear(); + g_lnch_remote_lan_state.slot_name[1].clear(); + g_lnch_remote_lan_state.slot_name[g_lnch_remote_lan_state.host_slot] = + g_lnch_remote_lan_state.host_name; + g_lnch_remote_lan_state.slot_name[1 - g_lnch_remote_lan_state.host_slot] = + g_lnch_remote_lan_state.joiner_name; std::string me = psx_lobby_display_name(); if (me.empty()) me = "Player"; if (g_lnch_remote_lan_state.joiner_name != me) { g_lnch_joined_lan = false; g_lnch_remote_lan = false; g_lnch_lan_endpoint.clear(); + g_lnch_lan_my_slot = -1; ae_np_lan_udp_close(); + } else { + g_lnch_lan_my_slot = 1 - g_lnch_remote_lan_state.host_slot; } continue; } @@ -4644,64 +5176,64 @@ namespace { return 1; } - int ae_np_local_ip(void*, char* out, size_t out_len) { - if (!out || out_len == 0) return 0; -#ifdef _WIN32 - WSADATA wsa; - WSAStartup(MAKEWORD(2, 2), &wsa); - SOCKET s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); - if (s == INVALID_SOCKET) return 0; - sockaddr_in dst{}; - dst.sin_family = AF_INET; - dst.sin_port = htons(80); - inet_pton(AF_INET, "8.8.8.8", &dst.sin_addr); - if (connect(s, (sockaddr*)&dst, sizeof(dst)) == SOCKET_ERROR) { - closesocket(s); - return 0; - } - sockaddr_in local{}; - int len = sizeof(local); - if (getsockname(s, (sockaddr*)&local, &len) == SOCKET_ERROR) { - closesocket(s); - return 0; - } - char buf[64] = {}; - const char* ip = inet_ntop(AF_INET, &local.sin_addr, buf, sizeof(buf)); - closesocket(s); - if (!ip || !buf[0]) return 0; - std::snprintf(out, out_len, "%s", buf); - return 1; -#else - std::snprintf(out, out_len, "Unavailable"); - return 0; -#endif - } - int ae_np_external_ip(void*, char* out, size_t out_len) { if (!out || out_len == 0) return 0; #ifdef _WIN32 WSADATA wsa; WSAStartup(MAKEWORD(2, 2), &wsa); +#endif addrinfo hints{}; hints.ai_family = AF_INET; hints.ai_socktype = SOCK_STREAM; addrinfo* res = nullptr; if (getaddrinfo("api.ipify.org", "80", &hints, &res) != 0 || !res) return 0; +#ifdef _WIN32 SOCKET s = socket(res->ai_family, res->ai_socktype, res->ai_protocol); if (s == INVALID_SOCKET) { freeaddrinfo(res); return 0; } + DWORD timeout_ms = 3000; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&timeout_ms, + sizeof(timeout_ms)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char*)&timeout_ms, + sizeof(timeout_ms)); +#else + int s = socket(res->ai_family, res->ai_socktype, res->ai_protocol); + if (s < 0) { + freeaddrinfo(res); + return 0; + } + timeval tv{}; + tv.tv_sec = 3; + tv.tv_usec = 0; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); +#endif int ok = 0; - if (connect(s, res->ai_addr, (int)res->ai_addrlen) != SOCKET_ERROR) { +#ifdef _WIN32 + const int connected = + connect(s, res->ai_addr, (int)res->ai_addrlen) != SOCKET_ERROR; +#else + const int connected = + connect(s, res->ai_addr, (socklen_t)res->ai_addrlen) == 0; +#endif + if (connected) { const char req[] = "GET / HTTP/1.1\r\n" "Host: api.ipify.org\r\n" + "User-Agent: psxrecomp-netplay/1.0\r\n" "Connection: close\r\n\r\n"; +#ifdef _WIN32 (void)send(s, req, (int)strlen(req), 0); char resp[1024]; int n = recv(s, resp, sizeof(resp) - 1, 0); +#else + (void)send(s, req, strlen(req), 0); + char resp[1024]; + ssize_t n = recv(s, resp, sizeof(resp) - 1, 0); +#endif if (n > 0) { resp[n] = '\0'; char* body = strstr(resp, "\r\n\r\n"); @@ -4720,13 +5252,13 @@ namespace { } } } +#ifdef _WIN32 closesocket(s); - freeaddrinfo(res); - return ok; #else - std::snprintf(out, out_len, "Unavailable"); - return 0; + close(s); #endif + freeaddrinfo(res); + return ok; } /* Collect non-loopback IPv4 addresses for local_address_get. */ @@ -4809,14 +5341,16 @@ namespace { return out->address[0] ? 1 : 0; } - /* Online create uses 0.0.0.0 / * / :: so the lobby server can rewrite the - * peer-facing endpoint. Those binds are never a same-machine LAN room. */ - static bool ae_np_endpoint_is_any_bind(const char* endpoint) { - if (!endpoint || !endpoint[0]) return true; - const char* colon = std::strrchr(endpoint, ':'); - std::string host = colon ? std::string(endpoint, colon) : std::string(endpoint); - return host.empty() || host == "0.0.0.0" || host == "*" || host == "::" || - host == "[::]"; + int ae_np_local_ip(void*, char* out, size_t out_len) { + if (!out || out_len == 0) return 0; + std::vector addrs; + ae_np_collect_local_addresses(&addrs); + if (addrs.empty()) { + std::snprintf(out, out_len, "Unavailable"); + return 0; + } + std::snprintf(out, out_len, "%s", addrs[0].address); + return 1; } /* LAN/Direct IP rooms own membership via the local file registry. Server @@ -4837,16 +5371,36 @@ namespace { return; } AeLanLobbyState state; - if (!ae_np_read_lan_state(&state) || state.joiner_name.empty()) { + if (!ae_np_read_lan_state(&state)) { g_lnch_joined_lan = false; g_lnch_lan_endpoint.clear(); + g_lnch_lan_my_slot = -1; return; } - std::string me = psx_lobby_display_name(); - if (me.empty()) me = "Player"; - if (state.joiner_name != me) { + bool seated = false; + const int by_id = + ae_np_lan_find_slot_by_id(state, ae_np_lan_local_player_id()); + if (by_id >= 0 && by_id != state.host_slot) { + seated = true; + g_lnch_lan_my_slot = by_id; + } else if (g_lnch_lan_my_slot >= 0 && + g_lnch_lan_my_slot < state.max_slots && + g_lnch_lan_my_slot != state.host_slot && + !state.slot_name[g_lnch_lan_my_slot].empty()) { + seated = true; + } else { + std::string me = psx_lobby_display_name(); + if (me.empty()) me = "Player"; + const int slot = ae_np_lan_find_guest_slot_by_name(state, me.c_str()); + if (slot >= 0) { + seated = true; + g_lnch_lan_my_slot = slot; + } + } + if (!seated) { g_lnch_joined_lan = false; g_lnch_lan_endpoint.clear(); + g_lnch_lan_my_slot = -1; } } @@ -4855,7 +5409,16 @@ namespace { int ae_np_create(void*, const char* lobby_name, char* host_endpoint, const char* password, const RecompLauncherCSettings* settings, - int lan_only) { + int lan_only, int max_slots) { + int game_max = g_lnch_game_players >= 2 ? g_lnch_game_players : 2; + if (game_max > PSX_MAX_PLAYERS) game_max = PSX_MAX_PLAYERS; + if (game_max > 8) game_max = 8; + if (max_slots < 2) max_slots = 2; + if (max_slots > game_max) max_slots = game_max; + /* Lobby + delay-sync ceiling (party games up to 8). */ + if (max_slots > RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS) + max_slots = RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS; + g_lnch_host_max_slots = max_slots; PsxLobbyMatchCaps caps = ae_netplay_caps_from_settings(settings); char endpoint[96]; if (host_endpoint && host_endpoint[0]) @@ -4864,13 +5427,14 @@ namespace { std::snprintf(endpoint, sizeof(endpoint), "0.0.0.0:7777"); const int want_port = ae_np_lan_endpoint_port(endpoint); - /* lan_only: publish only the local LAN registry (no lobby server). - * Also take this path when the UI already bound a concrete LAN IP. */ - if (lan_only || !ae_np_endpoint_is_any_bind(endpoint)) { + /* LAN/Direct IP only: local registry + UDP membership (no lobby WS). + * Online create always uses psx_lobby_create — even when the UI passes + * a concrete LAN IPv4 for MotK-style host_bind rewrite. */ + if (lan_only) { /* LAN/Direct IP: exact port required — fail if busy. */ if (psx_lobby_in_lobby()) (void)psx_lobby_leave(); - if (!ae_np_write_lan_lobby(lobby_name, endpoint, password)) + if (!ae_np_write_lan_lobby(lobby_name, endpoint, password, max_slots)) return -4; if (host_endpoint) std::snprintf(host_endpoint, 96, "%s", endpoint); @@ -4897,7 +5461,7 @@ namespace { g_lnch_remote_lan = false; g_lnch_remote_lan_state = {}; g_lnch_lan_endpoint.clear(); - psx_lobby_set_max_slots(g_lnch_game_players); + psx_lobby_set_max_slots(max_slots); return psx_lobby_create(lobby_name && lobby_name[0] ? lobby_name : "Netplay Lobby", g_lnch_netplay_game_name.c_str(), PSX_GAME_VERSION, password ? password : "", endpoint, &caps); @@ -4938,16 +5502,20 @@ namespace { /* Same-machine / shared cwd: claim the local LAN file when the * endpoint matches. */ if (have_file && !g_lnch_hosting_lan && file.endpoint == endpoint) { - if (!file.joiner_name.empty()) return -1; if (file.password != (password ? password : "")) return -2; + std::string me = psx_lobby_display_name(); + if (me.empty()) me = "Player"; + const int slot = + ae_np_lan_seat_guest(file, ae_np_lan_local_player_id(), me.c_str()); + if (slot < 0) return -1; + file.started = false; + ae_np_lan_sync_legacy_names(file); g_lnch_remote_lan = false; g_lnch_remote_lan_state = {}; - file.joiner_name = psx_lobby_display_name(); - if (file.joiner_name.empty()) file.joiner_name = "Player"; - file.started = false; if (!ae_np_write_lan_state(file)) return -1; g_lnch_hosting_lan = false; g_lnch_joined_lan = true; + g_lnch_lan_my_slot = slot; g_lnch_lan_endpoint = file.endpoint; return 0; } @@ -4971,11 +5539,7 @@ namespace { } g_lnch_remote_lan = true; g_lnch_remote_lan_state = seated; - if (g_lnch_remote_lan_state.joiner_name.empty()) { - g_lnch_remote_lan_state.joiner_name = psx_lobby_display_name(); - if (g_lnch_remote_lan_state.joiner_name.empty()) - g_lnch_remote_lan_state.joiner_name = "Player"; - } + ae_np_lan_sync_legacy_names(g_lnch_remote_lan_state); g_lnch_hosting_lan = false; g_lnch_joined_lan = true; g_lnch_lan_endpoint = endpoint; @@ -5004,12 +5568,16 @@ namespace { int ae_np_leave(void*) { if (g_lnch_hosting_lan) { - if (g_lnch_lan_peer_valid) - ae_np_lan_udp_sendto(g_lnch_lan_peer, "MOTK1 KICK\n"); + for (int i = 0; i < kAeLanMaxSlots; ++i) { + if (g_lnch_lan_peer_ok[i]) + ae_np_lan_udp_sendto(g_lnch_lan_peers[i], "MOTK1 KICK\n"); + } std::error_code ec; std::filesystem::remove(ae_np_lan_file(), ec); g_lnch_hosting_lan = false; } else if (g_lnch_joined_lan) { + std::string me = psx_lobby_display_name(); + if (me.empty()) me = "Player"; if (g_lnch_remote_lan) { char host[64]; if (ae_np_lan_endpoint_host(g_lnch_lan_endpoint, host, sizeof(host)) && @@ -5018,14 +5586,31 @@ namespace { to.sin_family = AF_INET; to.sin_port = htons((uint16_t)ae_np_lan_endpoint_port(g_lnch_lan_endpoint)); - if (inet_pton(AF_INET, host, &to.sin_addr) == 1) - ae_np_lan_udp_sendto(to, "MOTK1 LEAVE\n"); + if (inet_pton(AF_INET, host, &to.sin_addr) == 1) { + char leave_msg[160]; + std::snprintf(leave_msg, sizeof(leave_msg), + "MOTK3 LEAVE\n%s\n", + ae_np_lan_local_player_id()); + ae_np_lan_udp_sendto(to, leave_msg); + } } } else { AeLanLobbyState state; if (ae_np_read_lan_state(&state)) { - state.joiner_name.clear(); + int slot = ae_np_lan_find_slot_by_id(state, ae_np_lan_local_player_id()); + if (slot < 0 && g_lnch_lan_my_slot >= 0 && + g_lnch_lan_my_slot < state.max_slots && + g_lnch_lan_my_slot != state.host_slot) { + slot = g_lnch_lan_my_slot; + } + if (slot < 0) + slot = ae_np_lan_find_guest_slot_by_name(state, me.c_str()); + if (slot >= 0) { + state.slot_name[slot].clear(); + state.slot_id[slot].clear(); + } state.started = false; + ae_np_lan_sync_legacy_names(state); ae_np_write_lan_state(state); } } @@ -5035,6 +5620,7 @@ namespace { g_lnch_remote_lan = false; g_lnch_remote_lan_state = {}; g_lnch_lan_endpoint.clear(); + g_lnch_lan_my_slot = -1; g_lnch_pending_direct_launch = {}; return psx_lobby_leave(); } @@ -5057,7 +5643,12 @@ namespace { const int n = psx_lobby_member_count(); return n > 0 ? n : 1; } - if (ae_np_use_lan_members()) return 2; + if (ae_np_use_lan_members()) { + AeLanLobbyState state; + if (!ae_np_read_lan_state(&state)) return 1; + const int n = ae_np_lan_occupied(state); + return n > 0 ? n : 1; + } return psx_lobby_member_count(); } @@ -5077,19 +5668,27 @@ namespace { out->is_host = (std::strcmp(host_id, mem.player_id) == 0) ? 1 : 0; else out->is_host = (mem.slot == 0) ? 1 : 0; + out->latency_ms = -1; return 1; } if (ae_np_use_lan_members()) { - if (index < 0 || index > 1) return 0; AeLanLobbyState state; if (!ae_np_read_lan_state(&state)) return 0; - const bool host = index == 0; - out->slot = host ? state.host_slot : 1 - state.host_slot; - const std::string& name = host ? state.host_name : state.joiner_name; - std::snprintf(out->display_name, sizeof(out->display_name), "%s", name.c_str()); - out->ready = !name.empty(); - out->is_host = host ? 1 : 0; - return 1; + int seen = 0; + for (int slot = 0; slot < state.max_slots; ++slot) { + if (state.slot_name[slot].empty()) continue; + if (seen == index) { + out->slot = slot; + std::snprintf(out->display_name, sizeof(out->display_name), "%s", + state.slot_name[slot].c_str()); + out->ready = 1; + out->is_host = (slot == state.host_slot) ? 1 : 0; + out->latency_ms = -1; + return 1; + } + ++seen; + } + return 0; } PsxLobbyMember mem{}; if (!psx_lobby_member_get(index, &mem)) return 0; @@ -5101,19 +5700,31 @@ namespace { out->is_host = (std::strcmp(host_id, mem.player_id) == 0) ? 1 : 0; else out->is_host = (mem.slot == 0) ? 1 : 0; + out->latency_ms = -1; return 1; } int ae_np_move_member(void*, int from_slot, int to_slot) { if (from_slot < 0 || to_slot < 0 || from_slot == to_slot) return -1; - if (g_lnch_hosting_lan && from_slot <= 1 && to_slot <= 1) { + if (g_lnch_hosting_lan) { AeLanLobbyState state; if (!ae_np_read_lan_state(&state)) return -1; - /* Swap which physical seat is "host slot" (P1/P2). */ - state.host_slot = 1 - state.host_slot; + if (from_slot < 0 || to_slot < 0 || + from_slot >= state.max_slots || to_slot >= state.max_slots) + return -1; + std::swap(state.slot_name[from_slot], state.slot_name[to_slot]); + std::swap(state.slot_id[from_slot], state.slot_id[to_slot]); + if (state.host_slot == from_slot) state.host_slot = to_slot; + else if (state.host_slot == to_slot) state.host_slot = from_slot; + /* Swap peer bindings with seats. */ + if (from_slot < kAeLanMaxSlots && to_slot < kAeLanMaxSlots) { + std::swap(g_lnch_lan_peers[from_slot], g_lnch_lan_peers[to_slot]); + std::swap(g_lnch_lan_peer_ok[from_slot], g_lnch_lan_peer_ok[to_slot]); + } state.started = false; + ae_np_lan_sync_legacy_names(state); if (!ae_np_write_lan_state(state)) return -1; - ae_np_lan_send_update_to_peer(state); + ae_np_lan_send_update_to_peers(state); return 0; } if (ae_np_use_ws_members() && psx_lobby_is_host()) @@ -5125,13 +5736,19 @@ namespace { if (g_lnch_hosting_lan) { AeLanLobbyState state; if (!ae_np_read_lan_state(&state)) return -1; - if (slot < 0 || slot > 1 || slot == state.host_slot) return -1; - state.joiner_name.clear(); + if (slot < 0 || slot >= state.max_slots || slot == state.host_slot) + return -1; + if (state.slot_name[slot].empty()) return -1; + state.slot_name[slot].clear(); + state.slot_id[slot].clear(); state.started = false; + ae_np_lan_sync_legacy_names(state); if (!ae_np_write_lan_state(state)) return -1; - if (g_lnch_lan_peer_valid) - ae_np_lan_udp_sendto(g_lnch_lan_peer, "MOTK1 KICK\n"); - g_lnch_lan_peer_valid = false; + if (slot < kAeLanMaxSlots && g_lnch_lan_peer_ok[slot]) { + ae_np_lan_udp_sendto(g_lnch_lan_peers[slot], "MOTK1 KICK\n"); + ae_np_lan_clear_peer_slot(slot); + } + ae_np_lan_send_update_to_peers(state); return 0; } if (ae_np_use_ws_members() && psx_lobby_is_host()) @@ -5146,18 +5763,20 @@ namespace { int ae_np_request_start(void*, const RecompLauncherCSettings* settings) { if (g_lnch_hosting_lan) { AeLanLobbyState state; - if (!ae_np_read_lan_state(&state) || state.joiner_name.empty()) return -1; + if (!ae_np_read_lan_state(&state) || ae_np_lan_occupied(state) < 2) + return -1; state.started = true; state.session_id += 1u; if (state.session_id == 0) state.session_id = 1; g_lnch_lan_session_id = state.session_id; if (!ae_np_write_lan_state(state)) return -1; - if (g_lnch_lan_peer_valid) { - ae_np_lan_send_update_to_peer(state); - char start_msg[64]; - std::snprintf(start_msg, sizeof(start_msg), "MOTK1 START\n%u\n", - (unsigned)state.session_id); - ae_np_lan_udp_sendto(g_lnch_lan_peer, start_msg); + ae_np_lan_send_update_to_peers(state); + char start_msg[64]; + std::snprintf(start_msg, sizeof(start_msg), "MOTK1 START\n%u\n", + (unsigned)state.session_id); + for (int i = 0; i < kAeLanMaxSlots; ++i) { + if (g_lnch_lan_peer_ok[i]) + ae_np_lan_udp_sendto(g_lnch_lan_peers[i], start_msg); } return 0; } @@ -5178,15 +5797,25 @@ namespace { g_lnch_lan_session_id = state.session_id ? state.session_id : 1u; g_lnch_pending_direct_launch = {}; g_lnch_pending_direct_launch.enabled = 1; - g_lnch_pending_direct_launch.local_slot = g_lnch_hosting_lan - ? state.host_slot : 1 - state.host_slot; + { + int local_slot = g_lnch_hosting_lan ? state.host_slot : g_lnch_lan_my_slot; + if (local_slot < 0) + local_slot = g_lnch_hosting_lan ? state.host_slot : 1; + g_lnch_pending_direct_launch.local_slot = local_slot; + } g_lnch_pending_direct_launch.input_player = 0; g_lnch_pending_direct_launch.session_id = g_lnch_lan_session_id; - g_lnch_pending_direct_launch.input_delay = 2; + g_lnch_pending_direct_launch.input_delay = g_lnch_lobby_input_delay; g_lnch_pending_direct_launch.max_slots = - g_lnch_game_players >= 2 ? g_lnch_game_players : 2; + state.max_slots >= 2 ? state.max_slots + : (g_lnch_host_max_slots >= 2 ? g_lnch_host_max_slots + : (g_lnch_game_players >= 2 ? g_lnch_game_players : 2)); if (g_lnch_pending_direct_launch.max_slots > PSX_MAX_PLAYERS) g_lnch_pending_direct_launch.max_slots = PSX_MAX_PLAYERS; + if (g_lnch_pending_direct_launch.max_slots > kAeLanMaxSlots) + g_lnch_pending_direct_launch.max_slots = kAeLanMaxSlots; + g_lnch_pending_direct_launch.force_input_relay = 0; + g_lnch_pending_direct_launch.player_count = ae_np_lan_occupied(state); if (g_lnch_hosting_lan) { const size_t colon = state.endpoint.rfind(':'); const char* port = colon == std::string::npos @@ -5194,6 +5823,8 @@ namespace { std::snprintf(g_lnch_pending_direct_launch.bind_hostport, sizeof(g_lnch_pending_direct_launch.bind_hostport), "0.0.0.0:%s", port); + /* 3+ host-as-relay: empty peer → lan_hub. 2P: accept-first. */ + g_lnch_pending_direct_launch.peer_hostport[0] = '\0'; } else { std::snprintf(g_lnch_pending_direct_launch.bind_hostport, sizeof(g_lnch_pending_direct_launch.bind_hostport), "0.0.0.0:0"); @@ -5224,8 +5855,8 @@ namespace { if (g_lnch_hosting_lan && !g_lnch_lan_endpoint.empty()) { ae_np_lan_udp_close(); (void)ae_np_lan_udp_ensure(true, ae_np_lan_endpoint_port(g_lnch_lan_endpoint)); - if (g_lnch_lan_peer_valid && ae_np_read_lan_state(&st)) - ae_np_lan_send_update_to_peer(st); + if (ae_np_read_lan_state(&st)) + ae_np_lan_send_update_to_peers(st); } else if (g_lnch_remote_lan) { ae_np_lan_udp_close(); (void)ae_np_lan_udp_ensure(false, 0); @@ -5252,10 +5883,29 @@ namespace { std::snprintf(out->bind_hostport, sizeof(out->bind_hostport), "%s", ji->bind_hostport); std::snprintf(out->peer_hostport, sizeof(out->peer_hostport), "%s", ji->peer_hostport); out->session_id = ji->session_id; - out->input_delay = (caps && caps->valid) ? caps->input_delay : 2; + out->input_delay = (caps && caps->valid) ? caps->input_delay + : g_lnch_lobby_input_delay; out->max_slots = ji->max_slots >= 2 ? ji->max_slots : (g_lnch_game_players >= 2 ? g_lnch_game_players : 2); if (out->max_slots > PSX_MAX_PLAYERS) out->max_slots = PSX_MAX_PLAYERS; + /* Seated width for delay-sync (not lobby ceiling). Bump for sparse + * seat indices so a moved player at slot N still fits. */ + { + int seated = ji->player_count > 0 ? ji->player_count : 0; + int high = ji->local_slot; + const int mc = psx_lobby_member_count(); + for (int i = 0; i < mc; ++i) { + PsxLobbyMember mem{}; + if (!psx_lobby_member_get(i, &mem)) continue; + if (mem.slot > high) high = mem.slot; + } + if (high + 1 > seated) seated = high + 1; + if (seated < 2) seated = out->max_slots; + if (seated > out->max_slots) seated = out->max_slots; + out->player_count = seated; + } + out->force_input_relay = + (caps && caps->valid && caps->force_input_relay) ? 1 : 0; return 1; } @@ -5292,6 +5942,11 @@ namespace { ae_np_kick_member, ae_np_last_error, ae_np_clear_last_error, + ae_np_input_delay_get, + ae_np_input_delay_set, + ae_np_force_input_relay_get, + ae_np_force_input_relay_set, + ae_np_lobby_max_slots, }; } // namespace #endif @@ -5445,33 +6100,33 @@ int main(int argc, char** argv) { bool memcard1_enabled = true; bool memcard2_enabled = true; /* [controller] device routing (defaults: P1 keyboard/digital, P2 none). */ - /* Dev builds default Player 1 to the first connected controller ("auto"): - * combined with dev-any-input (dev_any_input_enabled(), default ON) the - * selected controller, EVERY other plugged-in controller, AND the keyboard - * all drive P1 with no launcher setup. If no controller is present, "auto" - * opens nothing and the keyboard/any-controller merge still drives P1. - * Release keeps "keyboard" (the launcher assigns devices). */ + /* Dev builds default Player 1 to the first connected controller ("auto"). + * Strict per-slot routing is the default (PSX_DEV_INPUT off): only the + * assigned device drives each port. Opt in with PSX_DEV_INPUT=1 to merge + * keyboard + every controller onto P1 for quick testing. Release keeps + * "keyboard" until the launcher assigns devices. */ +std::string player_device[PSX_MAX_PLAYERS]; + int player_mode[PSX_MAX_PLAYERS]; + int player_deadzone[PSX_MAX_PLAYERS]; + int ctrl_locked_mode[PSX_MAX_PLAYERS]; + for (int i = 0; i < PSX_MAX_PLAYERS; ++i) { #if defined(PSX_DEBUG_TOOLS) - std::string p1_device = "auto"; + player_device[i] = (i == 0) ? "auto" : "none"; #else - std::string p1_device = "keyboard"; + player_device[i] = (i == 0) ? "keyboard" : "none"; #endif - std::string p2_device = "none"; - int p1_mode = PSXRecompV4::PAD_MODE_HYBRID; - int p2_mode = PSXRecompV4::PAD_MODE_HYBRID; + player_mode[i] = PSXRecompV4::PAD_MODE_HYBRID; + player_deadzone[i] = kDefaultDeadzoneRaw; + ctrl_locked_mode[i] = PSXRecompV4::PAD_MODE_HYBRID; + } bool ctrl_allow_hybrid = true; /* game.toml [controller] allow_hybrid; false hides Hybrid in the launcher */ bool ctrl_lock_mode = false; /* game.toml [controller] lock_mode; true hides the whole pad-mode selector */ bool ctrl_lock_device = false; /* game.toml [controller] lock_device; true hides the Player controller cards entirely */ - /* The game-DECLARED port modes, captured at game.toml load and immune to the - * settings.toml overrides below. Under lock_mode these are the only valid - * modes (the game supports exactly one pad type), so they are what the - * runtime clamps to and what the launcher locks its selector to. */ - int ctrl_locked_p1_mode = PSXRecompV4::PAD_MODE_HYBRID; - int ctrl_locked_p2_mode = PSXRecompV4::PAD_MODE_HYBRID; bool ws_offered = true; /* game.toml [widescreen] offer; false hides the launcher toggle + clamps 4:3 */ bool ws_ultrawide_offered = false; bool vulkan_offered = false; /* game.toml [video] offer_vulkan; developer opt-in for launcher visibility */ - int resolved_deadzone = -1; /* <0 => keep input.ini/runtime default (12000) */ + /* Legacy single deadzone (<0 => keep per-slot / input.ini defaults). */ + int resolved_deadzone = -1; /* Localization: the effective language (game.toml default -> settings.toml -> * launcher choice), applied to the translation layer AFTER the launcher runs. * lang_menu_options drives the launcher's "Localization" dropdown (empty => @@ -5659,15 +6314,32 @@ int main(int argc, char** argv) { /* [controller] game-declared input defaults (settings.toml/launcher * still override below). */ if (gc.runtime.has_default_mode) { - p1_mode = gc.runtime.default_p1_mode; - p2_mode = gc.runtime.default_p2_mode; + for (int i = 0; i < PSX_MAX_PLAYERS; ++i) { + player_mode[i] = (i == 0) ? gc.runtime.default_p1_mode + : gc.runtime.default_p2_mode; + /* Beyond P2, reuse default_mode (same as P1 when set via default_mode). */ + if (i >= 2) player_mode[i] = gc.runtime.default_p1_mode; + } } - ctrl_locked_p1_mode = gc.runtime.default_p1_mode; - ctrl_locked_p2_mode = gc.runtime.default_p2_mode; + for (int i = 0; i < PSX_MAX_PLAYERS; ++i) + ctrl_locked_mode[i] = player_mode[i]; ctrl_allow_hybrid = gc.runtime.controller_allow_hybrid; ctrl_lock_mode = gc.runtime.controller_lock_mode; ctrl_lock_device = gc.runtime.controller_lock_device; - if (gc.runtime.has_deadzone) resolved_deadzone = gc.runtime.deadzone; + if (gc.runtime.has_deadzone) { + resolved_deadzone = gc.runtime.deadzone; + for (int i = 0; i < PSX_MAX_PLAYERS; ++i) + player_deadzone[i] = gc.runtime.deadzone; + } + /* Console port for SCPH-1070 when offline/netplay arms multitap. + * Most titles use Port 1; Bomberman Party Edition needs Port 2. */ + if (gc.runtime.has_multitap_port) { + const int phys = (gc.runtime.multitap_port == 2) ? 1 : 0; + sio_set_multitap_port(phys); + std::fprintf(stdout, + "psxrecomp: multitap on console Port %d\n", + gc.runtime.multitap_port); + } /* LEGACY per-game pad-config opt-in (default modern). Only Tomba sets * it, so its launcher Hybrid mode's analog<->digital flip doesn't make * libpad manufacture a 1-frame "pad unplugged". sio_init() does not @@ -5964,11 +6636,16 @@ int main(int argc, char** argv) { if (us.has_memcard1_enabled) memcard1_enabled = us.memcard1_enabled; if (us.has_memcard2_enabled) memcard2_enabled = us.memcard2_enabled; if (us.has_language) resolved_language = us.language; - if (us.has_p1_device) p1_device = us.p1_device; - if (us.has_p2_device) p2_device = us.p2_device; - if (us.has_p1_mode) p1_mode = us.p1_mode; - if (us.has_p2_mode) p2_mode = us.p2_mode; - if (us.has_deadzone) resolved_deadzone = us.deadzone; + { + const int n = std::min(PSX_MAX_PLAYERS, + PSXRecompV4::UserSettings::kMaxControllerPlayers); + for (int i = 0; i < n; ++i) { + if (us.has_p_device[i]) player_device[i] = us.p_device[i]; + if (us.has_p_mode[i]) player_mode[i] = us.p_mode[i]; + if (us.has_p_deadzone[i]) player_deadzone[i] = us.p_deadzone[i]; + } + if (us.has_deadzone) resolved_deadzone = us.deadzone; + } if (us.has_low_latency_input) g_low_latency_input = us.low_latency_input ? 1 : 0; if (us.has_vsync) g_video_vsync = us.vsync; if (us.has_frame_interpolation) @@ -5987,8 +6664,8 @@ int main(int argc, char** argv) { * config/settings source has been applied, so a locked game can never boot * a pad type it doesn't support. */ if (ctrl_lock_mode) { - p1_mode = ctrl_locked_p1_mode; - p2_mode = ctrl_locked_p2_mode; + for (int i = 0; i < PSX_MAX_PLAYERS; ++i) + player_mode[i] = ctrl_locked_mode[i]; } /* [widescreen] offer=false: this title's widescreen is unported/unvalidated, @@ -6142,17 +6819,29 @@ int main(int argc, char** argv) { if (!memcard1_path.empty()) { seed.memcard1_path = memcard1_path; seed.has_memcard1_path = true; } if (!memcard2_path.empty()) { seed.memcard2_path = memcard2_path; seed.has_memcard2_path = true; } seed.language = resolved_language; seed.has_language = true; - seed.p1_device = p1_device; seed.has_p1_device = true; - seed.p2_device = p2_device; seed.has_p2_device = true; - seed.p1_mode = p1_mode; seed.has_p1_mode = true; - seed.p2_mode = p2_mode; seed.has_p2_mode = true; - seed.deadzone = resolved_deadzone >= 0 ? resolved_deadzone : 12000; - seed.has_deadzone = true; + { + const int n = std::min(PSX_MAX_PLAYERS, + PSXRecompV4::UserSettings::kMaxControllerPlayers); + for (int i = 0; i < n; ++i) { + seed.p_device[i] = player_device[i]; + seed.has_p_device[i] = true; + seed.p_mode[i] = player_mode[i]; + seed.has_p_mode[i] = true; + seed.p_deadzone[i] = player_deadzone[i]; + seed.has_p_deadzone[i] = true; + } + seed.deadzone = player_deadzone[0]; + seed.has_deadzone = true; + } seed.window_width = g_video_win_w; seed.has_window_width = true; /* recomp-ui creates + owns its SDL2/GL window internally, so there * is no launcher window/context to manage here. */ std::string assets_dir_str = exe_dir_from_argv(argv[0]).string(); + /* Same path the runtime's psx_keybinds_init(argv0) reads — keep the + * launcher Controls page and in-game keyboard map on one file. */ + static std::string s_rui_keybinds_path; + s_rui_keybinds_path = (exe_dir_from_argv(argv[0]) / "keybinds.ini").string(); std::string rui_initial_disc = resolved_disc.string(); std::string rui_title = (game_name.empty() ? std::string("PSX") : game_name) + " \xE2\x80\x94 Launcher"; @@ -6168,20 +6857,30 @@ int main(int argc, char** argv) { ls.enable_audio = 1; ls.audio_freq = 44100; ls.volume = 100; - ls.player_src[0] = (p1_device == "keyboard") ? 1 : (p1_device == "none") ? 0 : 2; - ls.player_src[1] = (p2_device == "keyboard") ? 1 : (p2_device == "none") ? 0 : 2; { - int rui_deadzone_pct = seed.deadzone * 100 / 32767; - ls.deadzone[0] = rui_deadzone_pct; - ls.deadzone[1] = rui_deadzone_pct; + const int n = std::min(PSX_MAX_PLAYERS, RECOMP_LAUNCHER_MAX_PLAYERS); + for (int i = 0; i < n; ++i) { + const std::string& d = player_device[i]; + ls.player_src[i] = (d == "keyboard") ? 1 + : (d == "none" || d.empty()) ? 0 : 2; + ls.deadzone[i] = (player_deadzone[i] * 100 + 16383) / 32767; + ls.pad_mode[i] = (ls.player_src[i] == 1) + ? PSXRecompV4::PAD_MODE_DIGITAL + : player_mode[i]; + ls.player_gamepad_guid[i][0] = '\0'; + if (ls.player_src[i] == 2 && !d.empty() && d != "auto" && + d != "gamepad" && d != "controller") { + std::snprintf(ls.player_gamepad_guid[i], + sizeof(ls.player_gamepad_guid[i]), "%s", + d.c_str()); + } + } } ls.skip_launcher = seed.skip_launcher ? 1 : 0; ls.msu1_enabled = 0; ls.msu1_dir[0] = '\0'; std::snprintf(ls.netplay_player_name, sizeof(ls.netplay_player_name), "%s", has_netplay_player_name ? netplay_player_name.c_str() : ""); - ls.pad_mode[0] = seed.p1_mode; - ls.pad_mode[1] = seed.p2_mode; /* aspect_index: 0 = 4:3, 1 = 16:9, 2 = 21:9 (see RecompLauncherCSettings). */ ls.aspect_index = (seed.aspect_num * 9 == seed.aspect_den * 21) ? 2 : (seed.aspect_num == 16 && seed.aspect_den == 9) ? 1 : 0; @@ -6260,6 +6959,7 @@ int main(int argc, char** argv) { launcher_profile_apply("psx", &gi); gi.name = game_name.empty() ? nullptr : game_name.c_str(); gi.region = rui_region.empty() ? nullptr : rui_region.c_str(); + gi.keybinds_path = s_rui_keybinds_path.c_str(); gi.has_expected_crc = 0; /* the launcher's simple file-CRC doesn't fit PSX multi-track discs — skip verification */ gi.num_known_sha256 = 0; @@ -6271,7 +6971,7 @@ int main(int argc, char** argv) { * [controller]/[widescreen] via GameConfig. PSX always has pad modes. */ gi.pad_mode_selectable = ctrl_lock_mode ? 0 : 1; gi.allow_hybrid = ctrl_allow_hybrid ? 1 : 0; - gi.locked_pad_mode = p1_mode; /* force the game's declared mode (default_mode) */ + gi.locked_pad_mode = ctrl_locked_mode[0]; /* game-declared default_mode */ gi.lock_device = ctrl_lock_device ? 1 : 0; gi.aspect_mask = 0x1 | (ws_offered ? 0x2 : 0) | (ws_ultrawide_offered ? 0x4 : 0); gi.renderer_labels = kPsxRendererLabels; @@ -6371,13 +7071,40 @@ int main(int argc, char** argv) { * is the legacy fallback field for consoles without the cap and is * left unused here. */ seed.texture_filter = ls.texture_filter ? 1 : 0; seed.has_texture_filter = true; - p1_device = (ls.player_src[0] == 1) ? "keyboard" : (ls.player_src[0] == 0) ? "none" : p1_device; - p2_device = (ls.player_src[1] == 1) ? "keyboard" : (ls.player_src[1] == 0) ? "none" : p2_device; - seed.p1_device = p1_device; seed.has_p1_device = true; - seed.p2_device = p2_device; seed.has_p2_device = true; - seed.deadzone = ls.deadzone[0] * 32767 / 100; seed.has_deadzone = true; - seed.p1_mode = ls.pad_mode[0]; seed.has_p1_mode = true; - seed.p2_mode = ls.pad_mode[1]; seed.has_p2_mode = true; + { + const int n = std::min(PSX_MAX_PLAYERS, RECOMP_LAUNCHER_MAX_PLAYERS); + const int un = std::min(n, PSXRecompV4::UserSettings::kMaxControllerPlayers); + for (int i = 0; i < n; ++i) { + if (ls.player_src[i] == 1) { + player_device[i] = "keyboard"; + /* Keyboard is always a digital pad at runtime. */ + player_mode[i] = PSXRecompV4::PAD_MODE_DIGITAL; + } else if (ls.player_src[i] == 0) { + player_device[i] = "none"; + player_mode[i] = ls.pad_mode[i]; + } else if (ls.player_gamepad_guid[i][0]) { + player_device[i] = ls.player_gamepad_guid[i]; + player_mode[i] = ls.pad_mode[i]; + } else if (player_device[i] == "none" || + player_device[i] == "keyboard") { + player_device[i] = "gamepad"; + player_mode[i] = ls.pad_mode[i]; + } else { + player_mode[i] = ls.pad_mode[i]; + } + player_deadzone[i] = ls.deadzone[i] * 32767 / 100; + if (i < un) { + seed.p_device[i] = player_device[i]; + seed.has_p_device[i] = true; + seed.p_mode[i] = player_mode[i]; + seed.has_p_mode[i] = true; + seed.p_deadzone[i] = player_deadzone[i]; + seed.has_p_deadzone[i] = true; + } + } + seed.deadzone = player_deadzone[0]; + seed.has_deadzone = true; + } /* ---- deeper PSX-style settings write-back (mirrors the seed * fields above), all gated on by the "psx" launcher_profile caps. */ @@ -6440,12 +7167,13 @@ int main(int argc, char** argv) { net_cfg.input_player = ls.netplay_launch.input_player; net_cfg.session_id = ls.netplay_launch.session_id; net_cfg.input_delay = ls.netplay_launch.input_delay; - net_cfg.slot_count = ls.netplay_launch.max_slots >= 2 - ? ls.netplay_launch.max_slots - : game_players; - if (net_cfg.slot_count < 2) net_cfg.slot_count = 2; - if (net_cfg.slot_count > PSX_MAX_PLAYERS) - net_cfg.slot_count = PSX_MAX_PLAYERS; + net_cfg.force_input_relay = ls.netplay_launch.force_input_relay ? 1 : 0; + net_cfg.player_count = ls.netplay_launch.player_count; + net_cfg.slot_count = ae_np_session_slot_count( + ls.netplay_launch.player_count, ls.netplay_launch.max_slots, + ls.netplay_launch.local_slot, game_players); + if (net_cfg.player_count <= 0) + net_cfg.player_count = net_cfg.slot_count; std::snprintf(net_cfg.bind_hostport, sizeof(net_cfg.bind_hostport), "%s", ls.netplay_launch.bind_hostport); std::snprintf(net_cfg.peer_hostport, sizeof(net_cfg.peer_hostport), "%s", @@ -6490,9 +7218,16 @@ int main(int argc, char** argv) { if (seed.has_memcard1_path) memcard1_path = seed.memcard1_path; if (seed.has_memcard2_path) memcard2_path = seed.memcard2_path; if (seed.has_language) resolved_language = seed.language; - p1_device = seed.p1_device; p2_device = seed.p2_device; - p1_mode = seed.p1_mode; p2_mode = seed.p2_mode; - if (seed.has_deadzone) resolved_deadzone = seed.deadzone; + { + const int n = std::min(PSX_MAX_PLAYERS, + PSXRecompV4::UserSettings::kMaxControllerPlayers); + for (int i = 0; i < n; ++i) { + if (seed.has_p_device[i]) player_device[i] = seed.p_device[i]; + if (seed.has_p_mode[i]) player_mode[i] = seed.p_mode[i]; + if (seed.has_p_deadzone[i]) player_deadzone[i] = seed.p_deadzone[i]; + } + if (seed.has_deadzone) resolved_deadzone = seed.deadzone; + } g_video_win_w = seed.window_width; /* Persist the user's choices next to the exe. */ PSXRecompV4::save_user_settings( @@ -6628,8 +7363,10 @@ int main(int argc, char** argv) { * SDL controller handles are opened later (after SDL_Init); here we only * set the PSX-visible connection + pad type so the BIOS sees the right * ports during early boot. */ - set_player_device(g_players[0], p1_device, p1_mode); - set_player_device(g_players[1], p2_device, p2_mode); + for (int s = 0; s < PSX_MAX_PLAYERS; ++s) { + set_player_device(g_players[s], player_device[s], player_mode[s]); + g_players[s].deadzone = player_deadzone[s]; + } /* Multitap stays OFF through BIOS boot: SCPH-1070 on port 1 breaks shell / * LoadExe pad bring-up for titles that expect a lone digital pad. Offline * 3+ player builds arm it after game entry (see vblank path); netplay arms @@ -6638,8 +7375,10 @@ int main(int argc, char** argv) { /* Dev-any-input keeps P1 connected even with no assigned controller so the * keyboard / any plugged-in controller can drive port 1 standalone. */ const bool dev_p1 = (dev_any_input_enabled() && s == 0); + const int mode = effective_player_mode(g_players[s]); sio_set_pad_connected(s, (g_players[s].kind != 0 || dev_p1) ? 1 : 0); - sio_set_pad_analog(s, pad_mode_boot_analog(g_players[s].mode), 0x80, 0x80, 0x80, 0x80); + sio_set_pad_analog(s, pad_mode_boot_analog(mode), 0x80, 0x80, 0x80, 0x80); + sio_set_pad_config_capable(s, mode != PSXRecompV4::PAD_MODE_DIGITAL); } /* SPU float-shadow gate must be set before spu_init() (which runs * spu_shadow_reset()). Default OFF; PSX_AUDIO_SHADOW env overrides. */ @@ -6758,6 +7497,15 @@ int main(int argc, char** argv) { * load_input_config has read input.ini. */ if (resolved_deadzone >= 0) controller_deadzone = std::max(0, std::min(32767, resolved_deadzone)); + else + controller_deadzone = kDefaultDeadzoneRaw; + for (int s = 0; s < PSX_MAX_PLAYERS; ++s) { + if (player_deadzone[s] >= 0) + g_players[s].deadzone = std::max(0, std::min(32767, player_deadzone[s])); + else + g_players[s].deadzone = controller_deadzone; + } + controller_deadzone = g_players[0].deadzone; refresh_player_devices(); /* open SDL handles to match the player config */ #ifndef PSX_SDL_NO_AUDIO audio_trace_init(); @@ -7392,12 +8140,13 @@ int main(int argc, char** argv) { net_cfg.input_player = ls.netplay_launch.input_player; net_cfg.session_id = ls.netplay_launch.session_id; net_cfg.input_delay = ls.netplay_launch.input_delay; - net_cfg.slot_count = ls.netplay_launch.max_slots >= 2 - ? ls.netplay_launch.max_slots - : game_players; - if (net_cfg.slot_count < 2) net_cfg.slot_count = 2; - if (net_cfg.slot_count > PSX_MAX_PLAYERS) - net_cfg.slot_count = PSX_MAX_PLAYERS; + net_cfg.force_input_relay = ls.netplay_launch.force_input_relay ? 1 : 0; + net_cfg.player_count = ls.netplay_launch.player_count; + net_cfg.slot_count = ae_np_session_slot_count( + ls.netplay_launch.player_count, ls.netplay_launch.max_slots, + ls.netplay_launch.local_slot, game_players); + if (net_cfg.player_count <= 0) + net_cfg.player_count = net_cfg.slot_count; std::snprintf(net_cfg.bind_hostport, sizeof(net_cfg.bind_hostport), "%s", ls.netplay_launch.bind_hostport); std::snprintf(net_cfg.peer_hostport, sizeof(net_cfg.peer_hostport), "%s", diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index 9414603a0..72cbe9b49 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -18,6 +18,7 @@ uint64_t psx_cycle_count = 0; uint32_t g_psx_cyc_batch = 0; +uint32_t g_psx_cyc_batch_limit = 0; int g_psx_cyc_bb_defer = 0; static int s_cycle_replay_active = 0; static uint64_t s_cycle_replay_live = 0; @@ -519,6 +520,7 @@ void psx_idle_note_check(CPUState *cpu, uint32_t check_pc) { * force a fresh deadline on the next charge. */ void psx_cycles_resync_after_restore(void) { g_psx_cyc_batch = 0; + g_psx_cyc_batch_limit = 0; g_psx_cyc_bb_defer = 0; s_devices_synced_cycle = psx_cycle_count; psx_next_service_cycle = 0; /* recompute on next charge */ @@ -527,6 +529,7 @@ void psx_cycles_resync_after_restore(void) { void psx_cycles_reset_for_boot(void) { g_psx_cyc_batch = 0; + g_psx_cyc_batch_limit = 0; g_psx_cyc_bb_defer = 0; psx_cycle_count = 0; s_devices_synced_cycle = 0; diff --git a/runtime/src/psx_keybinds.c b/runtime/src/psx_keybinds.c index 5e19efa9c..3d2458ef8 100644 --- a/runtime/src/psx_keybinds.c +++ b/runtime/src/psx_keybinds.c @@ -9,6 +9,7 @@ #include "psx_keybinds.h" #include +#include #include #include #include @@ -34,45 +35,37 @@ /* ── Defaults ─────────────────────────────────────────────────────────────── */ /* - * Player 1 reproduces the framework's historical hardcoded keyboard mapping - * (pad_from_keyboard / pad_sticks_for in main.cpp), so shipping keybinds.ini - * with defaults changes nothing until the user edits it: + * Every player slot uses the framework's historical hardcoded keyboard mapping + * (pad_from_keyboard / pad_sticks_for in main.cpp): * D-pad: Arrow keys Start: Return Select: Right Shift * Cross: X Circle: S Square: Z Triangle: A - * L1: Q R1: W L2: E R2: R L3: T R3: Y (stick clicks continue the - * shoulder row; DualShock-only games like Ape Escape require L3/R3) - * Left analog stick: Arrow keys (matches the old keyboard analog path) - * Right analog stick: unbound (the keyboard never drove it before) - * Player 2 is fully unbound (add binds to enable a 2nd keyboard player). + * L1: Q R1: W L2: E R2: R L3: T R3: Y + * Left analog stick: Arrow keys + * Right analog stick: unbound + * Simultaneous multi-keyboard play still requires distinct binds per slot. */ +#define PSXKB_PLAYER_DEFAULTS { \ + .up = SDL_SCANCODE_UP, .down = SDL_SCANCODE_DOWN, \ + .left = SDL_SCANCODE_LEFT, .right = SDL_SCANCODE_RIGHT, \ + .cross = SDL_SCANCODE_X, .circle = SDL_SCANCODE_S, \ + .square = SDL_SCANCODE_Z, .triangle = SDL_SCANCODE_A, \ + .l1 = SDL_SCANCODE_Q, .r1 = SDL_SCANCODE_W, \ + .l2 = SDL_SCANCODE_E, .r2 = SDL_SCANCODE_R, \ + .l3 = SDL_SCANCODE_T, .r3 = SDL_SCANCODE_Y, \ + .start = SDL_SCANCODE_RETURN, .select = SDL_SCANCODE_RSHIFT, \ + .ls_up = SDL_SCANCODE_UP, .ls_down = SDL_SCANCODE_DOWN, \ + .ls_left = SDL_SCANCODE_LEFT, .ls_right = SDL_SCANCODE_RIGHT, \ + .rs_up = SDL_SCANCODE_UNKNOWN, .rs_down = SDL_SCANCODE_UNKNOWN, \ + .rs_left = SDL_SCANCODE_UNKNOWN, .rs_right = SDL_SCANCODE_UNKNOWN, \ +} + #define PSXKB_DEFAULTS { \ - .p1 = { \ - .up = SDL_SCANCODE_UP, .down = SDL_SCANCODE_DOWN, \ - .left = SDL_SCANCODE_LEFT, .right = SDL_SCANCODE_RIGHT, \ - .cross = SDL_SCANCODE_X, .circle = SDL_SCANCODE_S, \ - .square = SDL_SCANCODE_Z, .triangle = SDL_SCANCODE_A, \ - .l1 = SDL_SCANCODE_Q, .r1 = SDL_SCANCODE_W, \ - .l2 = SDL_SCANCODE_E, .r2 = SDL_SCANCODE_R, \ - .l3 = SDL_SCANCODE_T, .r3 = SDL_SCANCODE_Y, \ - .start = SDL_SCANCODE_RETURN, .select = SDL_SCANCODE_RSHIFT, \ - .ls_up = SDL_SCANCODE_UP, .ls_down = SDL_SCANCODE_DOWN, \ - .ls_left = SDL_SCANCODE_LEFT, .ls_right = SDL_SCANCODE_RIGHT, \ - .rs_up = SDL_SCANCODE_UNKNOWN, .rs_down = SDL_SCANCODE_UNKNOWN, \ - .rs_left = SDL_SCANCODE_UNKNOWN, .rs_right = SDL_SCANCODE_UNKNOWN, \ - }, \ - .p2 = { \ - .up = SDL_SCANCODE_UNKNOWN, .down = SDL_SCANCODE_UNKNOWN, \ - .left = SDL_SCANCODE_UNKNOWN, .right = SDL_SCANCODE_UNKNOWN, \ - .cross = SDL_SCANCODE_UNKNOWN, .circle = SDL_SCANCODE_UNKNOWN, \ - .square = SDL_SCANCODE_UNKNOWN, .triangle = SDL_SCANCODE_UNKNOWN, \ - .l1 = SDL_SCANCODE_UNKNOWN, .r1 = SDL_SCANCODE_UNKNOWN, \ - .l2 = SDL_SCANCODE_UNKNOWN, .r2 = SDL_SCANCODE_UNKNOWN, \ - .l3 = SDL_SCANCODE_UNKNOWN, .r3 = SDL_SCANCODE_UNKNOWN, \ - .start = SDL_SCANCODE_UNKNOWN, .select = SDL_SCANCODE_UNKNOWN, \ - .ls_up = SDL_SCANCODE_UNKNOWN, .ls_down = SDL_SCANCODE_UNKNOWN, \ - .ls_left = SDL_SCANCODE_UNKNOWN, .ls_right = SDL_SCANCODE_UNKNOWN, \ - .rs_up = SDL_SCANCODE_UNKNOWN, .rs_down = SDL_SCANCODE_UNKNOWN, \ - .rs_left = SDL_SCANCODE_UNKNOWN, .rs_right = SDL_SCANCODE_UNKNOWN, \ + .player = { \ + PSXKB_PLAYER_DEFAULTS, \ + PSXKB_PLAYER_DEFAULTS, \ + PSXKB_PLAYER_DEFAULTS, \ + PSXKB_PLAYER_DEFAULTS, \ + PSXKB_PLAYER_DEFAULTS, \ }, \ } @@ -217,15 +210,35 @@ static void write_ini(const char *path) { "# l3/r3 (stick clicks), start/select. ls_* / rs_* are the left/right\n" "# analog-stick DIRECTIONS driven from the keyboard (analog pad modes).\n" "#\n" - "# Player 2 is unbound by default — fill in keys to enable a second\n" - "# keyboard player (route a port to \"Keyboard\" in the launcher).\n" + "# Every player slot defaults to the same keyboard map. Rebind per slot\n" + "# for simultaneous multi-keyboard play (route a port to \"Keyboard\").\n" "\n"); - write_player_section(f, "player1", &s_binds.p1); - write_player_section(f, "player2", &s_binds.p2); + for (int p = 0; p < PSXKB_MAX_PLAYERS; ++p) { + char section[16]; + snprintf(section, sizeof(section), "player%d", p + 1); + write_player_section(f, section, &s_binds.player[p]); + } fclose(f); printf("[Keybinds] Wrote %s\n", path); } +static int player_all_unbound(const PsxPlayerBinds *pb) { + for (int i = 0; i < PSXKB_N; i++) { + SDL_Scancode sc = *(const SDL_Scancode *)((const char *)pb + s_buttons[i].offset); + if (sc != SDL_SCANCODE_UNKNOWN) return 0; + } + return 1; +} + +/* Older keybinds.ini left P2+ fully unbound. Promote empty slots to the shared + * default map so every player can Reset / use keyboard with the P1 layout. */ +static void promote_empty_players(void) { + for (int p = 0; p < PSXKB_MAX_PLAYERS; ++p) { + if (player_all_unbound(&s_binds.player[p])) + s_binds.player[p] = s_default_binds.player[0]; + } +} + static void load_ini(const char *path) { FILE *f = fopen(path, "r"); if (!f) return; @@ -239,8 +252,11 @@ static void load_ini(const char *path) { if (end) *end = '\0'; const char *section = line + 1; current = NULL; - if (!strcmp(section, "player1")) current = &s_binds.p1; - else if (!strcmp(section, "player2")) current = &s_binds.p2; + if (!strncmp(section, "player", 6)) { + int n = atoi(section + 6); + if (n >= 1 && n <= PSXKB_MAX_PLAYERS) + current = &s_binds.player[n - 1]; + } continue; } char *eq = strchr(line, '='); @@ -258,6 +274,7 @@ static void load_ini(const char *path) { } } fclose(f); + promote_empty_players(); printf("[Keybinds] Loaded %s\n", path); } @@ -273,10 +290,12 @@ void psx_keybinds_init(const char *exe_path) { const PsxKeyBinds *psx_keybinds_get(void) { return &s_binds; } static const PsxPlayerBinds *player_binds_c(int player) { - return (player == 2) ? &s_binds.p2 : &s_binds.p1; + if (player < 1 || player > PSXKB_MAX_PLAYERS) return &s_binds.player[0]; + return &s_binds.player[player - 1]; } static PsxPlayerBinds *player_binds(int player) { - return (player == 2) ? &s_binds.p2 : &s_binds.p1; + if (player < 1 || player > PSXKB_MAX_PLAYERS) return &s_binds.player[0]; + return &s_binds.player[player - 1]; } /* Is the scancode at button-def index i currently held for this player? */ @@ -286,7 +305,7 @@ static int held(const uint8_t *keys, const PsxPlayerBinds *pb, int i) { } uint16_t psx_keybinds_pad_word(const uint8_t *keys, int player) { - if (!keys) return 0xFFFF; + if (!keys || player < 1 || player > PSXKB_MAX_PLAYERS) return 0xFFFF; const PsxPlayerBinds *pb = player_binds_c(player); uint16_t b = 0xFFFF; /* active-low: all released */ for (int i = 0; i < PSXKB_N; i++) { @@ -297,7 +316,7 @@ uint16_t psx_keybinds_pad_word(const uint8_t *keys, int player) { } void psx_keybinds_sticks(const uint8_t *keys, int player, uint8_t out[4]) { - if (!keys || !out) return; + if (!keys || !out || player < 1 || player > PSXKB_MAX_PLAYERS) return; const PsxPlayerBinds *pb = player_binds_c(player); if (held(keys, pb, PSX_KB_LS_LEFT)) out[0] = 0x00; if (held(keys, pb, PSX_KB_LS_RIGHT)) out[0] = 0xFF; @@ -310,7 +329,7 @@ void psx_keybinds_sticks(const uint8_t *keys, int player, uint8_t out[4]) { } int psx_keybinds_dpad_active(const uint8_t *keys, int player) { - if (!keys) return 0; + if (!keys || player < 1 || player > PSXKB_MAX_PLAYERS) return 0; const PsxPlayerBinds *pb = player_binds_c(player); return held(keys, pb, PSX_KB_UP) || held(keys, pb, PSX_KB_DOWN) || held(keys, pb, PSX_KB_LEFT) || held(keys, pb, PSX_KB_RIGHT); @@ -331,16 +350,19 @@ const char *psx_keybinds_button_label(int button) { SDL_Scancode psx_keybinds_get_button(int player, int button) { if (button < 0 || button >= PSXKB_N) return SDL_SCANCODE_UNKNOWN; + if (player < 1 || player > PSXKB_MAX_PLAYERS) return SDL_SCANCODE_UNKNOWN; return *(SDL_Scancode *)((char *)player_binds(player) + s_buttons[button].offset); } void psx_keybinds_set_button(int player, int button, SDL_Scancode sc) { if (button < 0 || button >= PSXKB_N) return; + if (player < 1 || player > PSXKB_MAX_PLAYERS) return; *(SDL_Scancode *)((char *)player_binds(player) + s_buttons[button].offset) = sc; } void psx_keybinds_reset_player(int player) { - *player_binds(player) = (player == 2) ? s_default_binds.p2 : s_default_binds.p1; + if (player < 1 || player > PSXKB_MAX_PLAYERS) return; + *player_binds(player) = s_default_binds.player[0]; } void psx_keybinds_save(void) { diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index f91c33d1b..0c57276d1 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -114,13 +114,13 @@ static LobbyClient g_lc = { .filter_game_version = PSX_GAME_VERSION, }; -/* Default max_slots for create (clamped 2..5). */ +/* Default max_slots for create (clamped 2..8). */ static int g_lobby_max_slots = 2; void psx_lobby_set_max_slots(int max_slots) { if (max_slots < 2) max_slots = 2; - if (max_slots > 5) max_slots = 5; + if (max_slots > 8) max_slots = 8; g_lobby_max_slots = max_slots; } @@ -344,6 +344,7 @@ static void parse_match_caps_object(const char *obj, PsxLobbyMatchCaps *out) out->input_delay = json_get_int(obj, "input_delay", 2); if (out->input_delay < 0) out->input_delay = 0; if (out->input_delay > 16) out->input_delay = 16; + out->force_input_relay = json_get_bool(obj, "force_input_relay", 0); json_get_str(obj, "language", out->language, sizeof(out->language)); out->valid = 1; } @@ -372,13 +373,16 @@ static int append_match_caps_json(char *dst, size_t dst_cap, const PsxLobbyMatch return snprintf(dst, dst_cap, ",\"match_caps\":{\"v\":1,\"aspect_num\":%d,\"aspect_den\":%d," "\"turbo_loads\":%s,\"bios_hle\":%s,\"fast_boot\":%s," - "\"auto_skip_fmv\":%s,\"input_delay\":%d,\"language\":\"%s\"}", + "\"auto_skip_fmv\":%s,\"input_delay\":%d,\"force_input_relay\":%s," + "\"language\":\"%s\"}", caps->aspect_num, caps->aspect_den, caps->turbo_loads ? "true" : "false", caps->bios_hle ? "true" : "false", caps->fast_boot ? "true" : "false", caps->auto_skip_fmv ? "true" : "false", - caps->input_delay, lang); + caps->input_delay, + caps->force_input_relay ? "true" : "false", + lang); } static void queue_send(const char *json) @@ -412,21 +416,72 @@ static int endpoint_port_is_zero(const char *ep) return (int)strtoul(colon + 1, NULL, 10) == 0; } +/* Prefer a usable host:port among candidates (skip empty / :0). */ +static void copy_first_usable_endpoint(char *dst, size_t dst_len, const char *a, + const char *b, const char *c) +{ + const char *cands[3]; + int i; + if (!dst || dst_len == 0) return; + dst[0] = '\0'; + cands[0] = a; + cands[1] = b; + cands[2] = c; + for (i = 0; i < 3; ++i) { + if (cands[i] && cands[i][0] && !endpoint_port_is_zero(cands[i])) { + strncpy(dst, cands[i], dst_len - 1); + dst[dst_len - 1] = '\0'; + return; + } + } +} + +static int using_server_input_relay(const PsxLobbyJoinInfo *j) +{ + if (g_lc.match_caps.valid && g_lc.match_caps.force_input_relay) + return 1; + /* Server rewrote both endpoints to the same relay advertise address. */ + if (j && j->host_endpoint[0] && j->guest_endpoint[0] && + !endpoint_port_is_zero(j->host_endpoint) && + !endpoint_port_is_zero(j->guest_endpoint) && + strcmp(j->host_endpoint, j->guest_endpoint) == 0 && + (!g_lc.my_bind[0] || strcmp(j->host_endpoint, g_lc.my_bind) != 0)) + return 1; + return 0; +} + static void fill_peer_bind_from_join(void) { PsxLobbyJoinInfo *j = &g_lc.join; + const int force_relay = using_server_input_relay(j); + const int seats = j->player_count >= 2 ? j->player_count : j->max_slots; + const int host_hub = (g_lc.is_host && seats >= 3 && !force_relay) ? 1 : 0; memset(j->bind_hostport, 0, sizeof(j->bind_hostport)); memset(j->peer_hostport, 0, sizeof(j->peer_hostport)); - if (g_lc.is_host) { + if (force_relay) { + /* Everyone dials the lobby-server UDP relay — ephemeral local bind + * (same as LAN guests) so same-PC multi-instance doesn't collide. */ + strncpy(j->bind_hostport, "0.0.0.0:0", sizeof(j->bind_hostport) - 1); + copy_first_usable_endpoint(j->peer_hostport, sizeof(j->peer_hostport), + j->host_endpoint, j->guest_endpoint, NULL); + } else if (g_lc.is_host) { strncpy(j->bind_hostport, g_lc.my_bind, sizeof(j->bind_hostport) - 1); - /* Online guests join with 0.0.0.0:0 (ephemeral). The lobby rewrites - * that to peer_ip:0, which rnet rejects as a dial target. Leave peer - * empty so the host learns the guest from the first HELLO (guest - * dials host_endpoint). Fixed guest ports still dial normally. */ - if (j->guest_endpoint[0] && !endpoint_port_is_zero(j->guest_endpoint)) - strncpy(j->peer_hostport, j->guest_endpoint, sizeof(j->peer_hostport) - 1); + if (!host_hub) { + /* 2P P2P: dial guest when they advertised a fixed port. Online + * guests often join with :0 — leave peer empty (accept-first). */ + if (j->guest_endpoint[0] && !endpoint_port_is_zero(j->guest_endpoint)) + strncpy(j->peer_hostport, j->guest_endpoint, sizeof(j->peer_hostport) - 1); + } + /* host_hub: peer stays empty → rnet_session_start_lan_hub */ } else { - strncpy(j->bind_hostport, g_lc.my_bind, sizeof(j->bind_hostport) - 1); + /* Guests dialing 3+ host hub: ephemeral local UDP (join only probes + * 7778+ and does not hold the socket). 2P P2P keeps the advertised + * fixed guest_bind so the host can dial. */ + if (seats >= 3) { + strncpy(j->bind_hostport, "0.0.0.0:0", sizeof(j->bind_hostport) - 1); + } else { + strncpy(j->bind_hostport, g_lc.my_bind, sizeof(j->bind_hostport) - 1); + } strncpy(j->peer_hostport, j->host_endpoint, sizeof(j->peer_hostport) - 1); } j->bind_hostport[sizeof(j->bind_hostport) - 1] = '\0'; @@ -617,10 +672,11 @@ static void handle_server_json(const char *json) json_get_str(chunk, "game_name", g_lc.list[n].game_name, sizeof(g_lc.list[n].game_name)); json_get_str(chunk, "game_version", g_lc.list[n].game_version, sizeof(g_lc.list[n].game_version)); - if (!g_lc.list[n].game_version[0]) { - strncpy(g_lc.list[n].game_version, "dev", - sizeof(g_lc.list[n].game_version) - 1); - } + /* Missing version stays empty for filter decisions; display + * defaults to "dev" only after accept. Old servers omitted + * game_version — rewriting to "dev" before the strict pin + * hid those rows from release clients. */ + const int has_game_version = g_lc.list[n].game_version[0] != '\0'; /* Drop lobbies that don't match our title (broadcast list * is unfiltered). Release builds also pin game_version; * "dev" keeps other versions visible for testing. */ @@ -629,7 +685,7 @@ static void handle_server_json(const char *json) p = end; continue; } - if (list_filter_version_strict()) { + if (list_filter_version_strict() && has_game_version) { const char *want_ver = effective_game_version(NULL); if (want_ver && want_ver[0] && strcmp(g_lc.list[n].game_version, want_ver) != 0) { @@ -637,6 +693,10 @@ static void handle_server_json(const char *json) continue; } } + if (!has_game_version) { + strncpy(g_lc.list[n].game_version, "dev", + sizeof(g_lc.list[n].game_version) - 1); + } g_lc.list[n].player_count = json_get_int(chunk, "player_count", 0); g_lc.list[n].max_slots = json_get_int(chunk, "max_slots", 2); g_lc.list[n].has_password = json_get_bool(chunk, "has_password", 0); @@ -720,24 +780,56 @@ static void handle_server_json(const char *json) return; } if (strcmp(op, "launch") == 0) { + char relay_endpoint[PSX_LOBBY_ENDPOINT_LEN]; json_get_str(json, "host_endpoint", g_lc.join.host_endpoint, sizeof(g_lc.join.host_endpoint)); json_get_str(json, "guest_endpoint", g_lc.join.guest_endpoint, sizeof(g_lc.join.guest_endpoint)); + relay_endpoint[0] = '\0'; + json_get_str(json, "relay_endpoint", relay_endpoint, sizeof(relay_endpoint)); g_lc.join.player_count = json_get_int(json, "player_count", g_lc.join.player_count); g_lc.join.max_slots = json_get_int(json, "max_slots", g_lc.join.max_slots); g_lc.join.session_id = (uint32_t)json_get_int(json, "session_id", (int)g_lc.join.session_id); ingest_match_caps_from_json(json); + /* Prefer explicit relay_endpoint when the server opened input relay. + * Apply after caps ingest: omitted force_input_relay must not leave + * hosts on the hub path while guests dial the relay. */ + if (relay_endpoint[0] && !endpoint_port_is_zero(relay_endpoint)) { + strncpy(g_lc.join.host_endpoint, relay_endpoint, + sizeof(g_lc.join.host_endpoint) - 1); + g_lc.join.host_endpoint[sizeof(g_lc.join.host_endpoint) - 1] = '\0'; + strncpy(g_lc.join.guest_endpoint, relay_endpoint, + sizeof(g_lc.join.guest_endpoint) - 1); + g_lc.join.guest_endpoint[sizeof(g_lc.join.guest_endpoint) - 1] = '\0'; + if (!g_lc.match_caps.valid) + g_lc.match_caps.valid = 1; + g_lc.match_caps.force_input_relay = 1; + } fill_peer_bind_from_join(); parse_slots_array(json); - /* Guest must know host:port. Host may leave peer empty (accept-first) - * when the guest advertised an ephemeral :0 bind. */ - if (!g_lc.join.host_endpoint[0] || - (g_lc.is_host && !g_lc.join.guest_endpoint[0]) || - (!g_lc.is_host && (!g_lc.join.peer_hostport[0] || - endpoint_port_is_zero(g_lc.join.peer_hostport)))) { - strncpy(g_lc.join.last_error, "missing_endpoints", - sizeof(g_lc.join.last_error) - 1); - g_lc.launch_pending = 0; - return; + /* Guest must know host:port (or relay). Host may leave peer empty for + * accept-first / host-as-relay. Server relay: every peer dials relay. */ + { + const int force_relay = using_server_input_relay(&g_lc.join); + const int seats = g_lc.join.player_count >= 2 ? g_lc.join.player_count + : g_lc.join.max_slots; + const int host_hub = + (g_lc.is_host && seats >= 3 && !force_relay) ? 1 : 0; + const int peer_bad = !g_lc.join.peer_hostport[0] || + endpoint_port_is_zero(g_lc.join.peer_hostport); + if (force_relay) { + if (peer_bad) { + strncpy(g_lc.join.last_error, "missing_endpoints", + sizeof(g_lc.join.last_error) - 1); + g_lc.launch_pending = 0; + return; + } + } else if (!g_lc.join.host_endpoint[0] || + (g_lc.is_host && !host_hub && !g_lc.join.guest_endpoint[0]) || + (!g_lc.is_host && peer_bad)) { + strncpy(g_lc.join.last_error, "missing_endpoints", + sizeof(g_lc.join.last_error) - 1); + g_lc.launch_pending = 0; + return; + } } g_lc.join.last_error[0] = '\0'; g_lc.launch_pending = 1; diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index 0fc79637f..cf1cdb82d 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -28,14 +28,32 @@ /* Session pad count mirrored for release_pads (available without recomp-net). */ static int g_np_slot_count = 2; +/* Persists across shutdown so starvation dumps still see last session topology. */ +static char g_np_diag_arch[24] = "off"; +static int g_np_diag_max_players = 0; +static int g_np_diag_player_count = 0; +static int g_np_diag_configured = 0; + +int psx_netplay_diag_snapshot(char *arch_out, size_t arch_cap, + int *max_players_out, int *player_count_out) +{ + if (arch_out && arch_cap) + snprintf(arch_out, arch_cap, "%s", g_np_diag_arch); + if (max_players_out) *max_players_out = g_np_diag_max_players; + if (player_count_out) *player_count_out = g_np_diag_player_count; + return g_np_diag_configured; +} + void psx_netplay_config_defaults(PsxNetplayConfig *cfg) { if (!cfg) return; memset(cfg, 0, sizeof(*cfg)); cfg->local_slot = 0; cfg->slot_count = 2; + cfg->player_count = 0; cfg->input_player = -1; cfg->input_delay = 2; + cfg->force_input_relay = 0; cfg->session_id = 1; strncpy(cfg->bind_hostport, "0.0.0.0:7777", sizeof(cfg->bind_hostport) - 1); cfg->peer_hostport[0] = '\0'; @@ -903,6 +921,23 @@ int psx_netplay_peer_disconnected(uint32_t timeout_ms) return rnet_session_peer_disconnected(g_np.session, (rnet_u64)timeout_ms); } +static void np_diag_capture(const PsxNetplayConfig *cfg, int slots) +{ + const char *arch = "p2p"; + int players; + if (!cfg) return; + if (cfg->force_input_relay) + arch = "server_relay"; + else if (slots >= 3) + arch = "host_relay"; + players = cfg->player_count > 0 ? cfg->player_count : slots; + if (players < 1) players = slots; + snprintf(g_np_diag_arch, sizeof(g_np_diag_arch), "%s", arch); + g_np_diag_max_players = slots; + g_np_diag_player_count = players; + g_np_diag_configured = 1; +} + #if defined(__linux__) static int peer_is_loopback(const char *peer_hostport) { @@ -975,10 +1010,21 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) g_np.session = rnet_session_create(&rcfg, &host); if (!g_np.session) return -2; - if (rnet_session_start_lan(g_np.session, cfg->bind_hostport, cfg->peer_hostport) != 0) { - rnet_session_destroy(g_np.session); - g_np.session = NULL; - return -3; + /* Host-as-relay: slot 0 with 3+ seats and no dial peer (guests dial host). */ + { + const int peer_empty = + !cfg->peer_hostport || !cfg->peer_hostport[0]; + const int use_hub = (local == 0 && slots >= 3 && peer_empty); + const int rc = use_hub + ? rnet_session_start_lan_hub(g_np.session, cfg->bind_hostport) + : rnet_session_start_lan(g_np.session, cfg->bind_hostport, + cfg->peer_hostport); + if (rc != 0) { + rnet_session_destroy(g_np.session); + g_np.session = NULL; + return -3; + } + np_diag_capture(cfg, slots); } g_np.active = 1; g_np.slot_count = (int)rcfg.slot_count; diff --git a/runtime/src/sio.c b/runtime/src/sio.c index 3aefaf28e..cd79f833b 100644 --- a/runtime/src/sio.c +++ b/runtime/src/sio.c @@ -66,6 +66,8 @@ static uint8_t pad_connected = 0; /* Host-side SCPH-1070 enable. Only meaningful when PSX_MAX_PLAYERS >= 5. */ static int sio_multitap_enabled = 0; +/* Physical SIO port hosting the tap: 0 = console Port 1, 1 = Port 2. */ +static int sio_multitap_port = 0; /* Pad communication state machine */ typedef enum { @@ -77,6 +79,13 @@ typedef enum { /* Multitap 0x42 bulk: ID(0x80)+0x5A + 4×8 pad status bytes. */ #define PAD_RESPONSE_MAX 34 +/* What the next 0x42 on the multitap port returns (psx-spx TAP/REQ latch). */ +typedef enum { + MTAP_NEXT_SLOT_A = 0, + MTAP_NEXT_BULK, + MTAP_NEXT_GARBAGE, +} MtapNextMode; + static PadState pad_state = PAD_IDLE; static int selected_slot = 0; /* physical SIO slot (CTRL bit13): 0 or 1 */ static int pad_active_logical = 0; /* logical pad for single-pad / config cmds */ @@ -84,6 +93,11 @@ static uint8_t pad_response[PAD_RESPONSE_MAX]; static uint8_t pad_response_len = 0; static uint8_t pad_response_idx = 0; static uint8_t pad_current_cmd = 0; +/* Address byte that opened this pad txn (01h=Slot A / bulk, 02h..04h=B..D). */ +static uint8_t pad_mtap_addr = 0x01; +static int mtap_next_mode = MTAP_NEXT_SLOT_A; +static int mtap_req_this = 0; /* TAP==1 seen on current 0x42 txn */ +static int mtap_returned = MTAP_NEXT_SLOT_A; /* what this txn returned */ /* DualShock config-mode latch, per logical pad. A real controller only answers * the config commands (0x44/0x45/0x46/0x47/0x4C/0x4D/0x4F) and reports the * config ID 0xF3 while it is IN config mode; outside config it reports its @@ -127,9 +141,12 @@ static int8_t pad_type_req[PSX_MAX_PLAYERS] = { * * Multitap off (default / PSX_MAX_PLAYERS==2): * physical 0 → logical 0, physical 1 → logical 1 - * Multitap on (SCPH-1070 on port 1, single pad on port 2): - * physical 0 → multitap (bulk pads 0–3 on 0x42; config/other → pad A = 0) + * Multitap on Port 1 (sio_multitap_port==0): + * physical 0 → multitap (pads A–D = logical 0–3; Slot A path = 0) * physical 1 → logical 4 + * Multitap on Port 2 (sio_multitap_port==1): + * physical 0 → logical 0 + * physical 1 → multitap (pads A–D = logical 1–4; Slot A path = 1) */ static int sio_multitap_active(void) { #if PSX_MAX_PLAYERS >= 5 @@ -139,24 +156,55 @@ static int sio_multitap_active(void) { #endif } +static int mtap_slot_a_logical(void) { + return (sio_multitap_port == 0) ? 0 : 1; +} + +static int mtap_standalone_logical(void) { + return (sio_multitap_port == 0) ? 4 : 0; +} + static int pad_logical_for_port(int phys_port) { if (phys_port < 0 || phys_port > 1) return -1; - if (sio_multitap_active()) - return (phys_port == 0) ? 0 : 4; + if (sio_multitap_active()) { + if (phys_port == sio_multitap_port) + return mtap_slot_a_logical(); + return mtap_standalone_logical(); + } return phys_port; } -/* Physical port answers 0x01 when a device is present. Multitap itself is - * present whenever enabled (individual tap slots may still be empty). */ +/* Physical port answers when a device is present. Multitap itself is present + * whenever enabled (individual tap slots may still be empty). */ static int pad_port_has_device(int phys_port) { if (phys_port < 0 || phys_port > 1) return 0; if (sio_multitap_active()) { - if (phys_port == 0) return 1; - return (pad_connected & (1u << 4)) ? 1 : 0; + if (phys_port == sio_multitap_port) return 1; + return (pad_connected & (1u << mtap_standalone_logical())) ? 1 : 0; } return (pad_connected & (1u << phys_port)) ? 1 : 0; } +static int selected_is_mtap_port(void) { + return sio_multitap_active() && selected_slot == sio_multitap_port; +} + +/* After a completed 0x42 on the multitap port, arm the next response mode + * from the REQ bit seen this transfer and what we just returned (psx-spx). */ +static void mtap_finish_42(void) { + if (!selected_is_mtap_port() || pad_current_cmd != 0x42 || pad_mtap_addr != 0x01) + return; + if (!mtap_req_this) { + mtap_next_mode = MTAP_NEXT_SLOT_A; + } else if (mtap_returned == MTAP_NEXT_SLOT_A) { + mtap_next_mode = MTAP_NEXT_BULK; + } else if (mtap_returned == MTAP_NEXT_BULK) { + mtap_next_mode = MTAP_NEXT_GARBAGE; + } else { + mtap_next_mode = MTAP_NEXT_BULK; + } +} + /* Fill 8-byte per-pad status block used in multitap bulk 0x42 responses. * Disconnected → all 0xFF. Digital → 0x41 0x5A btnL btnH + 0xFF pad. * Analog/config → 0x73/0xF3 0x5A btn + stick bytes. */ @@ -625,8 +673,8 @@ void sio_init(void) { pad_supports_config[i] = 1; } pad_connected = 0; - /* Multitap enable is a host preference — leave sio_multitap_enabled alone - * across sio_init so a soft reset does not drop the tap configuration. */ + /* Multitap enable/port are host preferences — leave them alone across + * sio_init so a soft reset does not drop the tap configuration. */ mc_state = MC_IDLE; for (int i = 0; i < 2; i++) { mc_slots[i].state = MC_IDLE; @@ -685,6 +733,11 @@ uint32_t sio_cycles_to_irq(uint32_t i_mask) { void sio_set_multitap(int enabled) { #if PSX_MAX_PLAYERS >= 5 sio_multitap_enabled = enabled ? 1 : 0; + if (!enabled) { + mtap_next_mode = MTAP_NEXT_SLOT_A; + mtap_req_this = 0; + mtap_returned = MTAP_NEXT_SLOT_A; + } #else (void)enabled; sio_multitap_enabled = 0; @@ -695,6 +748,26 @@ int sio_get_multitap(void) { return sio_multitap_active(); } +void sio_set_multitap_port(int phys_port) { +#if PSX_MAX_PLAYERS >= 5 + sio_multitap_port = (phys_port == 1) ? 1 : 0; + mtap_next_mode = MTAP_NEXT_SLOT_A; + mtap_req_this = 0; + mtap_returned = MTAP_NEXT_SLOT_A; +#else + (void)phys_port; + sio_multitap_port = 0; +#endif +} + +int sio_get_multitap_port(void) { +#if PSX_MAX_PLAYERS >= 5 + return sio_multitap_port; +#else + return 0; +#endif +} + void sio_connect_pad(int slot) { if (slot >= 0 && slot < PSX_MAX_PLAYERS) pad_connected |= (uint8_t)(1u << slot); @@ -861,8 +934,17 @@ static void pad_process_byte(uint8_t tx_byte) { } switch (pad_state) { case PAD_IDLE: + /* Standard address 01h selects Slot A (or the standalone pad). With a + * multitap, 02h..04h select pads B–D on that port (psx-spx method 2). */ if (tx_byte == 0x01 && pad_port_has_device(selected_slot)) { pad_active_logical = pad_logical_for_port(selected_slot); + pad_mtap_addr = 0x01; + pad_state = PAD_WAIT_ACCESS; + sio_rx_data = 0xFF; + sio_stat |= SIO_STAT_ACK; + } else if (selected_is_mtap_port() && tx_byte >= 0x02 && tx_byte <= 0x04) { + pad_active_logical = mtap_slot_a_logical() + (int)(tx_byte - 1); + pad_mtap_addr = tx_byte; pad_state = PAD_WAIT_ACCESS; sio_rx_data = 0xFF; sio_stat |= SIO_STAT_ACK; @@ -874,21 +956,44 @@ static void pad_process_byte(uint8_t tx_byte) { case PAD_WAIT_ACCESS: pad_current_cmd = tx_byte; pad_response_idx = 1; - /* SCPH-1070 multitap bulk poll on physical port 0: ID 0x80, 0x5A, then - * concatenated 8-byte status for logical pads 0–3 (Beetle/DuckStation/ - * BlueRetro SCPH-1070). */ - if (sio_multitap_active() && selected_slot == 0 && tx_byte == 0x42) { + mtap_req_this = 0; + /* SCPH-1070 method 1: only when address was 01h AND a prior transfer + * latched REQ=1. Otherwise Slot A (or garbage) — never force bulk on + * every 0x42 (that breaks P1 when the tap is present with one pad). */ + if (selected_is_mtap_port() && pad_mtap_addr == 0x01 && tx_byte == 0x42 && + mtap_next_mode == MTAP_NEXT_BULK) { + const int base = mtap_slot_a_logical(); pad_response[0] = 0x80; pad_response[1] = 0x5A; for (int i = 0; i < 4; i++) - pad_fill_status8(i, &pad_response[2 + i * 8]); + pad_fill_status8(base + i, &pad_response[2 + i * 8]); pad_response_len = PAD_RESPONSE_MAX; + mtap_returned = MTAP_NEXT_BULK; + pad_state = PAD_SEND_RESPONSE; + sio_rx_data = pad_response[0]; + sio_stat |= SIO_STAT_ACK; + break; + } + if (selected_is_mtap_port() && pad_mtap_addr == 0x01 && tx_byte == 0x42 && + mtap_next_mode == MTAP_NEXT_GARBAGE) { + /* HiZ,80h,5Ah,LSB(Slot A id) then abort (psx-spx). */ + const int a = mtap_slot_a_logical(); + const uint8_t id = (!(pad_connected & (1u << a))) ? 0xFFu + : (pad_in_config[a] ? 0xF3u + : (pad_analog[a] ? 0x73u : 0x41u)); + pad_response[0] = 0x80; + pad_response[1] = 0x5A; + pad_response[2] = id; + pad_response_len = 3; + mtap_returned = MTAP_NEXT_GARBAGE; pad_state = PAD_SEND_RESPONSE; sio_rx_data = pad_response[0]; sio_stat |= SIO_STAT_ACK; break; } - /* Single-pad path (port2 / multitap-off / non-0x42 on multitap port A). */ + if (selected_is_mtap_port() && pad_mtap_addr == 0x01 && tx_byte == 0x42) + mtap_returned = MTAP_NEXT_SLOT_A; + /* Single-pad path (standalone port / Slot A / method-2 pad / non-0x42). */ { const int lp = pad_active_logical; if (lp < 0 || lp >= PSX_MAX_PLAYERS || !(pad_connected & (1u << lp))) { @@ -1046,6 +1151,11 @@ static void pad_process_byte(uint8_t tx_byte) { break; case PAD_SEND_RESPONSE: + /* TAP/REQ (third command byte, paired with idhi/5Ah at idx==1): does not + * change *this* response; it arms the next 0x42 on the multitap port. */ + if (selected_is_mtap_port() && pad_current_cmd == 0x42 && + pad_mtap_addr == 0x01 && pad_response_idx == 1) + mtap_req_this = (tx_byte == 0x01) ? 1 : 0; /* For 0x43 (enter/exit config), the data byte selecting enter(0x01)/ * exit(0x00) arrives paired with response index 2. Latch the new config * state; it takes effect from the next transaction (the ID byte already @@ -1077,12 +1187,14 @@ static void pad_process_byte(uint8_t tx_byte) { if (pad_response_idx < pad_response_len) { sio_stat |= SIO_STAT_ACK; } else { + mtap_finish_42(); pad_state = PAD_IDLE; pad_response_len = 0; pad_response_idx = 0; pad_current_cmd = 0; } } else { + mtap_finish_42(); pad_state = PAD_IDLE; pad_response_len = 0; pad_response_idx = 0; diff --git a/runtime/src/starvation_ring.c b/runtime/src/starvation_ring.c index 1a0bced48..d4274f68c 100644 --- a/runtime/src/starvation_ring.c +++ b/runtime/src/starvation_ring.c @@ -11,6 +11,7 @@ #include "starvation_ring.h" #include "psx_cycles.h" +#include "psx_netplay.h" #include #include #include @@ -190,18 +191,26 @@ void starvation_ring_dump(const char *path) { if (!f) return; uint64_t total = s_seq; uint64_t avail = total < STARVATION_RING_CAP ? total : STARVATION_RING_CAP; - fprintf(f, "{\"meta\":{\"total\":%llu,\"shown\":%llu," - "\"last_heartbeat_us\":%llu,\"now_us\":%llu," - "\"psx_cycle_count\":%llu," - "\"current_func\":\"0x%08X\",\"last_store_pc\":\"0x%08X\"," - "\"in_exception\":%u,\"i_stat\":\"0x%08X\",\"i_mask\":\"0x%08X\"}}\n", - (unsigned long long)total, (unsigned long long)avail, - (unsigned long long)s_last_heartbeat_us, - (unsigned long long)host_us_now(), - (unsigned long long)psx_get_cycle_count(), - g_debug_current_func_addr, g_debug_last_store_pc, - (unsigned)psx_get_in_exception(), - i_stat, i_mask); + { + char net_arch[24]; + int max_players = 0, player_count = 0; + (void)psx_netplay_diag_snapshot(net_arch, sizeof(net_arch), + &max_players, &player_count); + fprintf(f, "{\"meta\":{\"total\":%llu,\"shown\":%llu," + "\"last_heartbeat_us\":%llu,\"now_us\":%llu," + "\"psx_cycle_count\":%llu," + "\"current_func\":\"0x%08X\",\"last_store_pc\":\"0x%08X\"," + "\"in_exception\":%u,\"i_stat\":\"0x%08X\",\"i_mask\":\"0x%08X\"," + "\"net_arch\":\"%s\",\"max_players\":%d,\"player_count\":%d}}\n", + (unsigned long long)total, (unsigned long long)avail, + (unsigned long long)s_last_heartbeat_us, + (unsigned long long)host_us_now(), + (unsigned long long)psx_get_cycle_count(), + g_debug_current_func_addr, g_debug_last_store_pc, + (unsigned)psx_get_in_exception(), + i_stat, i_mask, + net_arch, max_players, player_count); + } uint64_t start = total > avail ? total - avail : 0; for (uint64_t i = start; i < total; i++) { StarvationEntry *e = &s_ring[i & (STARVATION_RING_CAP - 1)]; From 2431c773a1c8dc1489b89b3e58f8cad0299244ff Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Fri, 24 Jul 2026 14:42:22 -0400 Subject: [PATCH 16/38] Netplay 5p MotK sync: lobby client relay/bind fixes on feat/ui-5p. Co-authored-by: Cursor --- runtime/include/psx_netplay.h | 12 ++ runtime/include/sio.h | 22 +++ runtime/src/main.cpp | 322 +++++++++++++++++++++++++++++++- runtime/src/psx_lobby_client.c | 19 ++ runtime/src/psx_netplay.c | 70 ++++++- runtime/src/sio.c | 326 ++++++++++++++++++++++++++++++++- 6 files changed, 761 insertions(+), 10 deletions(-) diff --git a/runtime/include/psx_netplay.h b/runtime/include/psx_netplay.h index 3d3e56255..2b13100d7 100644 --- a/runtime/include/psx_netplay.h +++ b/runtime/include/psx_netplay.h @@ -25,7 +25,12 @@ extern "C" { * (prefer g_players[local_slot] if assigned, else player 0). * - While active, publish / release_pads is the sole SIO writer. * - Every session slot stays plugged for in-game N-player detect. +<<<<<<< Updated upstream * - slot_count >= 3 enables SCPH-1070 multitap (sio_set_multitap). +======= + * - slot_count >= 3 enables SCPH-1070 multitap on both console ports + * (pads mirrored onto port-2 taps for BPE-style titles). +>>>>>>> Stashed changes * * Pad blob (8 bytes): * [0..1] buttons LE u16 (PSX active-low) @@ -122,6 +127,13 @@ void psx_netplay_finish_frame(void); /* Park the admit barrier until a peer datagram may be ready (or timeout). */ void psx_netplay_wait_recv(int timeout_ms); +/* highest_remote_wire - sim_tick (0 if inactive; can be negative). */ +int psx_netplay_remote_lead(void); +/* Session input delay frames (default 2 when inactive). */ +int psx_netplay_input_delay(void); +/* Extra wall-frame catch-up budget: min(8, max(0, remote_lead - delay)). */ +int psx_netplay_catchup_budget(void); + /* Normalize sticks (deadzone → center) for stabler cross-device blobs. */ void psx_netplay_normalize_pad(PsxNetPad *pad); diff --git a/runtime/include/sio.h b/runtime/include/sio.h index 49e146f4d..5d186790b 100644 --- a/runtime/include/sio.h +++ b/runtime/include/sio.h @@ -103,6 +103,7 @@ void sio_set_pad_analog(int slot, int enabled, void sio_set_pad_sticks(int slot, uint8_t lx, uint8_t ly, uint8_t rx, uint8_t ry); void sio_request_pad_type(int slot, int analog); +<<<<<<< Updated upstream /* Connect / disconnect a logical pad (0 .. PSX_MAX_PLAYERS-1). By default no * pads are connected during initial BIOS boot. */ void sio_connect_pad(int slot); @@ -116,6 +117,27 @@ void sio_set_pad_connected(int slot, int connected); * answers them. Set from the per-player pad mode (DIGITAL => 0, ANALOG/HYBRID * => 1) at boot/hotplug. Default is 1 (config-capable) so existing * analog/hybrid behaviour is unchanged. */ +======= +/* Connect / disconnect a pad on a logical slot (0..7). Without multitap, + * 0/1 are console ports 1/2. With SCPH-1070 multitap, port N exposes pads + * N*4 .. N*4+3. By default no pads are connected during initial BIOS boot. */ +void sio_connect_pad(int slot); +void sio_set_pad_connected(int slot, int connected); + +/* Enable/disable SCPH-1070 multitap on both console ports (method-1 LONG + * Slot A-D polls + method-2 tap select 0x01..0x04). */ +void sio_set_multitap(int enabled); +int sio_get_multitap(void); + +/* Declare whether the pad on a slot is a config-capable DualShock (1) or a + * plain digital controller (0). A real digital controller (SCPH-1080, poll id + * 0x41) does NOT answer the config-mode commands (0x43/0x44/0x45/0x46/0x47/ + * 0x4C/0x4D/0x4F) — it returns hi-z / no ACK, so a game's pad driver classifies + * it as digital-only and just polls with 0x42. A DualShock answers them. Set + * from the per-player pad mode (DIGITAL => 0, ANALOG/HYBRID => 1) at boot/ + * hotplug. Default is 1 (config-capable) so existing analog/hybrid behaviour is + * unchanged. */ +>>>>>>> Stashed changes void sio_set_pad_config_capable(int slot, int capable); /* Return current pad button state (for debug server). _slot targets a logical diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index da038036d..ca67b1eac 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -40,6 +40,10 @@ extern "C" void psx_event_step_conservative_env_init(void); #endif #include "psx_netplay.h" #include "psx_lobby_client.h" + +#ifndef PSX_MAX_PLAYERS +#define PSX_MAX_PLAYERS 8 +#endif #include "spu.h" #include "audio_trace.h" #include "spu_shadow.h" @@ -1124,6 +1128,134 @@ static void netplay_host_present_uncap(void) { g_netplay_vsync_forced_off = 1; } +/* Last successful present — re-swapped while admit is starved (no guest advance). */ +enum { + NP_HELD_NONE = 0, + NP_HELD_SDL, + NP_HELD_GL_CPU, + NP_HELD_GL_VRAM, + NP_HELD_GL_WIDE, + NP_HELD_VK_CPU, + NP_HELD_VK_VRAM, + NP_HELD_VK_WIDE +}; +static struct { + int mode; + int src_w, src_h; + int linear, pin43; + int disp_x, disp_y, disp_w, disp_h; +} s_np_held; +static uint32_t s_np_held_last_ms; + +static void netplay_held_note_sdl(int src_w, int src_h) { + s_np_held.mode = NP_HELD_SDL; + s_np_held.src_w = src_w; + s_np_held.src_h = src_h; +} +static void netplay_held_note_gl_cpu(int src_w, int src_h, int linear, int pin43) { + s_np_held.mode = NP_HELD_GL_CPU; + s_np_held.src_w = src_w; + s_np_held.src_h = src_h; + s_np_held.linear = linear; + s_np_held.pin43 = pin43; +} +static void netplay_held_note_gl_vram(int x, int y, int w, int h, int linear, int pin43) { + s_np_held.mode = NP_HELD_GL_VRAM; + s_np_held.disp_x = x; + s_np_held.disp_y = y; + s_np_held.disp_w = w; + s_np_held.disp_h = h; + s_np_held.linear = linear; + s_np_held.pin43 = pin43; + s_np_held.src_w = w; + s_np_held.src_h = h; +} +static void netplay_held_note_gl_wide(int x, int y, int h, int linear) { + s_np_held.mode = NP_HELD_GL_WIDE; + s_np_held.disp_x = x; + s_np_held.disp_y = y; + s_np_held.disp_h = h; + s_np_held.linear = linear; + s_np_held.src_w = 1; + s_np_held.src_h = h; +} +static void netplay_held_note_vk_cpu(int src_w, int src_h, int linear, int pin43) { + s_np_held.mode = NP_HELD_VK_CPU; + s_np_held.src_w = src_w; + s_np_held.src_h = src_h; + s_np_held.linear = linear; + s_np_held.pin43 = pin43; +} +static void netplay_held_note_vk_vram(int x, int y, int w, int h, int linear, int pin43) { + s_np_held.mode = NP_HELD_VK_VRAM; + s_np_held.disp_x = x; + s_np_held.disp_y = y; + s_np_held.disp_w = w; + s_np_held.disp_h = h; + s_np_held.linear = linear; + s_np_held.pin43 = pin43; + s_np_held.src_w = w; + s_np_held.src_h = h; +} +static void netplay_held_note_vk_wide(int x, int y, int h, int linear) { + s_np_held.mode = NP_HELD_VK_WIDE; + s_np_held.disp_x = x; + s_np_held.disp_y = y; + s_np_held.disp_h = h; + s_np_held.linear = linear; + s_np_held.src_w = 1; + s_np_held.src_h = h; +} + +/* Re-present last framebuffer without advancing guest / rescanning VRAM logic. */ +static void present_held_netplay_frame(void) { +#ifndef PSX_SDL_NO_RENDER + if (g_headless || psx_netplay_in_load_barrier()) + return; + if (s_np_held.mode == NP_HELD_NONE || s_np_held.src_w <= 0) + return; + if (g_gl_active) { + if (s_np_held.mode == NP_HELD_GL_WIDE) { + (void)gl_renderer_present_wide_fbo(s_np_held.disp_x, s_np_held.disp_y, + s_np_held.disp_h, s_np_held.linear); + } else if (s_np_held.mode == NP_HELD_GL_VRAM) { + gl_renderer_present_vram(s_np_held.disp_x, s_np_held.disp_y, + s_np_held.disp_w, s_np_held.disp_h, + s_np_held.linear, s_np_held.pin43); + } else if (sdl_pixel_buf) { + gl_renderer_present(sdl_pixel_buf, s_np_held.src_w, s_np_held.src_h, + s_np_held.linear, s_np_held.pin43, 0); + } + return; + } + if (g_vk_active) { + if (s_np_held.mode == NP_HELD_VK_WIDE) { + (void)vk_renderer_present_wide(s_np_held.disp_x, s_np_held.disp_y, + s_np_held.disp_h, s_np_held.linear); + } else if (s_np_held.mode == NP_HELD_VK_VRAM) { + vk_renderer_present_vram(s_np_held.disp_x, s_np_held.disp_y, + s_np_held.disp_w, s_np_held.disp_h, + s_np_held.linear, s_np_held.pin43); + } else if (sdl_pixel_buf) { + vk_renderer_present_cpu(sdl_pixel_buf, s_np_held.src_w, s_np_held.src_h, + s_np_held.linear, s_np_held.pin43); + } + return; + } + if (sdl_renderer && sdl_texture && s_np_held.src_w > 0 && s_np_held.src_h > 0) { + SDL_Rect src = { 0, 0, s_np_held.src_w, s_np_held.src_h }; + int dst_w = g_logical_w; + int dst_h = 480 * g_video_scale; + SDL_Rect dst = { 0, 0, dst_w, dst_h }; + SDL_RenderClear(sdl_renderer); + SDL_RenderCopy(sdl_renderer, sdl_texture, &src, &dst); + SDL_RenderPresent(sdl_renderer); + } +#else + (void)0; +#endif +} + static void netplay_host_present_restore(void) { if (!g_netplay_vsync_forced_off) return; #ifndef PSX_SDL_NO_RENDER @@ -2772,6 +2904,7 @@ static void netplay_barrier_admit(int override) { std::fflush(stdout); desync_logged = 1; } + present_held_netplay_frame(); SDL_Delay(16); #ifndef PSX_NO_DEBUG_TOOLS debug_server_poll(); @@ -2823,6 +2956,15 @@ static void netplay_barrier_admit(int override) { SDL_GameControllerUpdate(); } if (psx_return_to_lobby_requested()) return; + /* Keep the window alive while starved — re-present last frame ~60 Hz + * without advancing sim (PSX finish→present→admit order parks here). */ + { + const uint32_t now = SDL_GetTicks(); + if (now - s_np_held_last_ms >= 16u) { + present_held_netplay_frame(); + s_np_held_last_ms = now; + } + } /* Wake on peer UDP (or 1ms timeout). SDL_Delay(1) under dual FMV load * often stretches multi-ms and cut MotK netplay intro ~59→~36; Delay(0) * busy-spins and can starve the peer (tick-0 hang). */ @@ -3273,6 +3415,10 @@ static void sdl_vblank_present(void) { if (psx_return_to_lobby_requested()) return; netplay_barrier_admit(override_); if (skip_pace_ || psx_return_to_lobby_requested()) return; + /* Remote input buffered ahead of delay: skip wall pace so this + * peer drains lead (PSX catch-up; no multi-RtlRunFrame burst). */ + if (psx_netplay_catchup_budget() > 0) + return; uint64_t perf_start = runtime_perf_section_begin(); frame_pacer_wait(&s_frame_pacer, g_frame_period_ms); runtime_perf_section_end(perf_start, &g_runtime_perf.pacer_ticks); @@ -3613,12 +3759,18 @@ static void sdl_vblank_present(void) { * SW stayed smooth). Falls through to the CPU readout path only if * the wide surface for this buffer doesn't exist yet. */ if (gl_renderer_present_wide_fbo((int)di.display_x, (int)di.display_y, - (int)h, g_video_aa ? 1 : 0)) + (int)h, g_video_aa ? 1 : 0)) { + netplay_held_note_gl_wide((int)di.display_x, (int)di.display_y, + (int)h, g_video_aa ? 1 : 0); return; + } } else { gl_renderer_present_vram((int)di.display_x, (int)di.display_y, (int)present_w, (int)h, g_video_aa ? 1 : 0, (fmv_frame || nw_pin) ? 1 : 0); + netplay_held_note_gl_vram((int)di.display_x, (int)di.display_y, + (int)present_w, (int)h, g_video_aa ? 1 : 0, + (fmv_frame || nw_pin) ? 1 : 0); return; } } @@ -3640,14 +3792,20 @@ static void sdl_vblank_present(void) { depth24_fix_trailing_margin(sdl_pixel_buf, present_w, h); vk_renderer_present_cpu(sdl_pixel_buf, (int)present_w, (int)h, 0 /* nearest */, fmv_frame ? 1 : 0); + netplay_held_note_vk_cpu((int)present_w, (int)h, 0, + fmv_frame ? 1 : 0); } else if (wide_present && vk_renderer_present_wide((int)di.display_x, (int)di.display_y, (int)h, g_video_aa ? 1 : 0)) { - /* presented wide */ + netplay_held_note_vk_wide((int)di.display_x, (int)di.display_y, + (int)h, g_video_aa ? 1 : 0); } else { vk_renderer_present_vram((int)di.display_x, (int)di.display_y, (int)present_w, (int)h, g_video_aa ? 1 : 0, (fmv_frame || nw_pin) ? 1 : 0); + netplay_held_note_vk_vram((int)di.display_x, (int)di.display_y, + (int)present_w, (int)h, g_video_aa ? 1 : 0, + (fmv_frame || nw_pin) ? 1 : 0); } return; } @@ -3769,6 +3927,9 @@ static void sdl_vblank_present(void) { gl_renderer_present(sdl_pixel_buf, src_w, src_h, (g_video_aa && !depth24_frame) ? 1 : 0, pin_43 ? 1 : 0, 0 /* full width */); + netplay_held_note_gl_cpu(src_w, src_h, + (g_video_aa && !depth24_frame) ? 1 : 0, + pin_43 ? 1 : 0); } else { SDL_Rect src = { 0, 0, src_w, src_h }; SDL_UpdateTexture(sdl_texture, &src, sdl_pixel_buf, @@ -3806,6 +3967,7 @@ static void sdl_vblank_present(void) { SDL_RenderPresent(sdl_renderer); const Uint64 t1 = SDL_GetPerformanceCounter(); latency_ring_mark(LAT_SWAP_END); + netplay_held_note_sdl(src_w, src_h); const Uint64 freq = SDL_GetPerformanceFrequency(); const Uint64 present_ms = (t1 >= t0 && freq) ? ((t1 - t0) * 1000u) / freq : 0; if (!g_present_vsync_disabled && present_ms > 250) { @@ -3987,7 +4149,14 @@ namespace { /* Join Direct / cross-machine: membership via UDP, not the local file. */ bool g_lnch_remote_lan = false; std::string g_lnch_lan_endpoint; +<<<<<<< Updated upstream uint32_t g_lnch_lan_session_id = 1; +======= + std::string g_lnch_lan_guest_bind; + int g_lnch_lobby_input_delay = 2; + int g_lnch_force_input_relay = 0; + int g_lnch_host_max_slots = 2; +>>>>>>> Stashed changes static constexpr int kAeLanMaxSlots = RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS; struct AeLanLobbyState { @@ -4800,6 +4969,7 @@ namespace { return 0; } +<<<<<<< Updated upstream /* Seat ceiling for the active room (listing / LOBBY UI). 0 if unknown. */ int ae_np_lobby_max_slots(void*) { if (g_lnch_hosting_lan || g_lnch_joined_lan) { @@ -4816,6 +4986,8 @@ namespace { return 0; } +======= +>>>>>>> Stashed changes const char* ae_np_default_url(void*) { return g_lnch_lobby_url.empty() ? psx_lobby_default_url() : g_lnch_lobby_url.c_str(); } @@ -5353,10 +5525,96 @@ namespace { return 1; } +<<<<<<< Updated upstream /* LAN/Direct IP rooms own membership via the local file registry. Server * lobbies use WebSocket lobby_update. Never mix: LAN mode wins if set. */ static bool ae_np_use_lan_members(void) { return g_lnch_hosting_lan || g_lnch_joined_lan; +======= + int ae_np_external_ip(void*, char* out, size_t out_len) { + if (!out || out_len == 0) return 0; +#ifdef _WIN32 + WSADATA wsa; + WSAStartup(MAKEWORD(2, 2), &wsa); +#endif + addrinfo hints{}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + addrinfo* res = nullptr; + if (getaddrinfo("api.ipify.org", "80", &hints, &res) != 0 || !res) + return 0; +#ifdef _WIN32 + SOCKET s = socket(res->ai_family, res->ai_socktype, res->ai_protocol); + if (s == INVALID_SOCKET) { + freeaddrinfo(res); + return 0; + } + DWORD timeout_ms = 3000; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&timeout_ms, + sizeof(timeout_ms)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char*)&timeout_ms, + sizeof(timeout_ms)); +#else + int s = socket(res->ai_family, res->ai_socktype, res->ai_protocol); + if (s < 0) { + freeaddrinfo(res); + return 0; + } + timeval tv{}; + tv.tv_sec = 3; + tv.tv_usec = 0; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); +#endif + int ok = 0; +#ifdef _WIN32 + const int connected = + connect(s, res->ai_addr, (int)res->ai_addrlen) != SOCKET_ERROR; +#else + const int connected = + connect(s, res->ai_addr, (socklen_t)res->ai_addrlen) == 0; +#endif + if (connected) { + const char req[] = + "GET / HTTP/1.1\r\n" + "Host: api.ipify.org\r\n" + "User-Agent: psxrecomp-netplay/1.0\r\n" + "Connection: close\r\n\r\n"; +#ifdef _WIN32 + (void)send(s, req, (int)strlen(req), 0); + char resp[1024]; + int n = recv(s, resp, sizeof(resp) - 1, 0); +#else + (void)send(s, req, strlen(req), 0); + char resp[1024]; + ssize_t n = recv(s, resp, sizeof(resp) - 1, 0); +#endif + if (n > 0) { + resp[n] = '\0'; + char* body = strstr(resp, "\r\n\r\n"); + body = body ? body + 4 : resp; + char ip[64] = {}; + int j = 0; + for (int i = 0; body[i] && j < (int)sizeof(ip) - 1; ++i) { + if ((body[i] >= '0' && body[i] <= '9') || body[i] == '.') + ip[j++] = body[i]; + else if (j > 0) + break; + } + if (j > 0) { + std::snprintf(out, out_len, "%s", ip); + ok = 1; + } + } + } +#ifdef _WIN32 + closesocket(s); +#else + close(s); +#endif + freeaddrinfo(res); + return ok; +>>>>>>> Stashed changes } static bool ae_np_use_ws_members(void) { @@ -5410,6 +5668,7 @@ namespace { const char* password, const RecompLauncherCSettings* settings, int lan_only, int max_slots) { +<<<<<<< Updated upstream int game_max = g_lnch_game_players >= 2 ? g_lnch_game_players : 2; if (game_max > PSX_MAX_PLAYERS) game_max = PSX_MAX_PLAYERS; if (game_max > 8) game_max = 8; @@ -5418,6 +5677,11 @@ namespace { /* Lobby + delay-sync ceiling (party games up to 8). */ if (max_slots > RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS) max_slots = RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS; +======= + if (max_slots < 2) max_slots = 2; + if (max_slots > PSX_MAX_PLAYERS) max_slots = PSX_MAX_PLAYERS; + if (max_slots > 8) max_slots = 8; +>>>>>>> Stashed changes g_lnch_host_max_slots = max_slots; PsxLobbyMatchCaps caps = ae_netplay_caps_from_settings(settings); char endpoint[96]; @@ -5461,6 +5725,12 @@ namespace { g_lnch_remote_lan = false; g_lnch_remote_lan_state = {}; g_lnch_lan_endpoint.clear(); +<<<<<<< Updated upstream +======= + g_lnch_lan_guest_bind.clear(); + if (host_endpoint) + std::snprintf(host_endpoint, 96, "%s", endpoint); +>>>>>>> Stashed changes psx_lobby_set_max_slots(max_slots); return psx_lobby_create(lobby_name && lobby_name[0] ? lobby_name : "Netplay Lobby", g_lnch_netplay_game_name.c_str(), PSX_GAME_VERSION, @@ -5674,6 +5944,7 @@ namespace { if (ae_np_use_lan_members()) { AeLanLobbyState state; if (!ae_np_read_lan_state(&state)) return 0; +<<<<<<< Updated upstream int seen = 0; for (int slot = 0; slot < state.max_slots; ++slot) { if (state.slot_name[slot].empty()) continue; @@ -5689,17 +5960,31 @@ namespace { ++seen; } return 0; +======= + const bool host = index == 0; + out->slot = host ? state.host_slot : 1 - state.host_slot; + const std::string& name = host ? state.host_name : state.joiner_name; + std::snprintf(out->display_name, sizeof(out->display_name), "%s", name.c_str()); + out->ready = !name.empty(); + out->is_host = host ? 1 : 0; + out->latency_ms = -1; + return 1; +>>>>>>> Stashed changes } PsxLobbyMember mem{}; if (!psx_lobby_member_get(index, &mem)) return 0; out->slot = mem.slot; std::snprintf(out->display_name, sizeof(out->display_name), "%s", mem.display_name); out->ready = mem.ready; +<<<<<<< Updated upstream const char* host_id = psx_lobby_host_player_id(); if (host_id && host_id[0] && mem.player_id[0]) out->is_host = (std::strcmp(host_id, mem.player_id) == 0) ? 1 : 0; else out->is_host = (mem.slot == 0) ? 1 : 0; +======= + out->is_host = mem.slot == 0; +>>>>>>> Stashed changes out->latency_ms = -1; return 1; } @@ -5804,6 +6089,7 @@ namespace { g_lnch_pending_direct_launch.local_slot = local_slot; } g_lnch_pending_direct_launch.input_player = 0; +<<<<<<< Updated upstream g_lnch_pending_direct_launch.session_id = g_lnch_lan_session_id; g_lnch_pending_direct_launch.input_delay = g_lnch_lobby_input_delay; g_lnch_pending_direct_launch.max_slots = @@ -5816,6 +6102,14 @@ namespace { g_lnch_pending_direct_launch.max_slots = kAeLanMaxSlots; g_lnch_pending_direct_launch.force_input_relay = 0; g_lnch_pending_direct_launch.player_count = ae_np_lan_occupied(state); +======= + g_lnch_pending_direct_launch.session_id = 1; + g_lnch_pending_direct_launch.input_delay = g_lnch_lobby_input_delay; + g_lnch_pending_direct_launch.max_slots = 2; + g_lnch_pending_direct_launch.force_input_relay = 0; + g_lnch_pending_direct_launch.player_count = + state.joiner_name.empty() ? 1 : 2; +>>>>>>> Stashed changes if (g_lnch_hosting_lan) { const size_t colon = state.endpoint.rfind(':'); const char* port = colon == std::string::npos @@ -5885,6 +6179,7 @@ namespace { out->session_id = ji->session_id; out->input_delay = (caps && caps->valid) ? caps->input_delay : g_lnch_lobby_input_delay; +<<<<<<< Updated upstream out->max_slots = ji->max_slots >= 2 ? ji->max_slots : (g_lnch_game_players >= 2 ? g_lnch_game_players : 2); if (out->max_slots > PSX_MAX_PLAYERS) out->max_slots = PSX_MAX_PLAYERS; @@ -5904,6 +6199,12 @@ namespace { if (seated > out->max_slots) seated = out->max_slots; out->player_count = seated; } +======= + out->max_slots = ji->max_slots >= 2 ? ji->max_slots : 2; + if (out->max_slots > PSX_MAX_PLAYERS) out->max_slots = PSX_MAX_PLAYERS; + out->player_count = ji->player_count > 0 ? ji->player_count : out->max_slots; + if (out->player_count > out->max_slots) out->player_count = out->max_slots; +>>>>>>> Stashed changes out->force_input_relay = (caps && caps->valid && caps->force_input_relay) ? 1 : 0; return 1; @@ -5946,7 +6247,10 @@ namespace { ae_np_input_delay_set, ae_np_force_input_relay_get, ae_np_force_input_relay_set, +<<<<<<< Updated upstream ae_np_lobby_max_slots, +======= +>>>>>>> Stashed changes }; } // namespace #endif @@ -7169,9 +7473,23 @@ std::string player_device[PSX_MAX_PLAYERS]; net_cfg.input_delay = ls.netplay_launch.input_delay; net_cfg.force_input_relay = ls.netplay_launch.force_input_relay ? 1 : 0; net_cfg.player_count = ls.netplay_launch.player_count; +<<<<<<< Updated upstream net_cfg.slot_count = ae_np_session_slot_count( ls.netplay_launch.player_count, ls.netplay_launch.max_slots, ls.netplay_launch.local_slot, game_players); +======= + /* Delay-sync READY/START needs seated count, not lobby ceiling. */ + net_cfg.slot_count = ls.netplay_launch.player_count >= 2 + ? ls.netplay_launch.player_count + : (ls.netplay_launch.max_slots >= 2 + ? ls.netplay_launch.max_slots + : 2); + if (ls.netplay_launch.local_slot + 1 > net_cfg.slot_count) + net_cfg.slot_count = ls.netplay_launch.local_slot + 1; + if (net_cfg.slot_count < 2) net_cfg.slot_count = 2; + if (net_cfg.slot_count > PSX_MAX_PLAYERS) + net_cfg.slot_count = PSX_MAX_PLAYERS; +>>>>>>> Stashed changes if (net_cfg.player_count <= 0) net_cfg.player_count = net_cfg.slot_count; std::snprintf(net_cfg.bind_hostport, sizeof(net_cfg.bind_hostport), "%s", diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index 0c57276d1..d98f8cfd7 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -416,7 +416,10 @@ static int endpoint_port_is_zero(const char *ep) return (int)strtoul(colon + 1, NULL, 10) == 0; } +<<<<<<< Updated upstream /* Prefer a usable host:port among candidates (skip empty / :0). */ +======= +>>>>>>> Stashed changes static void copy_first_usable_endpoint(char *dst, size_t dst_len, const char *a, const char *b, const char *c) { @@ -440,7 +443,10 @@ static int using_server_input_relay(const PsxLobbyJoinInfo *j) { if (g_lc.match_caps.valid && g_lc.match_caps.force_input_relay) return 1; +<<<<<<< Updated upstream /* Server rewrote both endpoints to the same relay advertise address. */ +======= +>>>>>>> Stashed changes if (j && j->host_endpoint[0] && j->guest_endpoint[0] && !endpoint_port_is_zero(j->host_endpoint) && !endpoint_port_is_zero(j->guest_endpoint) && @@ -459,14 +465,18 @@ static void fill_peer_bind_from_join(void) memset(j->bind_hostport, 0, sizeof(j->bind_hostport)); memset(j->peer_hostport, 0, sizeof(j->peer_hostport)); if (force_relay) { +<<<<<<< Updated upstream /* Everyone dials the lobby-server UDP relay — ephemeral local bind * (same as LAN guests) so same-PC multi-instance doesn't collide. */ +======= +>>>>>>> Stashed changes strncpy(j->bind_hostport, "0.0.0.0:0", sizeof(j->bind_hostport) - 1); copy_first_usable_endpoint(j->peer_hostport, sizeof(j->peer_hostport), j->host_endpoint, j->guest_endpoint, NULL); } else if (g_lc.is_host) { strncpy(j->bind_hostport, g_lc.my_bind, sizeof(j->bind_hostport) - 1); if (!host_hub) { +<<<<<<< Updated upstream /* 2P P2P: dial guest when they advertised a fixed port. Online * guests often join with :0 — leave peer empty (accept-first). */ if (j->guest_endpoint[0] && !endpoint_port_is_zero(j->guest_endpoint)) @@ -477,6 +487,12 @@ static void fill_peer_bind_from_join(void) /* Guests dialing 3+ host hub: ephemeral local UDP (join only probes * 7778+ and does not hold the socket). 2P P2P keeps the advertised * fixed guest_bind so the host can dial. */ +======= + if (j->guest_endpoint[0] && !endpoint_port_is_zero(j->guest_endpoint)) + strncpy(j->peer_hostport, j->guest_endpoint, sizeof(j->peer_hostport) - 1); + } + } else { +>>>>>>> Stashed changes if (seats >= 3) { strncpy(j->bind_hostport, "0.0.0.0:0", sizeof(j->bind_hostport) - 1); } else { @@ -789,9 +805,12 @@ static void handle_server_json(const char *json) g_lc.join.max_slots = json_get_int(json, "max_slots", g_lc.join.max_slots); g_lc.join.session_id = (uint32_t)json_get_int(json, "session_id", (int)g_lc.join.session_id); ingest_match_caps_from_json(json); +<<<<<<< Updated upstream /* Prefer explicit relay_endpoint when the server opened input relay. * Apply after caps ingest: omitted force_input_relay must not leave * hosts on the hub path while guests dial the relay. */ +======= +>>>>>>> Stashed changes if (relay_endpoint[0] && !endpoint_port_is_zero(relay_endpoint)) { strncpy(g_lc.join.host_endpoint, relay_endpoint, sizeof(g_lc.join.host_endpoint) - 1); diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index cf1cdb82d..8f2f56bdb 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -21,8 +21,14 @@ #include "recomp_net/recomp_net.h" #endif +<<<<<<< Updated upstream #ifndef PSX_MAX_PLAYERS #define PSX_MAX_PLAYERS 2 +======= +/* Align with RNET_MAX_SLOTS / SCPH-1070 (2 ports × 4 taps). */ +#ifndef PSX_MAX_PLAYERS +#define PSX_MAX_PLAYERS 8 +>>>>>>> Stashed changes #endif /* Session pad count mirrored for release_pads (available without recomp-net). */ @@ -119,6 +125,11 @@ static void force_session_pads_connected(int slot_count) for (i = 0; i < slot_count; ++i) { sio_connect_pad(i); sio_set_pad_config_capable(i, 1); + /* Mirror onto port-2 multitap taps (BPE and other port-2 MT titles). */ + if (slot_count >= 3 && i < 4) { + sio_connect_pad(4 + i); + sio_set_pad_config_capable(4 + i, 1); + } } } @@ -171,6 +182,9 @@ int psx_netplay_in_load_barrier(void) { return 0; } int psx_netplay_poll_admit(void) { return 1; } void psx_netplay_finish_frame(void) {} void psx_netplay_wait_recv(int timeout_ms) { (void)timeout_ms; } +int psx_netplay_remote_lead(void) { return 0; } +int psx_netplay_input_delay(void) { return 2; } +int psx_netplay_catchup_budget(void) { return 0; } #else /* PSX_HAS_RECOMP_NET */ @@ -804,9 +818,12 @@ static void decode_pad(const RNetInputSample *in, PsxNetPad *pad) psx_netplay_normalize_pad(pad); } -static void apply_pad_slot(int slot, const PsxNetPad *pad) +static void apply_pad_slot_one(int slot, const PsxNetPad *pad) { +<<<<<<< Updated upstream if (slot < 0 || slot >= g_np.slot_count || slot >= PSX_MAX_PLAYERS || !pad) return; +======= +>>>>>>> Stashed changes sio_set_pad_connected(slot, 1); sio_set_pad_config_capable(slot, 1); sio_set_pad_state_slot(slot, pad->buttons); @@ -814,6 +831,15 @@ static void apply_pad_slot(int slot, const PsxNetPad *pad) sio_request_pad_type(slot, pad->analog ? 1 : 0); } +static void apply_pad_slot(int slot, const PsxNetPad *pad) +{ + if (slot < 0 || slot >= g_np.slot_count || slot >= PSX_MAX_PLAYERS || !pad) return; + apply_pad_slot_one(slot, pad); + /* Port-2 multitap mirror for titles that require MT on console port 2. */ + if (g_np.slot_count >= 3 && slot < 4) + apply_pad_slot_one(4 + slot, pad); +} + static void host_sample_local(rnet_u32 tick, RNetInputSample *out, void *ctx) { NetplayState *st = (NetplayState *)ctx; @@ -1010,11 +1036,20 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) g_np.session = rnet_session_create(&rcfg, &host); if (!g_np.session) return -2; +<<<<<<< Updated upstream /* Host-as-relay: slot 0 with 3+ seats and no dial peer (guests dial host). */ { const int peer_empty = !cfg->peer_hostport || !cfg->peer_hostport[0]; const int use_hub = (local == 0 && slots >= 3 && peer_empty); +======= + /* Host-as-relay: lobby owner gets an empty peer and fans out UDP. Transport + * hub role is independent of sim local_slot (seats may be reordered). */ + { + const int peer_empty = + !cfg->peer_hostport || !cfg->peer_hostport[0]; + const int use_hub = (slots >= 3 && peer_empty); +>>>>>>> Stashed changes const int rc = use_hub ? rnet_session_start_lan_hub(g_np.session, cfg->bind_hostport) : rnet_session_start_lan(g_np.session, cfg->bind_hostport, @@ -1031,10 +1066,14 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) g_np_slot_count = g_np.slot_count; g_np.local_slot = (int)rcfg.local_slot; g_np.input_player = in_player; +<<<<<<< Updated upstream if (g_np.slot_count >= 3) sio_set_multitap(1); else sio_set_multitap(0); +======= + force_session_pads_connected(g_np.slot_count); +>>>>>>> Stashed changes g_np.staged_valid = 0; g_np.needs_advance = 0; g_np.latched_for_tick = 0; @@ -1216,4 +1255,33 @@ void psx_netplay_wait_recv(int timeout_ms) (void)rnet_session_wait_recv(g_np.session, timeout_ms); } +int psx_netplay_remote_lead(void) +{ + RNetSessionStats st; + if (!psx_netplay_active()) return 0; + memset(&st, 0, sizeof(st)); + rnet_session_get_stats(g_np.session, &st); + return st.remote_lead; +} + +int psx_netplay_input_delay(void) +{ + int d; + if (!psx_netplay_active()) return 2; + d = (int)rnet_session_committed_delay(g_np.session); + return d > 0 ? d : 2; +} + +int psx_netplay_catchup_budget(void) +{ + int lead, delay, budget; + if (!psx_netplay_active()) return 0; + lead = psx_netplay_remote_lead(); + delay = psx_netplay_input_delay(); + budget = lead - delay; + if (budget < 0) budget = 0; + if (budget > 8) budget = 8; + return budget; +} + #endif /* PSX_HAS_RECOMP_NET */ diff --git a/runtime/src/sio.c b/runtime/src/sio.c index cd79f833b..fd3bf905c 100644 --- a/runtime/src/sio.c +++ b/runtime/src/sio.c @@ -41,6 +41,7 @@ static void sio_debug_poll_maybe(void) { } } +<<<<<<< Updated upstream /* Pad state: 0=pressed, 1=released (PS1 convention). Indexed by LOGICAL pad * 0 .. PSX_MAX_PLAYERS-1 (not physical SIO slot). */ static uint16_t pad_buttons[PSX_MAX_PLAYERS] = { [0 ... PSX_MAX_PLAYERS - 1] = 0xFFFF }; @@ -60,6 +61,27 @@ static uint8_t pad_stick[PSX_MAX_PLAYERS][4] = { * flips underneath a game that pinned DualShock, the exact desync the * deferred-request machinery cannot otherwise prevent. */ static uint8_t analog_mode_locked[PSX_MAX_PLAYERS]; +======= +/* Logical pad slots: without multitap, 0/1 are console ports 1/2. With + * SCPH-1070 multitap on a port, that port exposes four pads at + * port*4 + {0..3}. Netplay maps session seats onto these indices. */ +#define SIO_PAD_SLOTS 8 +#define SIO_MT_RSP_MAX 34 /* 5A80h + 4 pads × 4 halfwords */ + +/* Pad state: 0=pressed, 1=released (PS1 convention). */ +static uint16_t pad_buttons[SIO_PAD_SLOTS]; +static uint8_t pad_analog[SIO_PAD_SLOTS]; +static uint8_t pad_stick[SIO_PAD_SLOTS][4]; /* lx,ly,rx,ry */ + +/* Analog-mode lock, per slot. A real DualShock's config command 0x44 0x..02/0x03 + * locks/unlocks the mode (dualshock.cpp:714-725); a locked pad ignores the + * physical analog button (dualshock.cpp:203). We emulate the analog button via + * the host hybrid heuristic (pad_type_req), so when a game LOCKS the mode the + * hybrid auto-flip must not override it — else the type flips underneath a game + * that pinned DualShock, the exact desync the deferred-request machinery cannot + * otherwise prevent. */ +static uint8_t analog_mode_locked[SIO_PAD_SLOTS]; +>>>>>>> Stashed changes /* Which logical pads have devices connected (bit i = pad i). Fits 5 pads. */ static uint8_t pad_connected = 0; @@ -87,6 +109,7 @@ typedef enum { } MtapNextMode; static PadState pad_state = PAD_IDLE; +<<<<<<< Updated upstream static int selected_slot = 0; /* physical SIO slot (CTRL bit13): 0 or 1 */ static int pad_active_logical = 0; /* logical pad for single-pad / config cmds */ static uint8_t pad_response[PAD_RESPONSE_MAX]; @@ -121,6 +144,40 @@ static uint8_t pad_in_config[PSX_MAX_PLAYERS]; static uint8_t pad_supports_config[PSX_MAX_PLAYERS] = { [0 ... PSX_MAX_PLAYERS - 1] = 1 }; +======= +static int selected_slot = 0; /* console port 0/1 from SIO_CTRL bit 13 */ +static int active_pad = 0; /* logical pad for the current transaction */ +static uint8_t pad_response[SIO_MT_RSP_MAX]; +static uint8_t pad_response_len = 0; +static uint8_t pad_response_idx = 0; +static uint8_t pad_current_cmd = 0; +/* SCPH-1070: multitap on console port 0 and/or 1. Method-1 LONG response is + * armed by TX byte 3 == 0x01 on a Slot-A poll and consumed on the next poll. */ +static uint8_t mt_on[2]; +static uint8_t mt_req_next[2]; +static uint8_t mt_prev_long[2]; +/* DualShock config-mode latch, per slot. A real controller only answers the + * config commands (0x44/0x45/0x46/0x47/0x4C/0x4D/0x4F) and reports the config + * ID 0xF3 while it is IN config mode; outside config it reports its normal ID + * (0x41 digital / 0x73 analog) and ignores config commands. Config is entered/ + * exited by command 0x43 with the data byte 0x01(enter)/0x00(exit). Faking + * "always in config" (constant 0xF3) wedges games that probe the pad type via + * 0x43 before polling — e.g. Mega Man X6 loops 01 43 00 00 forever and never + * reaches 0x42. (MMX6 ISSUES.md #2.) */ +static uint8_t pad_in_config[SIO_PAD_SLOTS]; + +/* Whether the pad on a slot is a config-capable DualShock (1) or a plain + * digital controller (0). A real SCPH-1080 digital pad (poll id 0x41) does NOT + * answer the config-mode commands (0x43/0x44/.../0x4F): it returns hi-z and the + * transaction ends. A game's pad driver that probes with 0x43 to detect a + * DualShock therefore classifies a digital pad as digital-only and just polls + * it with 0x42. Tomba 2's driver probes this way every frame; when the SM + * (wrongly) answered 0x43 for its digital pad it went down the DualShock config + * path and read the 0x00 config-response bytes as buttons -> phantom "all + * pressed" input. Default 1 keeps analog/hybrid pads unchanged; main.cpp sets 0 + * for PAD_MODE_DIGITAL. */ +static uint8_t pad_supports_config[SIO_PAD_SLOTS]; +>>>>>>> Stashed changes /* Coherent-DualShock model (Tomba phantom-input fix). A real controller never * changes its reported type (0x41 digital <-> 0x73 analog) in the middle of a @@ -133,6 +190,7 @@ static uint8_t pad_supports_config[PSX_MAX_PLAYERS] = { * host REQUESTS a type via pad_type_req[] and the change is applied atomically * only when the bus is idle (PAD_IDLE) and the pad is NOT in config mode. A * request raised during config is held until config exits. -1 = no request. */ +<<<<<<< Updated upstream static int8_t pad_type_req[PSX_MAX_PLAYERS] = { [0 ... PSX_MAX_PLAYERS - 1] = -1 }; @@ -230,6 +288,9 @@ static void pad_fill_status8(int logical, uint8_t out[8]) { out[4] = out[5] = out[6] = out[7] = 0xFF; } } +======= +static int8_t pad_type_req[SIO_PAD_SLOTS]; +>>>>>>> Stashed changes /* Memory card SIO state machine */ typedef enum { @@ -652,6 +713,7 @@ static int sio_ack_visible_reads = 0; #define SIO_CTRL_SLOT (1 << 13) void sio_init(void) { + int p; sio_tx_data = 0; sio_rx_data = 0xFF; sio_stat = SIO_STAT_TX_RDY | SIO_STAT_TX_EMPTY; @@ -662,6 +724,7 @@ void sio_init(void) { pad_response_len = 0; pad_response_idx = 0; pad_current_cmd = 0; +<<<<<<< Updated upstream pad_active_logical = 0; for (int i = 0; i < PSX_MAX_PLAYERS; i++) { pad_buttons[i] = 0xFFFF; @@ -671,6 +734,21 @@ void sio_init(void) { pad_type_req[i] = -1; analog_mode_locked[i] = 0; pad_supports_config[i] = 1; +======= + active_pad = 0; + selected_slot = 0; + mt_on[0] = mt_on[1] = 0; + mt_req_next[0] = mt_req_next[1] = 0; + mt_prev_long[0] = mt_prev_long[1] = 0; + for (p = 0; p < SIO_PAD_SLOTS; p++) { + pad_buttons[p] = 0xFFFF; + pad_analog[p] = 0; + pad_stick[p][0] = pad_stick[p][1] = pad_stick[p][2] = pad_stick[p][3] = 0x80; + pad_in_config[p] = 0; + pad_type_req[p] = -1; + analog_mode_locked[p] = 0; + pad_supports_config[p] = 1; +>>>>>>> Stashed changes } pad_connected = 0; /* Multitap enable/port are host preferences — leave them alone across @@ -769,6 +847,7 @@ int sio_get_multitap_port(void) { } void sio_connect_pad(int slot) { +<<<<<<< Updated upstream if (slot >= 0 && slot < PSX_MAX_PLAYERS) pad_connected |= (uint8_t)(1u << slot); } @@ -781,6 +860,20 @@ void sio_set_pad_connected(int slot, int connected) { void sio_set_pad_config_capable(int slot, int capable) { if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; +======= + if (slot >= 0 && slot < SIO_PAD_SLOTS) + pad_connected |= (uint8_t)(1 << slot); +} + +void sio_set_pad_connected(int slot, int connected) { + if (slot < 0 || slot >= SIO_PAD_SLOTS) return; + if (connected) pad_connected |= (uint8_t)(1 << slot); + else pad_connected &= (uint8_t)~(1 << slot); +} + +void sio_set_pad_config_capable(int slot, int capable) { + if (slot < 0 || slot >= SIO_PAD_SLOTS) return; +>>>>>>> Stashed changes pad_supports_config[slot] = capable ? 1 : 0; /* A plain digital pad can never be in config mode; clear any stale latch so * the next poll reports the digital id (0x41), not the config id (0xF3). */ @@ -792,7 +885,28 @@ void sio_set_pad_state(uint16_t buttons) { } void sio_set_pad_state_slot(int slot, uint16_t buttons) { +<<<<<<< Updated upstream if (slot >= 0 && slot < PSX_MAX_PLAYERS) pad_buttons[slot] = buttons; +======= + if (slot >= 0 && slot < SIO_PAD_SLOTS) pad_buttons[slot] = buttons; +} + +/* Enable SCPH-1070 multitap on both console ports (covers port-1 games and + * BPE-style port-2 multitap). Netplay seats map to logical pads 0..N-1 and + * are mirrored onto port-2's multitap (4..4+N-1) by the netplay layer. */ +void sio_set_multitap(int enabled) { + const uint8_t on = enabled ? 1u : 0u; + mt_on[0] = on; + mt_on[1] = on; + if (!on) { + mt_req_next[0] = mt_req_next[1] = 0; + mt_prev_long[0] = mt_prev_long[1] = 0; + } +} + +int sio_get_multitap(void) { + return (mt_on[0] || mt_on[1]) ? 1 : 0; +>>>>>>> Stashed changes } /* Direct set of pad type + sticks. Used at boot/hotplug (refresh_player_devices) @@ -802,7 +916,11 @@ void sio_set_pad_state_slot(int slot, uint16_t buttons) { * coherently (see pad_type_req[] above). */ void sio_set_pad_analog(int slot, int enabled, uint8_t lx, uint8_t ly, uint8_t rx, uint8_t ry) { +<<<<<<< Updated upstream if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; +======= + if (slot < 0 || slot >= SIO_PAD_SLOTS) return; +>>>>>>> Stashed changes pad_analog[slot] = enabled ? 1 : 0; pad_type_req[slot] = -1; /* explicit set supersedes any pending request */ pad_stick[slot][0] = lx; pad_stick[slot][1] = ly; @@ -811,7 +929,11 @@ void sio_set_pad_analog(int slot, int enabled, /* Per-frame stick update (does not touch the reported pad type). */ void sio_set_pad_sticks(int slot, uint8_t lx, uint8_t ly, uint8_t rx, uint8_t ry) { +<<<<<<< Updated upstream if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; +======= + if (slot < 0 || slot >= SIO_PAD_SLOTS) return; +>>>>>>> Stashed changes pad_stick[slot][0] = lx; pad_stick[slot][1] = ly; pad_stick[slot][2] = rx; pad_stick[slot][3] = ry; } @@ -820,7 +942,11 @@ void sio_set_pad_sticks(int slot, uint8_t lx, uint8_t ly, uint8_t rx, uint8_t ry * deferred and applied atomically at the next idle, non-config boundary, so it * can never split a poll or a config handshake. A no-op if already that type. */ void sio_request_pad_type(int slot, int analog) { +<<<<<<< Updated upstream if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; +======= + if (slot < 0 || slot >= SIO_PAD_SLOTS) return; +>>>>>>> Stashed changes int want = analog ? 1 : 0; pad_type_req[slot] = (pad_analog[slot] == want) ? -1 : (int8_t)want; } @@ -830,6 +956,7 @@ uint16_t sio_get_pad_buttons(void) { } uint16_t sio_get_pad_buttons_slot(int slot) { +<<<<<<< Updated upstream return (slot >= 0 && slot < PSX_MAX_PLAYERS) ? pad_buttons[slot] : 0xFFFF; } @@ -840,11 +967,27 @@ int sio_get_pad_connected(int slot) { int sio_get_pad_analog(int slot) { return (slot >= 0 && slot < PSX_MAX_PLAYERS) ? pad_analog[slot] : 0; +======= + return (slot >= 0 && slot < SIO_PAD_SLOTS) ? pad_buttons[slot] : 0xFFFF; +} + +int sio_get_pad_connected(int slot) { + if (slot < 0 || slot >= SIO_PAD_SLOTS) return 0; + return (pad_connected & (1 << slot)) ? 1 : 0; +} + +int sio_get_pad_analog(int slot) { + return (slot >= 0 && slot < SIO_PAD_SLOTS) ? pad_analog[slot] : 0; +>>>>>>> Stashed changes } void sio_get_pad_sticks(int slot, uint8_t out[4]) { if (!out) return; +<<<<<<< Updated upstream if (slot < 0 || slot >= PSX_MAX_PLAYERS) { +======= + if (slot < 0 || slot >= SIO_PAD_SLOTS) { +>>>>>>> Stashed changes out[0] = out[1] = out[2] = out[3] = 0x80; return; } @@ -908,21 +1051,68 @@ void sio_get_pad_sticks(int slot, uint8_t out[4]) { volatile int g_pad_legacy_cfg = 0; int sio_get_legacy_cfg(void) { return g_pad_legacy_cfg; } void sio_set_legacy_cfg(int v) { + int s; g_pad_legacy_cfg = v ? 1 : 0; /* Clear any in-flight config latch so a mid-session toggle can't carry a * stale 0xF3/8-byte poll into the other mode's dispatch. */ +<<<<<<< Updated upstream for (int s = 0; s < PSX_MAX_PLAYERS; s++) pad_in_config[s] = 0; +======= + for (s = 0; s < SIO_PAD_SLOTS; s++) + pad_in_config[s] = 0; +} + +/* Pack one pad's 4 halfwords into an 8-byte multitap slot block (digital pads + * and empty slots pad unused halfwords with FFFFh per psx-spx). */ +static void mt_pack_pad_block(uint8_t *dst, int pad) { + if (pad < 0 || pad >= SIO_PAD_SLOTS || !(pad_connected & (1 << pad))) { + memset(dst, 0xFF, 8); + return; + } + { + const uint16_t btn = pad_buttons[pad]; + const uint8_t id = pad_in_config[pad] ? 0xF3u + : (pad_analog[pad] ? 0x73u : 0x41u); + dst[0] = id; + dst[1] = 0x5A; + dst[2] = (uint8_t)(btn & 0xFF); + dst[3] = (uint8_t)(btn >> 8); + if (pad_analog[pad] || pad_in_config[pad]) { + dst[4] = pad_stick[pad][2]; + dst[5] = pad_stick[pad][3]; + dst[6] = pad_stick[pad][0]; + dst[7] = pad_stick[pad][1]; + } else { + dst[4] = dst[5] = dst[6] = dst[7] = 0xFF; + } + } +} + +static void mt_build_long_response(int port) { + int t; + pad_response[0] = 0x80; + pad_response[1] = 0x5A; + for (t = 0; t < 4; t++) + mt_pack_pad_block(&pad_response[2 + t * 8], port * 4 + t); + pad_response_len = SIO_MT_RSP_MAX; +>>>>>>> Stashed changes } static void pad_process_byte(uint8_t tx_byte) { + const int port = selected_slot; + const int mt = (port >= 0 && port <= 1 && mt_on[port]) ? 1 : 0; /* Apply any pending host type change (the emulated analog button) ONLY while * the bus is idle and the pad is not in config mode. This guarantees the * reported type (0x41/0x73) is stable for the whole of any poll or config * handshake — a hybrid stick/d-pad flip can never desync the game's driver * mid-transaction. A request raised during config stays pending until exit. */ if (pad_state == PAD_IDLE) { +<<<<<<< Updated upstream for (int s = 0; s < PSX_MAX_PLAYERS; s++) { +======= + for (int s = 0; s < SIO_PAD_SLOTS; s++) { +>>>>>>> Stashed changes /* A game-LOCKED analog mode (0x44 ..03) ignores the physical analog * button — and our hybrid auto-flip IS that button — so a locked slot * drops the pending host request instead of applying it. */ @@ -934,6 +1124,7 @@ static void pad_process_byte(uint8_t tx_byte) { } switch (pad_state) { case PAD_IDLE: +<<<<<<< Updated upstream /* Standard address 01h selects Slot A (or the standalone pad). With a * multitap, 02h..04h select pads B–D on that port (psx-spx method 2). */ if (tx_byte == 0x01 && pad_port_has_device(selected_slot)) { @@ -945,6 +1136,34 @@ static void pad_process_byte(uint8_t tx_byte) { } else if (selected_is_mtap_port() && tx_byte >= 0x02 && tx_byte <= 0x04) { pad_active_logical = mtap_slot_a_logical() + (int)(tx_byte - 1); pad_mtap_addr = tx_byte; +======= + /* 0x01 = pad/multitap select; with multitap, 0x02..0x04 select taps B-D + * (method 2). Slot A (tap 0) must be present to arm method-1 REQ. */ + if (tx_byte >= 0x01 && tx_byte <= 0x04) { + const int tap = (int)tx_byte - 1; + if (mt) { + active_pad = port * 4 + tap; + if (!(pad_connected & (1 << (port * 4)))) { + /* Empty Slot A: transfer aborts after first byte. */ + sio_rx_data = 0xFF; + break; + } + if (tap > 0 && !(pad_connected & (1 << active_pad))) { + sio_rx_data = 0xFF; + break; + } + } else { + if (tx_byte != 0x01) { + sio_rx_data = 0xFF; + break; + } + active_pad = port; + if (!(pad_connected & (1 << active_pad))) { + sio_rx_data = 0xFF; + break; + } + } +>>>>>>> Stashed changes pad_state = PAD_WAIT_ACCESS; sio_rx_data = 0xFF; sio_stat |= SIO_STAT_ACK; @@ -1009,13 +1228,20 @@ static void pad_process_byte(uint8_t tx_byte) { /* Controller ID reported as the first response byte. Real hardware * reports the config ID (0xF3) ONLY while in config mode; otherwise the * normal mode ID (0x41 digital / 0x73 analog). */ +<<<<<<< Updated upstream const uint8_t cur_id = pad_in_config[lp] ? 0xF3 : (pad_analog[lp] ? 0x73 : 0x41); +======= + { + const uint8_t cur_id = pad_in_config[active_pad] ? 0xF3 + : (pad_analog[active_pad] ? 0x73 : 0x41); +>>>>>>> Stashed changes /* A plain digital controller (SCPH-1080) answers ONLY the 0x42 poll; it * ignores every config-mode command (returns hi-z, no ACK). A driver * that probes with 0x43 to detect a DualShock then classifies it as * digital-only and just polls. Gate all config branches on this so a * digital-mode pad behaves like real hardware (see pad_supports_config). */ +<<<<<<< Updated upstream const int ds = pad_supports_config[lp]; if (tx_byte == 0x42) { /* Read poll. Analog (or in-config) uses the 8-byte format with the @@ -1031,8 +1257,42 @@ static void pad_process_byte(uint8_t tx_byte) { pad_response[6] = pad_stick[lp][0]; /* left X */ pad_response[7] = pad_stick[lp][1]; /* left Y */ pad_response_len = 8; +======= + const int ds = pad_supports_config[active_pad]; + if (tx_byte == 0x42) { + /* Multitap method 1: previous REQ arms a LONG Slot A-D response. */ + if (mt && mt_req_next[port]) { + mt_req_next[port] = 0; + if (mt_prev_long[port]) { + /* REQ while already long → short "garbage" (psx-spx). */ + pad_response[0] = 0x80; + pad_response[1] = 0x5A; + pad_response[2] = pad_analog[port * 4] ? 0x73 : 0x41; + pad_response_len = 3; + mt_prev_long[port] = 0; + } else { + mt_build_long_response(port); + mt_prev_long[port] = 1; + } +>>>>>>> Stashed changes } else { - pad_response_len = 4; + mt_prev_long[port] = 0; + /* Read poll. Analog (or in-config) uses the 8-byte format with the + * four stick axes; a plain digital pad uses the 4-byte format. */ + const uint16_t btn = pad_buttons[active_pad]; + pad_response[0] = cur_id; + pad_response[1] = 0x5A; + pad_response[2] = (uint8_t)(btn & 0xFF); + pad_response[3] = (uint8_t)(btn >> 8); + if (pad_analog[active_pad] || pad_in_config[active_pad]) { + pad_response[4] = pad_stick[active_pad][2]; /* right X */ + pad_response[5] = pad_stick[active_pad][3]; /* right Y */ + pad_response[6] = pad_stick[active_pad][0]; /* left X */ + pad_response[7] = pad_stick[active_pad][1]; /* left Y */ + pad_response_len = 8; + } else { + pad_response_len = 4; + } } pad_state = PAD_SEND_RESPONSE; sio_rx_data = pad_response[0]; @@ -1041,7 +1301,11 @@ static void pad_process_byte(uint8_t tx_byte) { /* Enter/exit config mode. The ID byte reflects the CURRENT mode; the * enter(0x01)/exit(0x00) flag is the second data byte, latched in * PAD_SEND_RESPONSE so it takes effect after this transaction. */ +<<<<<<< Updated upstream const uint16_t btn = pad_buttons[lp]; +======= + const uint16_t btn = pad_buttons[active_pad]; +>>>>>>> Stashed changes pad_response[1] = 0x5A; if (g_pad_legacy_cfg) { /* LEGACY (pre-98aa688): always config ID 0xF3, zero frame, no @@ -1051,7 +1315,11 @@ static void pad_process_byte(uint8_t tx_byte) { pad_response[4] = 0x00; pad_response[5] = 0x00; pad_response[6] = 0x00; pad_response[7] = 0x00; pad_response_len = 8; +<<<<<<< Updated upstream } else if (!pad_in_config[lp]) { +======= + } else if (!pad_in_config[active_pad]) { +>>>>>>> Stashed changes /* ENTER attempt (normal mode): a real DualShock transmits the LIVE * poll frame here — identical framing to 0x42 (dualshock.cpp:471-490) * — and only latches config entry from the 0x01 data byte AFTERWARD. @@ -1062,11 +1330,19 @@ static void pad_process_byte(uint8_t tx_byte) { pad_response[0] = cur_id; pad_response[2] = (uint8_t)(btn & 0xFF); pad_response[3] = (uint8_t)(btn >> 8); +<<<<<<< Updated upstream if (pad_analog[lp]) { pad_response[4] = pad_stick[lp][2]; /* right X */ pad_response[5] = pad_stick[lp][3]; /* right Y */ pad_response[6] = pad_stick[lp][0]; /* left X */ pad_response[7] = pad_stick[lp][1]; /* left Y */ +======= + if (pad_analog[active_pad]) { + pad_response[4] = pad_stick[active_pad][2]; /* right X */ + pad_response[5] = pad_stick[active_pad][3]; /* right Y */ + pad_response[6] = pad_stick[active_pad][0]; /* left X */ + pad_response[7] = pad_stick[active_pad][1]; /* left Y */ +>>>>>>> Stashed changes pad_response_len = 8; } else { pad_response_len = 4; @@ -1103,12 +1379,20 @@ static void pad_process_byte(uint8_t tx_byte) { /* 0x45 status byte must report the LIVE analog mode, not a fixed * analog-on (dualshock.cpp:743) — see fix below for the modern path. */ if (tx_byte == 0x45) +<<<<<<< Updated upstream pad_response[3] = pad_analog[lp] ? 0x01 : 0x00; +======= + pad_response[3] = pad_analog[active_pad] ? 0x01 : 0x00; +>>>>>>> Stashed changes pad_response_len = 8; pad_state = PAD_SEND_RESPONSE; sio_rx_data = pad_response[0]; sio_stat |= SIO_STAT_ACK; +<<<<<<< Updated upstream } else if (ds && !g_pad_legacy_cfg && pad_in_config[lp] && +======= + } else if (ds && !g_pad_legacy_cfg && pad_in_config[active_pad] && +>>>>>>> Stashed changes (tx_byte == 0x44 || tx_byte == 0x45 || tx_byte == 0x46 || tx_byte == 0x47 || tx_byte == 0x4C || tx_byte == 0x4D || tx_byte == 0x4F)) { @@ -1133,7 +1417,11 @@ static void pad_process_byte(uint8_t tx_byte) { * driver mis-parse the poll frame length → off-by-frame garbage buttons * (axis5_sio_controller.md D8). */ if (tx_byte == 0x45) +<<<<<<< Updated upstream pad_response[3] = pad_analog[lp] ? 0x01 : 0x00; +======= + pad_response[3] = pad_analog[active_pad] ? 0x01 : 0x00; +>>>>>>> Stashed changes pad_response_len = 8; pad_state = PAD_SEND_RESPONSE; sio_rx_data = pad_response[0]; @@ -1151,36 +1439,61 @@ static void pad_process_byte(uint8_t tx_byte) { break; case PAD_SEND_RESPONSE: +<<<<<<< Updated upstream /* TAP/REQ (third command byte, paired with idhi/5Ah at idx==1): does not * change *this* response; it arms the next 0x42 on the multitap port. */ if (selected_is_mtap_port() && pad_current_cmd == 0x42 && pad_mtap_addr == 0x01 && pad_response_idx == 1) mtap_req_this = (tx_byte == 0x01) ? 1 : 0; +======= + /* Multitap method 1: third TX byte (paired with response idx 2) is REQ. + * REQ=1 arms a LONG Slot A-D response on the *next* poll (psx-spx). */ + if (mt && pad_current_cmd == 0x42 && pad_response_idx == 2 && + active_pad == port * 4) + mt_req_next[port] = (tx_byte == 0x01) ? 1u : 0u; +>>>>>>> Stashed changes /* For 0x43 (enter/exit config), the data byte selecting enter(0x01)/ * exit(0x00) arrives paired with response index 2. Latch the new config * state; it takes effect from the next transaction (the ID byte already * reported the mode that was current at the start of this one). */ +<<<<<<< Updated upstream if (!g_pad_legacy_cfg && pad_current_cmd == 0x43 && pad_response_idx == 2 && pad_active_logical >= 0 && pad_active_logical < PSX_MAX_PLAYERS) pad_in_config[pad_active_logical] = (tx_byte == 0x01) ? 1 : 0; +======= + if (!g_pad_legacy_cfg && pad_current_cmd == 0x43 && pad_response_idx == 2) + pad_in_config[active_pad] = (tx_byte == 0x01) ? 1 : 0; +>>>>>>> Stashed changes /* 0x44 set-mode (game owns the analog/digital mode): the mode byte rides * in the same slot as 0x43's enter/exit flag (data position 3). 0x01 => * analog (0x73), 0x00 => digital (0x41). Honouring it makes the pad * coherent — the type the game just selected is the type it then polls, * instead of the host hybrid silently winning. Drop any stale host * request so it can't immediately undo the game's choice. */ +<<<<<<< Updated upstream if (!g_pad_legacy_cfg && pad_current_cmd == 0x44 && pad_response_idx == 2 && pad_active_logical >= 0 && pad_active_logical < PSX_MAX_PLAYERS) { pad_analog[pad_active_logical] = (tx_byte == 0x01) ? 1 : 0; pad_type_req[pad_active_logical] = -1; +======= + if (!g_pad_legacy_cfg && pad_current_cmd == 0x44 && pad_response_idx == 2) { + pad_analog[active_pad] = (tx_byte == 0x01) ? 1 : 0; + pad_type_req[active_pad] = -1; +>>>>>>> Stashed changes } /* 0x44 lock byte (data position 4, the byte after the mode byte): 0x03 => * lock analog mode, 0x02 => unlock (dualshock.cpp:714-725). A locked slot * ignores the host hybrid auto-flip (see analog_mode_locked). */ +<<<<<<< Updated upstream if (!g_pad_legacy_cfg && pad_current_cmd == 0x44 && pad_response_idx == 3 && pad_active_logical >= 0 && pad_active_logical < PSX_MAX_PLAYERS) { if (tx_byte == 0x03) analog_mode_locked[pad_active_logical] = 1; else if (tx_byte == 0x02) analog_mode_locked[pad_active_logical] = 0; +======= + if (!g_pad_legacy_cfg && pad_current_cmd == 0x44 && pad_response_idx == 3) { + if (tx_byte == 0x03) analog_mode_locked[active_pad] = 1; + else if (tx_byte == 0x02) analog_mode_locked[active_pad] = 0; +>>>>>>> Stashed changes } if (pad_response_idx < pad_response_len) { sio_rx_data = pad_response[pad_response_idx++]; @@ -1474,11 +1787,10 @@ static void sio_process_byte(uint8_t tx_byte) { if (active_device == DEV_NONE) { selected_slot = (sio_ctrl & SIO_CTRL_SLOT) ? 1 : 0; - if (tx_byte == 0x01) { - /* Pad select. Save any in-flight card state back to its slot - * so it survives pad polling. Don't touch mc_state — we need - * it per-slot, and mc_load_slot will restore it when the card - * slot is selected again. */ + if (tx_byte >= 0x01 && tx_byte <= 0x04) { + /* Pad / multitap select (0x01 = Slot A or single pad; 0x02..0x04 = + * multitap taps B-D). Save any in-flight card state back to its + * slot so it survives pad polling. */ if (mc_state != MC_IDLE) { mc_save_slot(mc_slot); mc_state = MC_IDLE; /* working vars idle while pad talks */ @@ -1556,7 +1868,7 @@ static void sio_process_byte(uint8_t tx_byte) { } else { active_device = DEV_NONE; selected_slot = (sio_ctrl & SIO_CTRL_SLOT) ? 1 : 0; - if (tx_byte == 0x01) { + if (tx_byte >= 0x01 && tx_byte <= 0x04) { active_device = DEV_PAD; pad_process_byte(tx_byte); } else { From e2e3bbb82e27677ed39a715795c936292d1c8118 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Fri, 24 Jul 2026 14:43:09 -0400 Subject: [PATCH 17/38] Fix accidental conflict markers from MotK stash merge on feat/ui-5p. Restore the six conflicted runtime files to the clean 5p tip so shared psxrecomp matches BPE again. Co-authored-by: Cursor --- runtime/include/psx_netplay.h | 12 -- runtime/include/sio.h | 22 --- runtime/src/main.cpp | 322 +------------------------------- runtime/src/psx_lobby_client.c | 19 -- runtime/src/psx_netplay.c | 70 +------ runtime/src/sio.c | 326 +-------------------------------- 6 files changed, 10 insertions(+), 761 deletions(-) diff --git a/runtime/include/psx_netplay.h b/runtime/include/psx_netplay.h index 2b13100d7..3d3e56255 100644 --- a/runtime/include/psx_netplay.h +++ b/runtime/include/psx_netplay.h @@ -25,12 +25,7 @@ extern "C" { * (prefer g_players[local_slot] if assigned, else player 0). * - While active, publish / release_pads is the sole SIO writer. * - Every session slot stays plugged for in-game N-player detect. -<<<<<<< Updated upstream * - slot_count >= 3 enables SCPH-1070 multitap (sio_set_multitap). -======= - * - slot_count >= 3 enables SCPH-1070 multitap on both console ports - * (pads mirrored onto port-2 taps for BPE-style titles). ->>>>>>> Stashed changes * * Pad blob (8 bytes): * [0..1] buttons LE u16 (PSX active-low) @@ -127,13 +122,6 @@ void psx_netplay_finish_frame(void); /* Park the admit barrier until a peer datagram may be ready (or timeout). */ void psx_netplay_wait_recv(int timeout_ms); -/* highest_remote_wire - sim_tick (0 if inactive; can be negative). */ -int psx_netplay_remote_lead(void); -/* Session input delay frames (default 2 when inactive). */ -int psx_netplay_input_delay(void); -/* Extra wall-frame catch-up budget: min(8, max(0, remote_lead - delay)). */ -int psx_netplay_catchup_budget(void); - /* Normalize sticks (deadzone → center) for stabler cross-device blobs. */ void psx_netplay_normalize_pad(PsxNetPad *pad); diff --git a/runtime/include/sio.h b/runtime/include/sio.h index 5d186790b..49e146f4d 100644 --- a/runtime/include/sio.h +++ b/runtime/include/sio.h @@ -103,7 +103,6 @@ void sio_set_pad_analog(int slot, int enabled, void sio_set_pad_sticks(int slot, uint8_t lx, uint8_t ly, uint8_t rx, uint8_t ry); void sio_request_pad_type(int slot, int analog); -<<<<<<< Updated upstream /* Connect / disconnect a logical pad (0 .. PSX_MAX_PLAYERS-1). By default no * pads are connected during initial BIOS boot. */ void sio_connect_pad(int slot); @@ -117,27 +116,6 @@ void sio_set_pad_connected(int slot, int connected); * answers them. Set from the per-player pad mode (DIGITAL => 0, ANALOG/HYBRID * => 1) at boot/hotplug. Default is 1 (config-capable) so existing * analog/hybrid behaviour is unchanged. */ -======= -/* Connect / disconnect a pad on a logical slot (0..7). Without multitap, - * 0/1 are console ports 1/2. With SCPH-1070 multitap, port N exposes pads - * N*4 .. N*4+3. By default no pads are connected during initial BIOS boot. */ -void sio_connect_pad(int slot); -void sio_set_pad_connected(int slot, int connected); - -/* Enable/disable SCPH-1070 multitap on both console ports (method-1 LONG - * Slot A-D polls + method-2 tap select 0x01..0x04). */ -void sio_set_multitap(int enabled); -int sio_get_multitap(void); - -/* Declare whether the pad on a slot is a config-capable DualShock (1) or a - * plain digital controller (0). A real digital controller (SCPH-1080, poll id - * 0x41) does NOT answer the config-mode commands (0x43/0x44/0x45/0x46/0x47/ - * 0x4C/0x4D/0x4F) — it returns hi-z / no ACK, so a game's pad driver classifies - * it as digital-only and just polls with 0x42. A DualShock answers them. Set - * from the per-player pad mode (DIGITAL => 0, ANALOG/HYBRID => 1) at boot/ - * hotplug. Default is 1 (config-capable) so existing analog/hybrid behaviour is - * unchanged. */ ->>>>>>> Stashed changes void sio_set_pad_config_capable(int slot, int capable); /* Return current pad button state (for debug server). _slot targets a logical diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index ca67b1eac..da038036d 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -40,10 +40,6 @@ extern "C" void psx_event_step_conservative_env_init(void); #endif #include "psx_netplay.h" #include "psx_lobby_client.h" - -#ifndef PSX_MAX_PLAYERS -#define PSX_MAX_PLAYERS 8 -#endif #include "spu.h" #include "audio_trace.h" #include "spu_shadow.h" @@ -1128,134 +1124,6 @@ static void netplay_host_present_uncap(void) { g_netplay_vsync_forced_off = 1; } -/* Last successful present — re-swapped while admit is starved (no guest advance). */ -enum { - NP_HELD_NONE = 0, - NP_HELD_SDL, - NP_HELD_GL_CPU, - NP_HELD_GL_VRAM, - NP_HELD_GL_WIDE, - NP_HELD_VK_CPU, - NP_HELD_VK_VRAM, - NP_HELD_VK_WIDE -}; -static struct { - int mode; - int src_w, src_h; - int linear, pin43; - int disp_x, disp_y, disp_w, disp_h; -} s_np_held; -static uint32_t s_np_held_last_ms; - -static void netplay_held_note_sdl(int src_w, int src_h) { - s_np_held.mode = NP_HELD_SDL; - s_np_held.src_w = src_w; - s_np_held.src_h = src_h; -} -static void netplay_held_note_gl_cpu(int src_w, int src_h, int linear, int pin43) { - s_np_held.mode = NP_HELD_GL_CPU; - s_np_held.src_w = src_w; - s_np_held.src_h = src_h; - s_np_held.linear = linear; - s_np_held.pin43 = pin43; -} -static void netplay_held_note_gl_vram(int x, int y, int w, int h, int linear, int pin43) { - s_np_held.mode = NP_HELD_GL_VRAM; - s_np_held.disp_x = x; - s_np_held.disp_y = y; - s_np_held.disp_w = w; - s_np_held.disp_h = h; - s_np_held.linear = linear; - s_np_held.pin43 = pin43; - s_np_held.src_w = w; - s_np_held.src_h = h; -} -static void netplay_held_note_gl_wide(int x, int y, int h, int linear) { - s_np_held.mode = NP_HELD_GL_WIDE; - s_np_held.disp_x = x; - s_np_held.disp_y = y; - s_np_held.disp_h = h; - s_np_held.linear = linear; - s_np_held.src_w = 1; - s_np_held.src_h = h; -} -static void netplay_held_note_vk_cpu(int src_w, int src_h, int linear, int pin43) { - s_np_held.mode = NP_HELD_VK_CPU; - s_np_held.src_w = src_w; - s_np_held.src_h = src_h; - s_np_held.linear = linear; - s_np_held.pin43 = pin43; -} -static void netplay_held_note_vk_vram(int x, int y, int w, int h, int linear, int pin43) { - s_np_held.mode = NP_HELD_VK_VRAM; - s_np_held.disp_x = x; - s_np_held.disp_y = y; - s_np_held.disp_w = w; - s_np_held.disp_h = h; - s_np_held.linear = linear; - s_np_held.pin43 = pin43; - s_np_held.src_w = w; - s_np_held.src_h = h; -} -static void netplay_held_note_vk_wide(int x, int y, int h, int linear) { - s_np_held.mode = NP_HELD_VK_WIDE; - s_np_held.disp_x = x; - s_np_held.disp_y = y; - s_np_held.disp_h = h; - s_np_held.linear = linear; - s_np_held.src_w = 1; - s_np_held.src_h = h; -} - -/* Re-present last framebuffer without advancing guest / rescanning VRAM logic. */ -static void present_held_netplay_frame(void) { -#ifndef PSX_SDL_NO_RENDER - if (g_headless || psx_netplay_in_load_barrier()) - return; - if (s_np_held.mode == NP_HELD_NONE || s_np_held.src_w <= 0) - return; - if (g_gl_active) { - if (s_np_held.mode == NP_HELD_GL_WIDE) { - (void)gl_renderer_present_wide_fbo(s_np_held.disp_x, s_np_held.disp_y, - s_np_held.disp_h, s_np_held.linear); - } else if (s_np_held.mode == NP_HELD_GL_VRAM) { - gl_renderer_present_vram(s_np_held.disp_x, s_np_held.disp_y, - s_np_held.disp_w, s_np_held.disp_h, - s_np_held.linear, s_np_held.pin43); - } else if (sdl_pixel_buf) { - gl_renderer_present(sdl_pixel_buf, s_np_held.src_w, s_np_held.src_h, - s_np_held.linear, s_np_held.pin43, 0); - } - return; - } - if (g_vk_active) { - if (s_np_held.mode == NP_HELD_VK_WIDE) { - (void)vk_renderer_present_wide(s_np_held.disp_x, s_np_held.disp_y, - s_np_held.disp_h, s_np_held.linear); - } else if (s_np_held.mode == NP_HELD_VK_VRAM) { - vk_renderer_present_vram(s_np_held.disp_x, s_np_held.disp_y, - s_np_held.disp_w, s_np_held.disp_h, - s_np_held.linear, s_np_held.pin43); - } else if (sdl_pixel_buf) { - vk_renderer_present_cpu(sdl_pixel_buf, s_np_held.src_w, s_np_held.src_h, - s_np_held.linear, s_np_held.pin43); - } - return; - } - if (sdl_renderer && sdl_texture && s_np_held.src_w > 0 && s_np_held.src_h > 0) { - SDL_Rect src = { 0, 0, s_np_held.src_w, s_np_held.src_h }; - int dst_w = g_logical_w; - int dst_h = 480 * g_video_scale; - SDL_Rect dst = { 0, 0, dst_w, dst_h }; - SDL_RenderClear(sdl_renderer); - SDL_RenderCopy(sdl_renderer, sdl_texture, &src, &dst); - SDL_RenderPresent(sdl_renderer); - } -#else - (void)0; -#endif -} - static void netplay_host_present_restore(void) { if (!g_netplay_vsync_forced_off) return; #ifndef PSX_SDL_NO_RENDER @@ -2904,7 +2772,6 @@ static void netplay_barrier_admit(int override) { std::fflush(stdout); desync_logged = 1; } - present_held_netplay_frame(); SDL_Delay(16); #ifndef PSX_NO_DEBUG_TOOLS debug_server_poll(); @@ -2956,15 +2823,6 @@ static void netplay_barrier_admit(int override) { SDL_GameControllerUpdate(); } if (psx_return_to_lobby_requested()) return; - /* Keep the window alive while starved — re-present last frame ~60 Hz - * without advancing sim (PSX finish→present→admit order parks here). */ - { - const uint32_t now = SDL_GetTicks(); - if (now - s_np_held_last_ms >= 16u) { - present_held_netplay_frame(); - s_np_held_last_ms = now; - } - } /* Wake on peer UDP (or 1ms timeout). SDL_Delay(1) under dual FMV load * often stretches multi-ms and cut MotK netplay intro ~59→~36; Delay(0) * busy-spins and can starve the peer (tick-0 hang). */ @@ -3415,10 +3273,6 @@ static void sdl_vblank_present(void) { if (psx_return_to_lobby_requested()) return; netplay_barrier_admit(override_); if (skip_pace_ || psx_return_to_lobby_requested()) return; - /* Remote input buffered ahead of delay: skip wall pace so this - * peer drains lead (PSX catch-up; no multi-RtlRunFrame burst). */ - if (psx_netplay_catchup_budget() > 0) - return; uint64_t perf_start = runtime_perf_section_begin(); frame_pacer_wait(&s_frame_pacer, g_frame_period_ms); runtime_perf_section_end(perf_start, &g_runtime_perf.pacer_ticks); @@ -3759,18 +3613,12 @@ static void sdl_vblank_present(void) { * SW stayed smooth). Falls through to the CPU readout path only if * the wide surface for this buffer doesn't exist yet. */ if (gl_renderer_present_wide_fbo((int)di.display_x, (int)di.display_y, - (int)h, g_video_aa ? 1 : 0)) { - netplay_held_note_gl_wide((int)di.display_x, (int)di.display_y, - (int)h, g_video_aa ? 1 : 0); + (int)h, g_video_aa ? 1 : 0)) return; - } } else { gl_renderer_present_vram((int)di.display_x, (int)di.display_y, (int)present_w, (int)h, g_video_aa ? 1 : 0, (fmv_frame || nw_pin) ? 1 : 0); - netplay_held_note_gl_vram((int)di.display_x, (int)di.display_y, - (int)present_w, (int)h, g_video_aa ? 1 : 0, - (fmv_frame || nw_pin) ? 1 : 0); return; } } @@ -3792,20 +3640,14 @@ static void sdl_vblank_present(void) { depth24_fix_trailing_margin(sdl_pixel_buf, present_w, h); vk_renderer_present_cpu(sdl_pixel_buf, (int)present_w, (int)h, 0 /* nearest */, fmv_frame ? 1 : 0); - netplay_held_note_vk_cpu((int)present_w, (int)h, 0, - fmv_frame ? 1 : 0); } else if (wide_present && vk_renderer_present_wide((int)di.display_x, (int)di.display_y, (int)h, g_video_aa ? 1 : 0)) { - netplay_held_note_vk_wide((int)di.display_x, (int)di.display_y, - (int)h, g_video_aa ? 1 : 0); + /* presented wide */ } else { vk_renderer_present_vram((int)di.display_x, (int)di.display_y, (int)present_w, (int)h, g_video_aa ? 1 : 0, (fmv_frame || nw_pin) ? 1 : 0); - netplay_held_note_vk_vram((int)di.display_x, (int)di.display_y, - (int)present_w, (int)h, g_video_aa ? 1 : 0, - (fmv_frame || nw_pin) ? 1 : 0); } return; } @@ -3927,9 +3769,6 @@ static void sdl_vblank_present(void) { gl_renderer_present(sdl_pixel_buf, src_w, src_h, (g_video_aa && !depth24_frame) ? 1 : 0, pin_43 ? 1 : 0, 0 /* full width */); - netplay_held_note_gl_cpu(src_w, src_h, - (g_video_aa && !depth24_frame) ? 1 : 0, - pin_43 ? 1 : 0); } else { SDL_Rect src = { 0, 0, src_w, src_h }; SDL_UpdateTexture(sdl_texture, &src, sdl_pixel_buf, @@ -3967,7 +3806,6 @@ static void sdl_vblank_present(void) { SDL_RenderPresent(sdl_renderer); const Uint64 t1 = SDL_GetPerformanceCounter(); latency_ring_mark(LAT_SWAP_END); - netplay_held_note_sdl(src_w, src_h); const Uint64 freq = SDL_GetPerformanceFrequency(); const Uint64 present_ms = (t1 >= t0 && freq) ? ((t1 - t0) * 1000u) / freq : 0; if (!g_present_vsync_disabled && present_ms > 250) { @@ -4149,14 +3987,7 @@ namespace { /* Join Direct / cross-machine: membership via UDP, not the local file. */ bool g_lnch_remote_lan = false; std::string g_lnch_lan_endpoint; -<<<<<<< Updated upstream uint32_t g_lnch_lan_session_id = 1; -======= - std::string g_lnch_lan_guest_bind; - int g_lnch_lobby_input_delay = 2; - int g_lnch_force_input_relay = 0; - int g_lnch_host_max_slots = 2; ->>>>>>> Stashed changes static constexpr int kAeLanMaxSlots = RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS; struct AeLanLobbyState { @@ -4969,7 +4800,6 @@ namespace { return 0; } -<<<<<<< Updated upstream /* Seat ceiling for the active room (listing / LOBBY UI). 0 if unknown. */ int ae_np_lobby_max_slots(void*) { if (g_lnch_hosting_lan || g_lnch_joined_lan) { @@ -4986,8 +4816,6 @@ namespace { return 0; } -======= ->>>>>>> Stashed changes const char* ae_np_default_url(void*) { return g_lnch_lobby_url.empty() ? psx_lobby_default_url() : g_lnch_lobby_url.c_str(); } @@ -5525,96 +5353,10 @@ namespace { return 1; } -<<<<<<< Updated upstream /* LAN/Direct IP rooms own membership via the local file registry. Server * lobbies use WebSocket lobby_update. Never mix: LAN mode wins if set. */ static bool ae_np_use_lan_members(void) { return g_lnch_hosting_lan || g_lnch_joined_lan; -======= - int ae_np_external_ip(void*, char* out, size_t out_len) { - if (!out || out_len == 0) return 0; -#ifdef _WIN32 - WSADATA wsa; - WSAStartup(MAKEWORD(2, 2), &wsa); -#endif - addrinfo hints{}; - hints.ai_family = AF_INET; - hints.ai_socktype = SOCK_STREAM; - addrinfo* res = nullptr; - if (getaddrinfo("api.ipify.org", "80", &hints, &res) != 0 || !res) - return 0; -#ifdef _WIN32 - SOCKET s = socket(res->ai_family, res->ai_socktype, res->ai_protocol); - if (s == INVALID_SOCKET) { - freeaddrinfo(res); - return 0; - } - DWORD timeout_ms = 3000; - setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&timeout_ms, - sizeof(timeout_ms)); - setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char*)&timeout_ms, - sizeof(timeout_ms)); -#else - int s = socket(res->ai_family, res->ai_socktype, res->ai_protocol); - if (s < 0) { - freeaddrinfo(res); - return 0; - } - timeval tv{}; - tv.tv_sec = 3; - tv.tv_usec = 0; - setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); - setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); -#endif - int ok = 0; -#ifdef _WIN32 - const int connected = - connect(s, res->ai_addr, (int)res->ai_addrlen) != SOCKET_ERROR; -#else - const int connected = - connect(s, res->ai_addr, (socklen_t)res->ai_addrlen) == 0; -#endif - if (connected) { - const char req[] = - "GET / HTTP/1.1\r\n" - "Host: api.ipify.org\r\n" - "User-Agent: psxrecomp-netplay/1.0\r\n" - "Connection: close\r\n\r\n"; -#ifdef _WIN32 - (void)send(s, req, (int)strlen(req), 0); - char resp[1024]; - int n = recv(s, resp, sizeof(resp) - 1, 0); -#else - (void)send(s, req, strlen(req), 0); - char resp[1024]; - ssize_t n = recv(s, resp, sizeof(resp) - 1, 0); -#endif - if (n > 0) { - resp[n] = '\0'; - char* body = strstr(resp, "\r\n\r\n"); - body = body ? body + 4 : resp; - char ip[64] = {}; - int j = 0; - for (int i = 0; body[i] && j < (int)sizeof(ip) - 1; ++i) { - if ((body[i] >= '0' && body[i] <= '9') || body[i] == '.') - ip[j++] = body[i]; - else if (j > 0) - break; - } - if (j > 0) { - std::snprintf(out, out_len, "%s", ip); - ok = 1; - } - } - } -#ifdef _WIN32 - closesocket(s); -#else - close(s); -#endif - freeaddrinfo(res); - return ok; ->>>>>>> Stashed changes } static bool ae_np_use_ws_members(void) { @@ -5668,7 +5410,6 @@ namespace { const char* password, const RecompLauncherCSettings* settings, int lan_only, int max_slots) { -<<<<<<< Updated upstream int game_max = g_lnch_game_players >= 2 ? g_lnch_game_players : 2; if (game_max > PSX_MAX_PLAYERS) game_max = PSX_MAX_PLAYERS; if (game_max > 8) game_max = 8; @@ -5677,11 +5418,6 @@ namespace { /* Lobby + delay-sync ceiling (party games up to 8). */ if (max_slots > RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS) max_slots = RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS; -======= - if (max_slots < 2) max_slots = 2; - if (max_slots > PSX_MAX_PLAYERS) max_slots = PSX_MAX_PLAYERS; - if (max_slots > 8) max_slots = 8; ->>>>>>> Stashed changes g_lnch_host_max_slots = max_slots; PsxLobbyMatchCaps caps = ae_netplay_caps_from_settings(settings); char endpoint[96]; @@ -5725,12 +5461,6 @@ namespace { g_lnch_remote_lan = false; g_lnch_remote_lan_state = {}; g_lnch_lan_endpoint.clear(); -<<<<<<< Updated upstream -======= - g_lnch_lan_guest_bind.clear(); - if (host_endpoint) - std::snprintf(host_endpoint, 96, "%s", endpoint); ->>>>>>> Stashed changes psx_lobby_set_max_slots(max_slots); return psx_lobby_create(lobby_name && lobby_name[0] ? lobby_name : "Netplay Lobby", g_lnch_netplay_game_name.c_str(), PSX_GAME_VERSION, @@ -5944,7 +5674,6 @@ namespace { if (ae_np_use_lan_members()) { AeLanLobbyState state; if (!ae_np_read_lan_state(&state)) return 0; -<<<<<<< Updated upstream int seen = 0; for (int slot = 0; slot < state.max_slots; ++slot) { if (state.slot_name[slot].empty()) continue; @@ -5960,31 +5689,17 @@ namespace { ++seen; } return 0; -======= - const bool host = index == 0; - out->slot = host ? state.host_slot : 1 - state.host_slot; - const std::string& name = host ? state.host_name : state.joiner_name; - std::snprintf(out->display_name, sizeof(out->display_name), "%s", name.c_str()); - out->ready = !name.empty(); - out->is_host = host ? 1 : 0; - out->latency_ms = -1; - return 1; ->>>>>>> Stashed changes } PsxLobbyMember mem{}; if (!psx_lobby_member_get(index, &mem)) return 0; out->slot = mem.slot; std::snprintf(out->display_name, sizeof(out->display_name), "%s", mem.display_name); out->ready = mem.ready; -<<<<<<< Updated upstream const char* host_id = psx_lobby_host_player_id(); if (host_id && host_id[0] && mem.player_id[0]) out->is_host = (std::strcmp(host_id, mem.player_id) == 0) ? 1 : 0; else out->is_host = (mem.slot == 0) ? 1 : 0; -======= - out->is_host = mem.slot == 0; ->>>>>>> Stashed changes out->latency_ms = -1; return 1; } @@ -6089,7 +5804,6 @@ namespace { g_lnch_pending_direct_launch.local_slot = local_slot; } g_lnch_pending_direct_launch.input_player = 0; -<<<<<<< Updated upstream g_lnch_pending_direct_launch.session_id = g_lnch_lan_session_id; g_lnch_pending_direct_launch.input_delay = g_lnch_lobby_input_delay; g_lnch_pending_direct_launch.max_slots = @@ -6102,14 +5816,6 @@ namespace { g_lnch_pending_direct_launch.max_slots = kAeLanMaxSlots; g_lnch_pending_direct_launch.force_input_relay = 0; g_lnch_pending_direct_launch.player_count = ae_np_lan_occupied(state); -======= - g_lnch_pending_direct_launch.session_id = 1; - g_lnch_pending_direct_launch.input_delay = g_lnch_lobby_input_delay; - g_lnch_pending_direct_launch.max_slots = 2; - g_lnch_pending_direct_launch.force_input_relay = 0; - g_lnch_pending_direct_launch.player_count = - state.joiner_name.empty() ? 1 : 2; ->>>>>>> Stashed changes if (g_lnch_hosting_lan) { const size_t colon = state.endpoint.rfind(':'); const char* port = colon == std::string::npos @@ -6179,7 +5885,6 @@ namespace { out->session_id = ji->session_id; out->input_delay = (caps && caps->valid) ? caps->input_delay : g_lnch_lobby_input_delay; -<<<<<<< Updated upstream out->max_slots = ji->max_slots >= 2 ? ji->max_slots : (g_lnch_game_players >= 2 ? g_lnch_game_players : 2); if (out->max_slots > PSX_MAX_PLAYERS) out->max_slots = PSX_MAX_PLAYERS; @@ -6199,12 +5904,6 @@ namespace { if (seated > out->max_slots) seated = out->max_slots; out->player_count = seated; } -======= - out->max_slots = ji->max_slots >= 2 ? ji->max_slots : 2; - if (out->max_slots > PSX_MAX_PLAYERS) out->max_slots = PSX_MAX_PLAYERS; - out->player_count = ji->player_count > 0 ? ji->player_count : out->max_slots; - if (out->player_count > out->max_slots) out->player_count = out->max_slots; ->>>>>>> Stashed changes out->force_input_relay = (caps && caps->valid && caps->force_input_relay) ? 1 : 0; return 1; @@ -6247,10 +5946,7 @@ namespace { ae_np_input_delay_set, ae_np_force_input_relay_get, ae_np_force_input_relay_set, -<<<<<<< Updated upstream ae_np_lobby_max_slots, -======= ->>>>>>> Stashed changes }; } // namespace #endif @@ -7473,23 +7169,9 @@ std::string player_device[PSX_MAX_PLAYERS]; net_cfg.input_delay = ls.netplay_launch.input_delay; net_cfg.force_input_relay = ls.netplay_launch.force_input_relay ? 1 : 0; net_cfg.player_count = ls.netplay_launch.player_count; -<<<<<<< Updated upstream net_cfg.slot_count = ae_np_session_slot_count( ls.netplay_launch.player_count, ls.netplay_launch.max_slots, ls.netplay_launch.local_slot, game_players); -======= - /* Delay-sync READY/START needs seated count, not lobby ceiling. */ - net_cfg.slot_count = ls.netplay_launch.player_count >= 2 - ? ls.netplay_launch.player_count - : (ls.netplay_launch.max_slots >= 2 - ? ls.netplay_launch.max_slots - : 2); - if (ls.netplay_launch.local_slot + 1 > net_cfg.slot_count) - net_cfg.slot_count = ls.netplay_launch.local_slot + 1; - if (net_cfg.slot_count < 2) net_cfg.slot_count = 2; - if (net_cfg.slot_count > PSX_MAX_PLAYERS) - net_cfg.slot_count = PSX_MAX_PLAYERS; ->>>>>>> Stashed changes if (net_cfg.player_count <= 0) net_cfg.player_count = net_cfg.slot_count; std::snprintf(net_cfg.bind_hostport, sizeof(net_cfg.bind_hostport), "%s", diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index d98f8cfd7..0c57276d1 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -416,10 +416,7 @@ static int endpoint_port_is_zero(const char *ep) return (int)strtoul(colon + 1, NULL, 10) == 0; } -<<<<<<< Updated upstream /* Prefer a usable host:port among candidates (skip empty / :0). */ -======= ->>>>>>> Stashed changes static void copy_first_usable_endpoint(char *dst, size_t dst_len, const char *a, const char *b, const char *c) { @@ -443,10 +440,7 @@ static int using_server_input_relay(const PsxLobbyJoinInfo *j) { if (g_lc.match_caps.valid && g_lc.match_caps.force_input_relay) return 1; -<<<<<<< Updated upstream /* Server rewrote both endpoints to the same relay advertise address. */ -======= ->>>>>>> Stashed changes if (j && j->host_endpoint[0] && j->guest_endpoint[0] && !endpoint_port_is_zero(j->host_endpoint) && !endpoint_port_is_zero(j->guest_endpoint) && @@ -465,18 +459,14 @@ static void fill_peer_bind_from_join(void) memset(j->bind_hostport, 0, sizeof(j->bind_hostport)); memset(j->peer_hostport, 0, sizeof(j->peer_hostport)); if (force_relay) { -<<<<<<< Updated upstream /* Everyone dials the lobby-server UDP relay — ephemeral local bind * (same as LAN guests) so same-PC multi-instance doesn't collide. */ -======= ->>>>>>> Stashed changes strncpy(j->bind_hostport, "0.0.0.0:0", sizeof(j->bind_hostport) - 1); copy_first_usable_endpoint(j->peer_hostport, sizeof(j->peer_hostport), j->host_endpoint, j->guest_endpoint, NULL); } else if (g_lc.is_host) { strncpy(j->bind_hostport, g_lc.my_bind, sizeof(j->bind_hostport) - 1); if (!host_hub) { -<<<<<<< Updated upstream /* 2P P2P: dial guest when they advertised a fixed port. Online * guests often join with :0 — leave peer empty (accept-first). */ if (j->guest_endpoint[0] && !endpoint_port_is_zero(j->guest_endpoint)) @@ -487,12 +477,6 @@ static void fill_peer_bind_from_join(void) /* Guests dialing 3+ host hub: ephemeral local UDP (join only probes * 7778+ and does not hold the socket). 2P P2P keeps the advertised * fixed guest_bind so the host can dial. */ -======= - if (j->guest_endpoint[0] && !endpoint_port_is_zero(j->guest_endpoint)) - strncpy(j->peer_hostport, j->guest_endpoint, sizeof(j->peer_hostport) - 1); - } - } else { ->>>>>>> Stashed changes if (seats >= 3) { strncpy(j->bind_hostport, "0.0.0.0:0", sizeof(j->bind_hostport) - 1); } else { @@ -805,12 +789,9 @@ static void handle_server_json(const char *json) g_lc.join.max_slots = json_get_int(json, "max_slots", g_lc.join.max_slots); g_lc.join.session_id = (uint32_t)json_get_int(json, "session_id", (int)g_lc.join.session_id); ingest_match_caps_from_json(json); -<<<<<<< Updated upstream /* Prefer explicit relay_endpoint when the server opened input relay. * Apply after caps ingest: omitted force_input_relay must not leave * hosts on the hub path while guests dial the relay. */ -======= ->>>>>>> Stashed changes if (relay_endpoint[0] && !endpoint_port_is_zero(relay_endpoint)) { strncpy(g_lc.join.host_endpoint, relay_endpoint, sizeof(g_lc.join.host_endpoint) - 1); diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index 8f2f56bdb..cf1cdb82d 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -21,14 +21,8 @@ #include "recomp_net/recomp_net.h" #endif -<<<<<<< Updated upstream #ifndef PSX_MAX_PLAYERS #define PSX_MAX_PLAYERS 2 -======= -/* Align with RNET_MAX_SLOTS / SCPH-1070 (2 ports × 4 taps). */ -#ifndef PSX_MAX_PLAYERS -#define PSX_MAX_PLAYERS 8 ->>>>>>> Stashed changes #endif /* Session pad count mirrored for release_pads (available without recomp-net). */ @@ -125,11 +119,6 @@ static void force_session_pads_connected(int slot_count) for (i = 0; i < slot_count; ++i) { sio_connect_pad(i); sio_set_pad_config_capable(i, 1); - /* Mirror onto port-2 multitap taps (BPE and other port-2 MT titles). */ - if (slot_count >= 3 && i < 4) { - sio_connect_pad(4 + i); - sio_set_pad_config_capable(4 + i, 1); - } } } @@ -182,9 +171,6 @@ int psx_netplay_in_load_barrier(void) { return 0; } int psx_netplay_poll_admit(void) { return 1; } void psx_netplay_finish_frame(void) {} void psx_netplay_wait_recv(int timeout_ms) { (void)timeout_ms; } -int psx_netplay_remote_lead(void) { return 0; } -int psx_netplay_input_delay(void) { return 2; } -int psx_netplay_catchup_budget(void) { return 0; } #else /* PSX_HAS_RECOMP_NET */ @@ -818,12 +804,9 @@ static void decode_pad(const RNetInputSample *in, PsxNetPad *pad) psx_netplay_normalize_pad(pad); } -static void apply_pad_slot_one(int slot, const PsxNetPad *pad) +static void apply_pad_slot(int slot, const PsxNetPad *pad) { -<<<<<<< Updated upstream if (slot < 0 || slot >= g_np.slot_count || slot >= PSX_MAX_PLAYERS || !pad) return; -======= ->>>>>>> Stashed changes sio_set_pad_connected(slot, 1); sio_set_pad_config_capable(slot, 1); sio_set_pad_state_slot(slot, pad->buttons); @@ -831,15 +814,6 @@ static void apply_pad_slot_one(int slot, const PsxNetPad *pad) sio_request_pad_type(slot, pad->analog ? 1 : 0); } -static void apply_pad_slot(int slot, const PsxNetPad *pad) -{ - if (slot < 0 || slot >= g_np.slot_count || slot >= PSX_MAX_PLAYERS || !pad) return; - apply_pad_slot_one(slot, pad); - /* Port-2 multitap mirror for titles that require MT on console port 2. */ - if (g_np.slot_count >= 3 && slot < 4) - apply_pad_slot_one(4 + slot, pad); -} - static void host_sample_local(rnet_u32 tick, RNetInputSample *out, void *ctx) { NetplayState *st = (NetplayState *)ctx; @@ -1036,20 +1010,11 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) g_np.session = rnet_session_create(&rcfg, &host); if (!g_np.session) return -2; -<<<<<<< Updated upstream /* Host-as-relay: slot 0 with 3+ seats and no dial peer (guests dial host). */ { const int peer_empty = !cfg->peer_hostport || !cfg->peer_hostport[0]; const int use_hub = (local == 0 && slots >= 3 && peer_empty); -======= - /* Host-as-relay: lobby owner gets an empty peer and fans out UDP. Transport - * hub role is independent of sim local_slot (seats may be reordered). */ - { - const int peer_empty = - !cfg->peer_hostport || !cfg->peer_hostport[0]; - const int use_hub = (slots >= 3 && peer_empty); ->>>>>>> Stashed changes const int rc = use_hub ? rnet_session_start_lan_hub(g_np.session, cfg->bind_hostport) : rnet_session_start_lan(g_np.session, cfg->bind_hostport, @@ -1066,14 +1031,10 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) g_np_slot_count = g_np.slot_count; g_np.local_slot = (int)rcfg.local_slot; g_np.input_player = in_player; -<<<<<<< Updated upstream if (g_np.slot_count >= 3) sio_set_multitap(1); else sio_set_multitap(0); -======= - force_session_pads_connected(g_np.slot_count); ->>>>>>> Stashed changes g_np.staged_valid = 0; g_np.needs_advance = 0; g_np.latched_for_tick = 0; @@ -1255,33 +1216,4 @@ void psx_netplay_wait_recv(int timeout_ms) (void)rnet_session_wait_recv(g_np.session, timeout_ms); } -int psx_netplay_remote_lead(void) -{ - RNetSessionStats st; - if (!psx_netplay_active()) return 0; - memset(&st, 0, sizeof(st)); - rnet_session_get_stats(g_np.session, &st); - return st.remote_lead; -} - -int psx_netplay_input_delay(void) -{ - int d; - if (!psx_netplay_active()) return 2; - d = (int)rnet_session_committed_delay(g_np.session); - return d > 0 ? d : 2; -} - -int psx_netplay_catchup_budget(void) -{ - int lead, delay, budget; - if (!psx_netplay_active()) return 0; - lead = psx_netplay_remote_lead(); - delay = psx_netplay_input_delay(); - budget = lead - delay; - if (budget < 0) budget = 0; - if (budget > 8) budget = 8; - return budget; -} - #endif /* PSX_HAS_RECOMP_NET */ diff --git a/runtime/src/sio.c b/runtime/src/sio.c index fd3bf905c..cd79f833b 100644 --- a/runtime/src/sio.c +++ b/runtime/src/sio.c @@ -41,7 +41,6 @@ static void sio_debug_poll_maybe(void) { } } -<<<<<<< Updated upstream /* Pad state: 0=pressed, 1=released (PS1 convention). Indexed by LOGICAL pad * 0 .. PSX_MAX_PLAYERS-1 (not physical SIO slot). */ static uint16_t pad_buttons[PSX_MAX_PLAYERS] = { [0 ... PSX_MAX_PLAYERS - 1] = 0xFFFF }; @@ -61,27 +60,6 @@ static uint8_t pad_stick[PSX_MAX_PLAYERS][4] = { * flips underneath a game that pinned DualShock, the exact desync the * deferred-request machinery cannot otherwise prevent. */ static uint8_t analog_mode_locked[PSX_MAX_PLAYERS]; -======= -/* Logical pad slots: without multitap, 0/1 are console ports 1/2. With - * SCPH-1070 multitap on a port, that port exposes four pads at - * port*4 + {0..3}. Netplay maps session seats onto these indices. */ -#define SIO_PAD_SLOTS 8 -#define SIO_MT_RSP_MAX 34 /* 5A80h + 4 pads × 4 halfwords */ - -/* Pad state: 0=pressed, 1=released (PS1 convention). */ -static uint16_t pad_buttons[SIO_PAD_SLOTS]; -static uint8_t pad_analog[SIO_PAD_SLOTS]; -static uint8_t pad_stick[SIO_PAD_SLOTS][4]; /* lx,ly,rx,ry */ - -/* Analog-mode lock, per slot. A real DualShock's config command 0x44 0x..02/0x03 - * locks/unlocks the mode (dualshock.cpp:714-725); a locked pad ignores the - * physical analog button (dualshock.cpp:203). We emulate the analog button via - * the host hybrid heuristic (pad_type_req), so when a game LOCKS the mode the - * hybrid auto-flip must not override it — else the type flips underneath a game - * that pinned DualShock, the exact desync the deferred-request machinery cannot - * otherwise prevent. */ -static uint8_t analog_mode_locked[SIO_PAD_SLOTS]; ->>>>>>> Stashed changes /* Which logical pads have devices connected (bit i = pad i). Fits 5 pads. */ static uint8_t pad_connected = 0; @@ -109,7 +87,6 @@ typedef enum { } MtapNextMode; static PadState pad_state = PAD_IDLE; -<<<<<<< Updated upstream static int selected_slot = 0; /* physical SIO slot (CTRL bit13): 0 or 1 */ static int pad_active_logical = 0; /* logical pad for single-pad / config cmds */ static uint8_t pad_response[PAD_RESPONSE_MAX]; @@ -144,40 +121,6 @@ static uint8_t pad_in_config[PSX_MAX_PLAYERS]; static uint8_t pad_supports_config[PSX_MAX_PLAYERS] = { [0 ... PSX_MAX_PLAYERS - 1] = 1 }; -======= -static int selected_slot = 0; /* console port 0/1 from SIO_CTRL bit 13 */ -static int active_pad = 0; /* logical pad for the current transaction */ -static uint8_t pad_response[SIO_MT_RSP_MAX]; -static uint8_t pad_response_len = 0; -static uint8_t pad_response_idx = 0; -static uint8_t pad_current_cmd = 0; -/* SCPH-1070: multitap on console port 0 and/or 1. Method-1 LONG response is - * armed by TX byte 3 == 0x01 on a Slot-A poll and consumed on the next poll. */ -static uint8_t mt_on[2]; -static uint8_t mt_req_next[2]; -static uint8_t mt_prev_long[2]; -/* DualShock config-mode latch, per slot. A real controller only answers the - * config commands (0x44/0x45/0x46/0x47/0x4C/0x4D/0x4F) and reports the config - * ID 0xF3 while it is IN config mode; outside config it reports its normal ID - * (0x41 digital / 0x73 analog) and ignores config commands. Config is entered/ - * exited by command 0x43 with the data byte 0x01(enter)/0x00(exit). Faking - * "always in config" (constant 0xF3) wedges games that probe the pad type via - * 0x43 before polling — e.g. Mega Man X6 loops 01 43 00 00 forever and never - * reaches 0x42. (MMX6 ISSUES.md #2.) */ -static uint8_t pad_in_config[SIO_PAD_SLOTS]; - -/* Whether the pad on a slot is a config-capable DualShock (1) or a plain - * digital controller (0). A real SCPH-1080 digital pad (poll id 0x41) does NOT - * answer the config-mode commands (0x43/0x44/.../0x4F): it returns hi-z and the - * transaction ends. A game's pad driver that probes with 0x43 to detect a - * DualShock therefore classifies a digital pad as digital-only and just polls - * it with 0x42. Tomba 2's driver probes this way every frame; when the SM - * (wrongly) answered 0x43 for its digital pad it went down the DualShock config - * path and read the 0x00 config-response bytes as buttons -> phantom "all - * pressed" input. Default 1 keeps analog/hybrid pads unchanged; main.cpp sets 0 - * for PAD_MODE_DIGITAL. */ -static uint8_t pad_supports_config[SIO_PAD_SLOTS]; ->>>>>>> Stashed changes /* Coherent-DualShock model (Tomba phantom-input fix). A real controller never * changes its reported type (0x41 digital <-> 0x73 analog) in the middle of a @@ -190,7 +133,6 @@ static uint8_t pad_supports_config[SIO_PAD_SLOTS]; * host REQUESTS a type via pad_type_req[] and the change is applied atomically * only when the bus is idle (PAD_IDLE) and the pad is NOT in config mode. A * request raised during config is held until config exits. -1 = no request. */ -<<<<<<< Updated upstream static int8_t pad_type_req[PSX_MAX_PLAYERS] = { [0 ... PSX_MAX_PLAYERS - 1] = -1 }; @@ -288,9 +230,6 @@ static void pad_fill_status8(int logical, uint8_t out[8]) { out[4] = out[5] = out[6] = out[7] = 0xFF; } } -======= -static int8_t pad_type_req[SIO_PAD_SLOTS]; ->>>>>>> Stashed changes /* Memory card SIO state machine */ typedef enum { @@ -713,7 +652,6 @@ static int sio_ack_visible_reads = 0; #define SIO_CTRL_SLOT (1 << 13) void sio_init(void) { - int p; sio_tx_data = 0; sio_rx_data = 0xFF; sio_stat = SIO_STAT_TX_RDY | SIO_STAT_TX_EMPTY; @@ -724,7 +662,6 @@ void sio_init(void) { pad_response_len = 0; pad_response_idx = 0; pad_current_cmd = 0; -<<<<<<< Updated upstream pad_active_logical = 0; for (int i = 0; i < PSX_MAX_PLAYERS; i++) { pad_buttons[i] = 0xFFFF; @@ -734,21 +671,6 @@ void sio_init(void) { pad_type_req[i] = -1; analog_mode_locked[i] = 0; pad_supports_config[i] = 1; -======= - active_pad = 0; - selected_slot = 0; - mt_on[0] = mt_on[1] = 0; - mt_req_next[0] = mt_req_next[1] = 0; - mt_prev_long[0] = mt_prev_long[1] = 0; - for (p = 0; p < SIO_PAD_SLOTS; p++) { - pad_buttons[p] = 0xFFFF; - pad_analog[p] = 0; - pad_stick[p][0] = pad_stick[p][1] = pad_stick[p][2] = pad_stick[p][3] = 0x80; - pad_in_config[p] = 0; - pad_type_req[p] = -1; - analog_mode_locked[p] = 0; - pad_supports_config[p] = 1; ->>>>>>> Stashed changes } pad_connected = 0; /* Multitap enable/port are host preferences — leave them alone across @@ -847,7 +769,6 @@ int sio_get_multitap_port(void) { } void sio_connect_pad(int slot) { -<<<<<<< Updated upstream if (slot >= 0 && slot < PSX_MAX_PLAYERS) pad_connected |= (uint8_t)(1u << slot); } @@ -860,20 +781,6 @@ void sio_set_pad_connected(int slot, int connected) { void sio_set_pad_config_capable(int slot, int capable) { if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; -======= - if (slot >= 0 && slot < SIO_PAD_SLOTS) - pad_connected |= (uint8_t)(1 << slot); -} - -void sio_set_pad_connected(int slot, int connected) { - if (slot < 0 || slot >= SIO_PAD_SLOTS) return; - if (connected) pad_connected |= (uint8_t)(1 << slot); - else pad_connected &= (uint8_t)~(1 << slot); -} - -void sio_set_pad_config_capable(int slot, int capable) { - if (slot < 0 || slot >= SIO_PAD_SLOTS) return; ->>>>>>> Stashed changes pad_supports_config[slot] = capable ? 1 : 0; /* A plain digital pad can never be in config mode; clear any stale latch so * the next poll reports the digital id (0x41), not the config id (0xF3). */ @@ -885,28 +792,7 @@ void sio_set_pad_state(uint16_t buttons) { } void sio_set_pad_state_slot(int slot, uint16_t buttons) { -<<<<<<< Updated upstream if (slot >= 0 && slot < PSX_MAX_PLAYERS) pad_buttons[slot] = buttons; -======= - if (slot >= 0 && slot < SIO_PAD_SLOTS) pad_buttons[slot] = buttons; -} - -/* Enable SCPH-1070 multitap on both console ports (covers port-1 games and - * BPE-style port-2 multitap). Netplay seats map to logical pads 0..N-1 and - * are mirrored onto port-2's multitap (4..4+N-1) by the netplay layer. */ -void sio_set_multitap(int enabled) { - const uint8_t on = enabled ? 1u : 0u; - mt_on[0] = on; - mt_on[1] = on; - if (!on) { - mt_req_next[0] = mt_req_next[1] = 0; - mt_prev_long[0] = mt_prev_long[1] = 0; - } -} - -int sio_get_multitap(void) { - return (mt_on[0] || mt_on[1]) ? 1 : 0; ->>>>>>> Stashed changes } /* Direct set of pad type + sticks. Used at boot/hotplug (refresh_player_devices) @@ -916,11 +802,7 @@ int sio_get_multitap(void) { * coherently (see pad_type_req[] above). */ void sio_set_pad_analog(int slot, int enabled, uint8_t lx, uint8_t ly, uint8_t rx, uint8_t ry) { -<<<<<<< Updated upstream if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; -======= - if (slot < 0 || slot >= SIO_PAD_SLOTS) return; ->>>>>>> Stashed changes pad_analog[slot] = enabled ? 1 : 0; pad_type_req[slot] = -1; /* explicit set supersedes any pending request */ pad_stick[slot][0] = lx; pad_stick[slot][1] = ly; @@ -929,11 +811,7 @@ void sio_set_pad_analog(int slot, int enabled, /* Per-frame stick update (does not touch the reported pad type). */ void sio_set_pad_sticks(int slot, uint8_t lx, uint8_t ly, uint8_t rx, uint8_t ry) { -<<<<<<< Updated upstream if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; -======= - if (slot < 0 || slot >= SIO_PAD_SLOTS) return; ->>>>>>> Stashed changes pad_stick[slot][0] = lx; pad_stick[slot][1] = ly; pad_stick[slot][2] = rx; pad_stick[slot][3] = ry; } @@ -942,11 +820,7 @@ void sio_set_pad_sticks(int slot, uint8_t lx, uint8_t ly, uint8_t rx, uint8_t ry * deferred and applied atomically at the next idle, non-config boundary, so it * can never split a poll or a config handshake. A no-op if already that type. */ void sio_request_pad_type(int slot, int analog) { -<<<<<<< Updated upstream if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; -======= - if (slot < 0 || slot >= SIO_PAD_SLOTS) return; ->>>>>>> Stashed changes int want = analog ? 1 : 0; pad_type_req[slot] = (pad_analog[slot] == want) ? -1 : (int8_t)want; } @@ -956,7 +830,6 @@ uint16_t sio_get_pad_buttons(void) { } uint16_t sio_get_pad_buttons_slot(int slot) { -<<<<<<< Updated upstream return (slot >= 0 && slot < PSX_MAX_PLAYERS) ? pad_buttons[slot] : 0xFFFF; } @@ -967,27 +840,11 @@ int sio_get_pad_connected(int slot) { int sio_get_pad_analog(int slot) { return (slot >= 0 && slot < PSX_MAX_PLAYERS) ? pad_analog[slot] : 0; -======= - return (slot >= 0 && slot < SIO_PAD_SLOTS) ? pad_buttons[slot] : 0xFFFF; -} - -int sio_get_pad_connected(int slot) { - if (slot < 0 || slot >= SIO_PAD_SLOTS) return 0; - return (pad_connected & (1 << slot)) ? 1 : 0; -} - -int sio_get_pad_analog(int slot) { - return (slot >= 0 && slot < SIO_PAD_SLOTS) ? pad_analog[slot] : 0; ->>>>>>> Stashed changes } void sio_get_pad_sticks(int slot, uint8_t out[4]) { if (!out) return; -<<<<<<< Updated upstream if (slot < 0 || slot >= PSX_MAX_PLAYERS) { -======= - if (slot < 0 || slot >= SIO_PAD_SLOTS) { ->>>>>>> Stashed changes out[0] = out[1] = out[2] = out[3] = 0x80; return; } @@ -1051,68 +908,21 @@ void sio_get_pad_sticks(int slot, uint8_t out[4]) { volatile int g_pad_legacy_cfg = 0; int sio_get_legacy_cfg(void) { return g_pad_legacy_cfg; } void sio_set_legacy_cfg(int v) { - int s; g_pad_legacy_cfg = v ? 1 : 0; /* Clear any in-flight config latch so a mid-session toggle can't carry a * stale 0xF3/8-byte poll into the other mode's dispatch. */ -<<<<<<< Updated upstream for (int s = 0; s < PSX_MAX_PLAYERS; s++) pad_in_config[s] = 0; -======= - for (s = 0; s < SIO_PAD_SLOTS; s++) - pad_in_config[s] = 0; -} - -/* Pack one pad's 4 halfwords into an 8-byte multitap slot block (digital pads - * and empty slots pad unused halfwords with FFFFh per psx-spx). */ -static void mt_pack_pad_block(uint8_t *dst, int pad) { - if (pad < 0 || pad >= SIO_PAD_SLOTS || !(pad_connected & (1 << pad))) { - memset(dst, 0xFF, 8); - return; - } - { - const uint16_t btn = pad_buttons[pad]; - const uint8_t id = pad_in_config[pad] ? 0xF3u - : (pad_analog[pad] ? 0x73u : 0x41u); - dst[0] = id; - dst[1] = 0x5A; - dst[2] = (uint8_t)(btn & 0xFF); - dst[3] = (uint8_t)(btn >> 8); - if (pad_analog[pad] || pad_in_config[pad]) { - dst[4] = pad_stick[pad][2]; - dst[5] = pad_stick[pad][3]; - dst[6] = pad_stick[pad][0]; - dst[7] = pad_stick[pad][1]; - } else { - dst[4] = dst[5] = dst[6] = dst[7] = 0xFF; - } - } -} - -static void mt_build_long_response(int port) { - int t; - pad_response[0] = 0x80; - pad_response[1] = 0x5A; - for (t = 0; t < 4; t++) - mt_pack_pad_block(&pad_response[2 + t * 8], port * 4 + t); - pad_response_len = SIO_MT_RSP_MAX; ->>>>>>> Stashed changes } static void pad_process_byte(uint8_t tx_byte) { - const int port = selected_slot; - const int mt = (port >= 0 && port <= 1 && mt_on[port]) ? 1 : 0; /* Apply any pending host type change (the emulated analog button) ONLY while * the bus is idle and the pad is not in config mode. This guarantees the * reported type (0x41/0x73) is stable for the whole of any poll or config * handshake — a hybrid stick/d-pad flip can never desync the game's driver * mid-transaction. A request raised during config stays pending until exit. */ if (pad_state == PAD_IDLE) { -<<<<<<< Updated upstream for (int s = 0; s < PSX_MAX_PLAYERS; s++) { -======= - for (int s = 0; s < SIO_PAD_SLOTS; s++) { ->>>>>>> Stashed changes /* A game-LOCKED analog mode (0x44 ..03) ignores the physical analog * button — and our hybrid auto-flip IS that button — so a locked slot * drops the pending host request instead of applying it. */ @@ -1124,7 +934,6 @@ static void pad_process_byte(uint8_t tx_byte) { } switch (pad_state) { case PAD_IDLE: -<<<<<<< Updated upstream /* Standard address 01h selects Slot A (or the standalone pad). With a * multitap, 02h..04h select pads B–D on that port (psx-spx method 2). */ if (tx_byte == 0x01 && pad_port_has_device(selected_slot)) { @@ -1136,34 +945,6 @@ static void pad_process_byte(uint8_t tx_byte) { } else if (selected_is_mtap_port() && tx_byte >= 0x02 && tx_byte <= 0x04) { pad_active_logical = mtap_slot_a_logical() + (int)(tx_byte - 1); pad_mtap_addr = tx_byte; -======= - /* 0x01 = pad/multitap select; with multitap, 0x02..0x04 select taps B-D - * (method 2). Slot A (tap 0) must be present to arm method-1 REQ. */ - if (tx_byte >= 0x01 && tx_byte <= 0x04) { - const int tap = (int)tx_byte - 1; - if (mt) { - active_pad = port * 4 + tap; - if (!(pad_connected & (1 << (port * 4)))) { - /* Empty Slot A: transfer aborts after first byte. */ - sio_rx_data = 0xFF; - break; - } - if (tap > 0 && !(pad_connected & (1 << active_pad))) { - sio_rx_data = 0xFF; - break; - } - } else { - if (tx_byte != 0x01) { - sio_rx_data = 0xFF; - break; - } - active_pad = port; - if (!(pad_connected & (1 << active_pad))) { - sio_rx_data = 0xFF; - break; - } - } ->>>>>>> Stashed changes pad_state = PAD_WAIT_ACCESS; sio_rx_data = 0xFF; sio_stat |= SIO_STAT_ACK; @@ -1228,20 +1009,13 @@ static void pad_process_byte(uint8_t tx_byte) { /* Controller ID reported as the first response byte. Real hardware * reports the config ID (0xF3) ONLY while in config mode; otherwise the * normal mode ID (0x41 digital / 0x73 analog). */ -<<<<<<< Updated upstream const uint8_t cur_id = pad_in_config[lp] ? 0xF3 : (pad_analog[lp] ? 0x73 : 0x41); -======= - { - const uint8_t cur_id = pad_in_config[active_pad] ? 0xF3 - : (pad_analog[active_pad] ? 0x73 : 0x41); ->>>>>>> Stashed changes /* A plain digital controller (SCPH-1080) answers ONLY the 0x42 poll; it * ignores every config-mode command (returns hi-z, no ACK). A driver * that probes with 0x43 to detect a DualShock then classifies it as * digital-only and just polls. Gate all config branches on this so a * digital-mode pad behaves like real hardware (see pad_supports_config). */ -<<<<<<< Updated upstream const int ds = pad_supports_config[lp]; if (tx_byte == 0x42) { /* Read poll. Analog (or in-config) uses the 8-byte format with the @@ -1257,42 +1031,8 @@ static void pad_process_byte(uint8_t tx_byte) { pad_response[6] = pad_stick[lp][0]; /* left X */ pad_response[7] = pad_stick[lp][1]; /* left Y */ pad_response_len = 8; -======= - const int ds = pad_supports_config[active_pad]; - if (tx_byte == 0x42) { - /* Multitap method 1: previous REQ arms a LONG Slot A-D response. */ - if (mt && mt_req_next[port]) { - mt_req_next[port] = 0; - if (mt_prev_long[port]) { - /* REQ while already long → short "garbage" (psx-spx). */ - pad_response[0] = 0x80; - pad_response[1] = 0x5A; - pad_response[2] = pad_analog[port * 4] ? 0x73 : 0x41; - pad_response_len = 3; - mt_prev_long[port] = 0; - } else { - mt_build_long_response(port); - mt_prev_long[port] = 1; - } ->>>>>>> Stashed changes } else { - mt_prev_long[port] = 0; - /* Read poll. Analog (or in-config) uses the 8-byte format with the - * four stick axes; a plain digital pad uses the 4-byte format. */ - const uint16_t btn = pad_buttons[active_pad]; - pad_response[0] = cur_id; - pad_response[1] = 0x5A; - pad_response[2] = (uint8_t)(btn & 0xFF); - pad_response[3] = (uint8_t)(btn >> 8); - if (pad_analog[active_pad] || pad_in_config[active_pad]) { - pad_response[4] = pad_stick[active_pad][2]; /* right X */ - pad_response[5] = pad_stick[active_pad][3]; /* right Y */ - pad_response[6] = pad_stick[active_pad][0]; /* left X */ - pad_response[7] = pad_stick[active_pad][1]; /* left Y */ - pad_response_len = 8; - } else { - pad_response_len = 4; - } + pad_response_len = 4; } pad_state = PAD_SEND_RESPONSE; sio_rx_data = pad_response[0]; @@ -1301,11 +1041,7 @@ static void pad_process_byte(uint8_t tx_byte) { /* Enter/exit config mode. The ID byte reflects the CURRENT mode; the * enter(0x01)/exit(0x00) flag is the second data byte, latched in * PAD_SEND_RESPONSE so it takes effect after this transaction. */ -<<<<<<< Updated upstream const uint16_t btn = pad_buttons[lp]; -======= - const uint16_t btn = pad_buttons[active_pad]; ->>>>>>> Stashed changes pad_response[1] = 0x5A; if (g_pad_legacy_cfg) { /* LEGACY (pre-98aa688): always config ID 0xF3, zero frame, no @@ -1315,11 +1051,7 @@ static void pad_process_byte(uint8_t tx_byte) { pad_response[4] = 0x00; pad_response[5] = 0x00; pad_response[6] = 0x00; pad_response[7] = 0x00; pad_response_len = 8; -<<<<<<< Updated upstream } else if (!pad_in_config[lp]) { -======= - } else if (!pad_in_config[active_pad]) { ->>>>>>> Stashed changes /* ENTER attempt (normal mode): a real DualShock transmits the LIVE * poll frame here — identical framing to 0x42 (dualshock.cpp:471-490) * — and only latches config entry from the 0x01 data byte AFTERWARD. @@ -1330,19 +1062,11 @@ static void pad_process_byte(uint8_t tx_byte) { pad_response[0] = cur_id; pad_response[2] = (uint8_t)(btn & 0xFF); pad_response[3] = (uint8_t)(btn >> 8); -<<<<<<< Updated upstream if (pad_analog[lp]) { pad_response[4] = pad_stick[lp][2]; /* right X */ pad_response[5] = pad_stick[lp][3]; /* right Y */ pad_response[6] = pad_stick[lp][0]; /* left X */ pad_response[7] = pad_stick[lp][1]; /* left Y */ -======= - if (pad_analog[active_pad]) { - pad_response[4] = pad_stick[active_pad][2]; /* right X */ - pad_response[5] = pad_stick[active_pad][3]; /* right Y */ - pad_response[6] = pad_stick[active_pad][0]; /* left X */ - pad_response[7] = pad_stick[active_pad][1]; /* left Y */ ->>>>>>> Stashed changes pad_response_len = 8; } else { pad_response_len = 4; @@ -1379,20 +1103,12 @@ static void pad_process_byte(uint8_t tx_byte) { /* 0x45 status byte must report the LIVE analog mode, not a fixed * analog-on (dualshock.cpp:743) — see fix below for the modern path. */ if (tx_byte == 0x45) -<<<<<<< Updated upstream pad_response[3] = pad_analog[lp] ? 0x01 : 0x00; -======= - pad_response[3] = pad_analog[active_pad] ? 0x01 : 0x00; ->>>>>>> Stashed changes pad_response_len = 8; pad_state = PAD_SEND_RESPONSE; sio_rx_data = pad_response[0]; sio_stat |= SIO_STAT_ACK; -<<<<<<< Updated upstream } else if (ds && !g_pad_legacy_cfg && pad_in_config[lp] && -======= - } else if (ds && !g_pad_legacy_cfg && pad_in_config[active_pad] && ->>>>>>> Stashed changes (tx_byte == 0x44 || tx_byte == 0x45 || tx_byte == 0x46 || tx_byte == 0x47 || tx_byte == 0x4C || tx_byte == 0x4D || tx_byte == 0x4F)) { @@ -1417,11 +1133,7 @@ static void pad_process_byte(uint8_t tx_byte) { * driver mis-parse the poll frame length → off-by-frame garbage buttons * (axis5_sio_controller.md D8). */ if (tx_byte == 0x45) -<<<<<<< Updated upstream pad_response[3] = pad_analog[lp] ? 0x01 : 0x00; -======= - pad_response[3] = pad_analog[active_pad] ? 0x01 : 0x00; ->>>>>>> Stashed changes pad_response_len = 8; pad_state = PAD_SEND_RESPONSE; sio_rx_data = pad_response[0]; @@ -1439,61 +1151,36 @@ static void pad_process_byte(uint8_t tx_byte) { break; case PAD_SEND_RESPONSE: -<<<<<<< Updated upstream /* TAP/REQ (third command byte, paired with idhi/5Ah at idx==1): does not * change *this* response; it arms the next 0x42 on the multitap port. */ if (selected_is_mtap_port() && pad_current_cmd == 0x42 && pad_mtap_addr == 0x01 && pad_response_idx == 1) mtap_req_this = (tx_byte == 0x01) ? 1 : 0; -======= - /* Multitap method 1: third TX byte (paired with response idx 2) is REQ. - * REQ=1 arms a LONG Slot A-D response on the *next* poll (psx-spx). */ - if (mt && pad_current_cmd == 0x42 && pad_response_idx == 2 && - active_pad == port * 4) - mt_req_next[port] = (tx_byte == 0x01) ? 1u : 0u; ->>>>>>> Stashed changes /* For 0x43 (enter/exit config), the data byte selecting enter(0x01)/ * exit(0x00) arrives paired with response index 2. Latch the new config * state; it takes effect from the next transaction (the ID byte already * reported the mode that was current at the start of this one). */ -<<<<<<< Updated upstream if (!g_pad_legacy_cfg && pad_current_cmd == 0x43 && pad_response_idx == 2 && pad_active_logical >= 0 && pad_active_logical < PSX_MAX_PLAYERS) pad_in_config[pad_active_logical] = (tx_byte == 0x01) ? 1 : 0; -======= - if (!g_pad_legacy_cfg && pad_current_cmd == 0x43 && pad_response_idx == 2) - pad_in_config[active_pad] = (tx_byte == 0x01) ? 1 : 0; ->>>>>>> Stashed changes /* 0x44 set-mode (game owns the analog/digital mode): the mode byte rides * in the same slot as 0x43's enter/exit flag (data position 3). 0x01 => * analog (0x73), 0x00 => digital (0x41). Honouring it makes the pad * coherent — the type the game just selected is the type it then polls, * instead of the host hybrid silently winning. Drop any stale host * request so it can't immediately undo the game's choice. */ -<<<<<<< Updated upstream if (!g_pad_legacy_cfg && pad_current_cmd == 0x44 && pad_response_idx == 2 && pad_active_logical >= 0 && pad_active_logical < PSX_MAX_PLAYERS) { pad_analog[pad_active_logical] = (tx_byte == 0x01) ? 1 : 0; pad_type_req[pad_active_logical] = -1; -======= - if (!g_pad_legacy_cfg && pad_current_cmd == 0x44 && pad_response_idx == 2) { - pad_analog[active_pad] = (tx_byte == 0x01) ? 1 : 0; - pad_type_req[active_pad] = -1; ->>>>>>> Stashed changes } /* 0x44 lock byte (data position 4, the byte after the mode byte): 0x03 => * lock analog mode, 0x02 => unlock (dualshock.cpp:714-725). A locked slot * ignores the host hybrid auto-flip (see analog_mode_locked). */ -<<<<<<< Updated upstream if (!g_pad_legacy_cfg && pad_current_cmd == 0x44 && pad_response_idx == 3 && pad_active_logical >= 0 && pad_active_logical < PSX_MAX_PLAYERS) { if (tx_byte == 0x03) analog_mode_locked[pad_active_logical] = 1; else if (tx_byte == 0x02) analog_mode_locked[pad_active_logical] = 0; -======= - if (!g_pad_legacy_cfg && pad_current_cmd == 0x44 && pad_response_idx == 3) { - if (tx_byte == 0x03) analog_mode_locked[active_pad] = 1; - else if (tx_byte == 0x02) analog_mode_locked[active_pad] = 0; ->>>>>>> Stashed changes } if (pad_response_idx < pad_response_len) { sio_rx_data = pad_response[pad_response_idx++]; @@ -1787,10 +1474,11 @@ static void sio_process_byte(uint8_t tx_byte) { if (active_device == DEV_NONE) { selected_slot = (sio_ctrl & SIO_CTRL_SLOT) ? 1 : 0; - if (tx_byte >= 0x01 && tx_byte <= 0x04) { - /* Pad / multitap select (0x01 = Slot A or single pad; 0x02..0x04 = - * multitap taps B-D). Save any in-flight card state back to its - * slot so it survives pad polling. */ + if (tx_byte == 0x01) { + /* Pad select. Save any in-flight card state back to its slot + * so it survives pad polling. Don't touch mc_state — we need + * it per-slot, and mc_load_slot will restore it when the card + * slot is selected again. */ if (mc_state != MC_IDLE) { mc_save_slot(mc_slot); mc_state = MC_IDLE; /* working vars idle while pad talks */ @@ -1868,7 +1556,7 @@ static void sio_process_byte(uint8_t tx_byte) { } else { active_device = DEV_NONE; selected_slot = (sio_ctrl & SIO_CTRL_SLOT) ? 1 : 0; - if (tx_byte >= 0x01 && tx_byte <= 0x04) { + if (tx_byte == 0x01) { active_device = DEV_PAD; pad_process_byte(tx_byte); } else { From 75640d36115fb79046732b5c1d538abe5239ce10 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Fri, 24 Jul 2026 15:08:29 -0400 Subject: [PATCH 18/38] Update recomp-net --- lib/recomp-net | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/recomp-net b/lib/recomp-net index b06b847d6..5786f9cc3 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit b06b847d67f1b45b1b62d30aeb7758ffe70fe7bd +Subproject commit 5786f9cc3d4e608b2ae85d201eb5c1ed68afb5d6 From 2be7ce36421d9ff45477e66b26aeff464ad80063 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Fri, 24 Jul 2026 15:28:08 -0400 Subject: [PATCH 19/38] Update recomp-net --- lib/recomp-net | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/recomp-net b/lib/recomp-net index 5786f9cc3..b06b847d6 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit 5786f9cc3d4e608b2ae85d201eb5c1ed68afb5d6 +Subproject commit b06b847d67f1b45b1b62d30aeb7758ffe70fe7bd From ed397fc16b1abf98c1e55db2adc7f6b9aabacdba Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Fri, 24 Jul 2026 15:30:23 -0400 Subject: [PATCH 20/38] rebump --- runtime/include/dirty_ram_interp.h | 6 +++++ runtime/src/memory.c | 42 +++++++++++++++++++++--------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/runtime/include/dirty_ram_interp.h b/runtime/include/dirty_ram_interp.h index 1e2f00e27..d1435465b 100644 --- a/runtime/include/dirty_ram_interp.h +++ b/runtime/include/dirty_ram_interp.h @@ -115,6 +115,12 @@ void dirty_ram_mark_executable_range(uint32_t phys, uint32_t len); void dirty_ram_register_text_image(uint32_t phys_lo, const uint8_t *bytes, uint32_t len); int dirty_ram_text_native_ok(uint32_t phys); +/* Exact CFG ranges; exec_pc clips ranges that end before the resume PC. */ +int dirty_ram_text_native_ok_ranges_from(const uint32_t *lo_len_pairs, + uint32_t count, + uint32_t exec_pc); +int dirty_ram_text_native_ok_ranges(const uint32_t *lo_len_pairs, + uint32_t count); int dirty_ram_text_image_registered(void); /* Bless an intentional runtime data patch (e.g. text_xlate string/glyph tables) * into the text reference image so it is not mistaken for self-modifying code. */ diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 43344a767..caa2ae137 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -383,10 +383,18 @@ int dirty_ram_text_native_ok(uint32_t phys) { * Each pair is {virtual/physical lo, byte len}; non-code gaps and mutable data * on the same page are intentionally absent. Unlike the legacy 256-byte probe, * a mismatch never poisons an unrelated 4 KB page forever: every decision is - * made from the live bytes the native body will actually execute. */ -int dirty_ram_text_native_ok_ranges(const uint32_t *lo_len_pairs, - uint32_t count) { + * made from the live bytes the native body will actually execute. + * + * exec_pc is the dispatch/resume address. Ranges that end at or before that PC + * are skipped, and a range that straddles it is clipped to [exec_pc, end). A + * runtime patch of a function prologue must not block a compiled continuation + * that never fetches the patched bytes. */ +int dirty_ram_text_native_ok_ranges_from(const uint32_t *lo_len_pairs, + uint32_t count, + uint32_t exec_pc) { if (!text_ref_image || !lo_len_pairs || count == 0) return 0; + uint32_t at = exec_pc & 0x1FFFFFFFu; + int any = 0; for (uint32_t i = 0; i < count; i++) { uint32_t phys = lo_len_pairs[i * 2u] & 0x1FFFFFFFu; uint32_t len = lo_len_pairs[i * 2u + 1u]; @@ -395,6 +403,12 @@ int dirty_ram_text_native_ok_ranges(const uint32_t *lo_len_pairs, g_text_native_blocked++; return 0; } + if (phys + len <= at) continue; + if (phys < at) { + len -= at - phys; + phys = at; + } + any = 1; if (memcmp(ram + phys, text_ref_image + (phys - text_ref_lo), len) != 0) { uint32_t off = 0; const uint8_t *live = ram + phys; @@ -406,23 +420,25 @@ int dirty_ram_text_native_ok_ranges(const uint32_t *lo_len_pairs, g_text_exact_last_mismatch = phys + off; g_text_exact_last_live = off < len ? live[off] : 0; g_text_exact_last_ref = off < len ? ref[off] : 0; - uint32_t first_page = phys >> DIRTY_RAM_PAGE_SHIFT; - uint32_t last_page = (phys + len - 1u) >> DIRTY_RAM_PAGE_SHIFT; - for (uint32_t page = first_page; page <= last_page; page++) { - uint32_t bit = 1u << (page & 31u); - uint32_t *word = &text_diverged_bitmap[page >> 5]; - if (!(*word & bit)) { - *word |= bit; - g_text_diverged_pages++; - } - } + /* Do not sticky-poison the page. A continuation on the same page + * may still match its clipped ranges. */ g_text_native_blocked++; return 0; } } + if (!any) { + g_text_native_blocked++; + return 0; + } return 1; } +/* Preserve the generated-code ABI used by existing game projects. */ +int dirty_ram_text_native_ok_ranges(const uint32_t *lo_len_pairs, + uint32_t count) { + return dirty_ram_text_native_ok_ranges_from(lo_len_pairs, count, 0u); +} + void dirty_ram_text_exact_mismatch_stats(uint64_t *count, uint32_t out[5]) { if (count) *count = g_text_exact_mismatches; From a4593df4349d171446ced61622a7648adbfac714 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Fri, 24 Jul 2026 15:39:38 -0400 Subject: [PATCH 21/38] Update psx_netplay.c --- runtime/src/psx_netplay.c | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index cf1cdb82d..d45efb339 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -1010,8 +1010,11 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) g_np.session = rnet_session_create(&rcfg, &host); if (!g_np.session) return -2; - /* Host-as-relay: slot 0 with 3+ seats and no dial peer (guests dial host). */ + /* Host-as-relay: slot 0 with 3+ seats and no dial peer (guests dial host). + * MotK is 2P (PSX_MAX_PLAYERS=2); skip the hub API so we still build + * against older recomp-net trees that only expose start_lan. */ { +#if PSX_MAX_PLAYERS >= 3 const int peer_empty = !cfg->peer_hostport || !cfg->peer_hostport[0]; const int use_hub = (local == 0 && slots >= 3 && peer_empty); @@ -1019,6 +1022,10 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) ? rnet_session_start_lan_hub(g_np.session, cfg->bind_hostport) : rnet_session_start_lan(g_np.session, cfg->bind_hostport, cfg->peer_hostport); +#else + const int rc = rnet_session_start_lan(g_np.session, cfg->bind_hostport, + cfg->peer_hostport); +#endif if (rc != 0) { rnet_session_destroy(g_np.session); g_np.session = NULL; From 2c372195d112c302c34ff519b7323c32d3ca3844 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Fri, 24 Jul 2026 15:40:05 -0400 Subject: [PATCH 22/38] Update main.cpp --- runtime/src/main.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index da038036d..41d6e3ee3 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -6844,7 +6844,7 @@ std::string player_device[PSX_MAX_PLAYERS]; s_rui_keybinds_path = (exe_dir_from_argv(argv[0]) / "keybinds.ini").string(); std::string rui_initial_disc = resolved_disc.string(); std::string rui_title = (game_name.empty() ? std::string("PSX") : game_name) - + " \xE2\x80\x94 Launcher"; + + " - Launcher"; RecompLauncherCSettings ls{}; ls.output_method = 2; /* OpenGL */ @@ -8071,7 +8071,7 @@ std::string player_device[PSX_MAX_PLAYERS]; { std::string assets_dir_str = exe_dir_from_argv(argv[0]).string(); std::string rui_title = (game_name.empty() ? std::string("PSX") : game_name) - + " \xE2\x80\x94 Launcher"; + + " - Launcher"; std::string rui_initial_disc = disc_path_str; RecompLauncherCSettings ls{}; From 2bba3cdc1ea6eeb272dee16108832d7076e591f8 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Fri, 24 Jul 2026 18:26:40 -0400 Subject: [PATCH 23/38] netplay patch --- lib/recomp-net | 2 +- runtime/include/psx_netplay.h | 24 ++++ runtime/include/sio.h | 5 + runtime/src/main.cpp | 132 +++++++++++++++---- runtime/src/psx_netplay.c | 231 +++++++++++++++++++++++++++++++--- runtime/src/savestate.c | 19 ++- runtime/src/sio.c | 18 +++ 7 files changed, 379 insertions(+), 52 deletions(-) diff --git a/lib/recomp-net b/lib/recomp-net index b06b847d6..5336f24b6 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit b06b847d67f1b45b1b62d30aeb7758ffe70fe7bd +Subproject commit 5336f24b6d31499c95983fb6753d3de91d5913d6 diff --git a/runtime/include/psx_netplay.h b/runtime/include/psx_netplay.h index 3d3e56255..13191f3b7 100644 --- a/runtime/include/psx_netplay.h +++ b/runtime/include/psx_netplay.h @@ -108,17 +108,41 @@ int psx_netplay_input_desync(uint32_t *tick, uint32_t *local_hash, uint32_t *re /* 1 if peer sent BYE or went silent for ~timeout_ms (default 1500). */ int psx_netplay_peer_disconnected(uint32_t timeout_ms); +/* + * Ingress / lobby / INPUT retransmit without try_admit. Used while the + * delay-sync starvation latch holds for remote runway refill. + */ +void psx_netplay_pump(void); + /* * Pump + try_admit for the current sim tick. On success, publish has written * SIO and a finish_frame() is owed after the guest completes that tick. * Returns 1 if admitted, 0 if caller must keep polling (linking / wait). * Does NOT advance the session clock. + * + * After sustained admit misses, latches starvation (pump-only) until + * remote_lead >= D for a few frames, then arms a recovery catch-up boost. + * Env: PSX_NET_STARVATION_ENTER_FRAMES, EXIT_FRAMES, EXIT_HR_LEAD. */ int psx_netplay_poll_admit(void); /* Call after the guest finishes the admitted tick (vblank boundary). */ void psx_netplay_finish_frame(void); +/* highest_remote_wire - sim_tick (0 if inactive; can be negative). */ +int psx_netplay_remote_lead(void); +/* Session input delay frames (default 2 when inactive). */ +int psx_netplay_input_delay(void); + +/* + * Extra headroom for post-starvation / behind-peer catch-up + * (min(16, max(0, remote_lead - D, recovery_burst))). + * Host should skip wall-clock pace while this is > 0, then call + * psx_netplay_catchup_consume_frame() once per skipped pace. + */ +int psx_netplay_catchup_budget(void); +void psx_netplay_catchup_consume_frame(void); + /* Park the admit barrier until a peer datagram may be ready (or timeout). */ void psx_netplay_wait_recv(int timeout_ms); diff --git a/runtime/include/sio.h b/runtime/include/sio.h index 49e146f4d..3e2f6f456 100644 --- a/runtime/include/sio.h +++ b/runtime/include/sio.h @@ -79,6 +79,11 @@ int sio_get_multitap(void); /* phys_port: 0 = console Port 1, 1 = console Port 2. Default 0. */ void sio_set_multitap_port(int phys_port); int sio_get_multitap_port(void); +/* 1 when multitap is armed and `logical_slot` is a tap pad (not the lone + * pad on the opposite console port). SCPH-1070 taps are treated as plain + * digital controllers (0x41) — DualShock/analog on a tap is not reliable + * across titles, so host input and SIO type requests are forced digital. */ +int sio_pad_on_multitap(int logical_slot); /* Update pad button state. Buttons use PS1 convention: 0=pressed, 1=released. Bit layout: SELECT, L3, R3, START, UP, RIGHT, DOWN, LEFT, diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 41d6e3ee3..4d0a1216c 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -2191,6 +2191,14 @@ static int effective_player_mode(const PlayerInput& p) { return p.mode; } +/* Pad mode for the SIO seat this sample will drive. Multitap taps are always + * plain digital (0x41) — DualShock on a SCPH-1070 tap is not reliable. */ +static int effective_player_mode_for_sio(const PlayerInput& p, int sio_slot) { + if (sio_pad_on_multitap(sio_slot)) + return (int)PSXRecompV4::PAD_MODE_DIGITAL; + return effective_player_mode(p); +} + /* Open/close SDL handles so they match g_players, and (re)assert each slot's * PSX connection + pad type. Safe to call repeatedly (hotplug, boot). * While delay-sync netplay is active, SIO connection/type are owned by @@ -2202,13 +2210,14 @@ static void refresh_player_devices(void) { if (p.kind != 2) close_player(p); /* keyboard/none: no handle */ else open_player(p, s); if (netplay) continue; - const int mode = effective_player_mode(p); + const int mode = effective_player_mode_for_sio(p, s); sio_set_pad_connected(s, p.kind != 0 ? 1 : 0); sio_set_pad_analog(s, pad_mode_boot_analog(mode), 0x80, 0x80, 0x80, 0x80); /* DIGITAL mode == a plain digital controller that ignores the DualShock * config-mode commands (real SCPH-1080 behaviour); ANALOG/HYBRID == a * config-capable DualShock. A digital pad that wrongly answered 0x43 - * sent Tomba 2's pad driver down the config path -> phantom 0x00 reads. */ + * sent Tomba 2's pad driver down the config path -> phantom 0x00 reads. + * Multitap taps are always digital (see sio_pad_on_multitap). */ sio_set_pad_config_capable(s, mode != PSXRecompV4::PAD_MODE_DIGITAL); } } @@ -2553,9 +2562,12 @@ static int capture_pad_slot(int s, PsxNetPad* out) { * analog axes below. An assigned device keeps its configured mode (a * launcher-selected analog DualShock stays analog, so its input path / SIO * handshake cadence is preserved exactly). Keyboard is always digital. - * A P1 with no assigned device but dev-any-input on presents as HYBRID. */ + * Multitap taps are forced digital. A P1 with no assigned device but + * dev-any-input on presents as HYBRID. */ int mode; - if (p.kind != 0) mode = effective_player_mode(p); + if (sio_pad_on_multitap(s)) + mode = (int)PSXRecompV4::PAD_MODE_DIGITAL; + else if (p.kind != 0) mode = effective_player_mode(p); else if (dev_here) mode = (int)PSXRecompV4::PAD_MODE_HYBRID; else mode = (int)PSXRecompV4::PAD_MODE_DIGITAL; int eff_analog; @@ -2614,8 +2626,9 @@ static int capture_pad_slot(int s, PsxNetPad* out) { /* Netplay-only capture: assigned PlayerInput for this slot only. Never merges * keyboard-all / all-controllers (dev_any_input). Same pad-mode / stick rules - * as capture_pad_slot otherwise. */ -static int capture_pad_slot_exclusive(int s, PsxNetPad* out) { + * as capture_pad_slot otherwise. present_sio_slot is the delay-sync seat this + * blob will publish to (may differ from host card `s` on guests). */ +static int capture_pad_slot_exclusive(int s, PsxNetPad* out, int present_sio_slot) { if (!out) return 0; out->buttons = 0xFFFFu; out->lx = out->ly = out->rx = out->ry = 0x80u; @@ -2627,7 +2640,8 @@ static int capture_pad_slot_exclusive(int s, PsxNetPad* out) { const bool dev_here = false; if (p.kind == 0) return 0; /* no device in this port */ - int mode = effective_player_mode(p); + const int sio_slot = (present_sio_slot >= 0) ? present_sio_slot : s; + int mode = effective_player_mode_for_sio(p, sio_slot); int eff_analog; if (mode == PSXRecompV4::PAD_MODE_DIGITAL) { eff_analog = 0; @@ -2657,6 +2671,8 @@ static int capture_pad_slot_exclusive(int s, PsxNetPad* out) { } static void apply_pad_slot_to_sio(int s, const PsxNetPad& pad) { + if (sio_pad_on_multitap(s)) + sio_set_pad_config_capable(s, 0); sio_set_pad_state_slot(s, pad.buttons); sio_set_pad_sticks(s, pad.lx, pad.ly, pad.rx, pad.ry); sio_request_pad_type(s, pad.analog ? 1 : 0); @@ -2664,22 +2680,42 @@ static void apply_pad_slot_to_sio(int s, const PsxNetPad& pad) { /* Local human pad for delay-sync: sample the host PlayerInput selected for * this peer (see --net-input-player / auto), then recomp-net maps that blob - * onto local_slot (host→sim P1, guest→sim P2). Never writes SIO. Exclusive + * onto local_slot (lobby seat → sim P1/P2/…). Never writes SIO. Exclusive * capture — no keyboard-all / all-controllers merge — so peers hash-agree. */ static void capture_local_human_pad(PsxNetPad* out) { int idx = psx_netplay_input_player(); if (idx < 0 || idx >= PSX_MAX_PLAYERS) idx = 0; - if (!capture_pad_slot_exclusive(idx, out)) { - /* Fallback: if auto picked empty local slot, try P1 (two-machine guest). */ - if (idx != 0 && capture_pad_slot_exclusive(0, out)) { + /* Present as the lobby seat (multitap taps → digital), not the host card. */ + const int seat = psx_netplay_local_slot(); + const int present = (seat >= 0) ? seat : idx; + if (capture_pad_slot_exclusive(idx, out, present)) { + out->connected = 1; + psx_netplay_normalize_pad(out); + return; + } + /* Fallbacks: NETPLAY/P1 card, lobby seat card, then any assigned device. */ + if (idx != 0 && capture_pad_slot_exclusive(0, out, present)) { + out->connected = 1; + psx_netplay_normalize_pad(out); + return; + } + if (seat >= 0 && seat < PSX_MAX_PLAYERS && seat != idx && seat != 0 && + capture_pad_slot_exclusive(seat, out, present)) { + out->connected = 1; + psx_netplay_normalize_pad(out); + return; + } + for (int s = 0; s < PSX_MAX_PLAYERS; ++s) { + if (s == idx || s == 0 || s == seat) continue; + if (capture_pad_slot_exclusive(s, out, present)) { out->connected = 1; psx_netplay_normalize_pad(out); return; } - out->buttons = 0xFFFFu; - out->lx = out->ly = out->rx = out->ry = 0x80u; - out->analog = 1; } + out->buttons = 0xFFFFu; + out->lx = out->ly = out->rx = out->ry = 0x80u; + out->analog = 1; out->connected = 1; psx_netplay_normalize_pad(out); } @@ -3273,6 +3309,12 @@ static void sdl_vblank_present(void) { if (psx_return_to_lobby_requested()) return; netplay_barrier_admit(override_); if (skip_pace_ || psx_return_to_lobby_requested()) return; + /* Post-starvation / behind-peer catch-up: skip wall pace so admits + * can burn down remote tip (mirrors snes_host_catchup_budget). */ + if (psx_netplay_catchup_budget() > 0) { + psx_netplay_catchup_consume_frame(); + return; + } uint64_t perf_start = runtime_perf_section_begin(); frame_pacer_wait(&s_frame_pacer, g_frame_period_ms); runtime_perf_section_end(perf_start, &g_runtime_perf.pacer_ticks); @@ -3293,8 +3335,15 @@ static void sdl_vblank_present(void) { if (g_offline_pad_count >= 3 && fntrace_is_game_started() && !sio_get_multitap()) { sio_set_multitap(1); + /* Tap seats drop to plain digital as soon as the tap is live. */ + for (int s = 0; s < PSX_MAX_PLAYERS; ++s) { + if (!sio_pad_on_multitap(s)) continue; + sio_set_pad_config_capable(s, 0); + sio_set_pad_analog(s, 0, 0x80, 0x80, 0x80, 0x80); + } std::fprintf(stdout, - "psxrecomp: multitap armed (console Port %d)\n", + "psxrecomp: multitap armed (console Port %d; " + "tap pads forced digital)\n", sio_get_multitap_port() + 1); } if (g_headless) @@ -4785,7 +4834,19 @@ namespace { (void)psx_lobby_set_match_caps(&caps); } - int ae_np_input_delay_get(void*) { return g_lnch_lobby_input_delay; } + int ae_np_input_delay_get(void*) { + /* Online guests show host-authoritative match_caps. */ + if (!g_lnch_hosting_lan && !g_lnch_joined_lan) { + const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); + if (caps && caps->valid) { + int d = caps->input_delay; + if (d < 2) d = 2; + if (d > 20) d = 20; + return d; + } + } + return g_lnch_lobby_input_delay; + } int ae_np_input_delay_set(void*, int delay_frames) { if (delay_frames < 2) delay_frames = 2; if (delay_frames > 20) delay_frames = 20; @@ -4793,7 +4854,14 @@ namespace { ae_np_push_match_caps(nullptr); return 0; } - int ae_np_force_input_relay_get(void*) { return g_lnch_force_input_relay; } + int ae_np_force_input_relay_get(void*) { + if (!g_lnch_hosting_lan && !g_lnch_joined_lan) { + const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); + if (caps && caps->valid) + return caps->force_input_relay ? 1 : 0; + } + return g_lnch_force_input_relay; + } int ae_np_force_input_relay_set(void*, int force) { g_lnch_force_input_relay = force ? 1 : 0; ae_np_push_match_caps(nullptr); @@ -5803,7 +5871,8 @@ namespace { local_slot = g_lnch_hosting_lan ? state.host_slot : 1; g_lnch_pending_direct_launch.local_slot = local_slot; } - g_lnch_pending_direct_launch.input_player = 0; + /* -1 => resolve at netplay start (prefer NETPLAY/P1 card). */ + g_lnch_pending_direct_launch.input_player = -1; g_lnch_pending_direct_launch.session_id = g_lnch_lan_session_id; g_lnch_pending_direct_launch.input_delay = g_lnch_lobby_input_delay; g_lnch_pending_direct_launch.max_slots = @@ -5877,9 +5946,11 @@ namespace { const PsxLobbyJoinInfo* ji = psx_lobby_join_info(); if (!ji || !ji->ok) return 0; const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); + if (!caps || !caps->valid) return 0; out->enabled = 1; out->local_slot = ji->local_slot; - out->input_player = 0; + /* -1 => resolve at netplay start (prefer NETPLAY/P1 card). */ + out->input_player = -1; std::snprintf(out->bind_hostport, sizeof(out->bind_hostport), "%s", ji->bind_hostport); std::snprintf(out->peer_hostport, sizeof(out->peer_hostport), "%s", ji->peer_hostport); out->session_id = ji->session_id; @@ -7375,7 +7446,7 @@ std::string player_device[PSX_MAX_PLAYERS]; /* Dev-any-input keeps P1 connected even with no assigned controller so the * keyboard / any plugged-in controller can drive port 1 standalone. */ const bool dev_p1 = (dev_any_input_enabled() && s == 0); - const int mode = effective_player_mode(g_players[s]); + const int mode = effective_player_mode_for_sio(g_players[s], s); sio_set_pad_connected(s, (g_players[s].kind != 0 || dev_p1) ? 1 : 0); sio_set_pad_analog(s, pad_mode_boot_analog(mode), 0x80, 0x80, 0x80, 0x80); sio_set_pad_config_capable(s, mode != PSXRecompV4::PAD_MODE_DIGITAL); @@ -7718,12 +7789,23 @@ std::string player_device[PSX_MAX_PLAYERS]; return 1; } /* Resolve which host PlayerInput feeds this peer's net sample. - * Auto: prefer g_players[local_slot] when assigned (same-PC: host - * C40 on P1 + guest keyboard on P2); else player 0 (two-machine). */ + * Auto (-1): always prefer dashboard P1 ("PLAYER N / NETPLAY") — that + * pad is published as lobby local_slot. Seat-card P2/P3… are only used + * when P1 is empty (legacy same-PC layout). Else sole assigned / P1. */ if (net_cfg.input_player < 0 || net_cfg.input_player >= PSX_MAX_PLAYERS) { - const int prefer = net_cfg.local_slot; - if (prefer >= 0 && prefer < PSX_MAX_PLAYERS && g_players[prefer].kind != 0) - net_cfg.input_player = prefer; + const int seat = net_cfg.local_slot; + int sole = -1, n_assigned = 0; + for (int i = 0; i < PSX_MAX_PLAYERS; ++i) { + if (g_players[i].kind == 0) continue; + ++n_assigned; + sole = i; + } + if (g_players[0].kind != 0) + net_cfg.input_player = 0; + else if (seat >= 0 && seat < PSX_MAX_PLAYERS && g_players[seat].kind != 0) + net_cfg.input_player = seat; + else if (n_assigned == 1) + net_cfg.input_player = sole; else net_cfg.input_player = 0; } diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index d45efb339..28bb58cf2 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -118,7 +118,8 @@ static void force_session_pads_connected(int slot_count) sio_set_multitap(0); for (i = 0; i < slot_count; ++i) { sio_connect_pad(i); - sio_set_pad_config_capable(i, 1); + /* Multitap taps are plain digital (sio clamps); lone port pad may be DS. */ + sio_set_pad_config_capable(i, sio_pad_on_multitap(i) ? 0 : 1); } } @@ -132,7 +133,8 @@ void psx_netplay_release_pads(void) for (i = 0; i < n; ++i) { sio_set_pad_state_slot(i, 0xFFFFu); sio_set_pad_sticks(i, 0x80, 0x80, 0x80, 0x80); - sio_request_pad_type(i, 1); + /* Tap slots stay digital; standalone port may request DualShock. */ + sio_request_pad_type(i, sio_pad_on_multitap(i) ? 0 : 1); } } @@ -168,8 +170,13 @@ int psx_netplay_is_host(void) { return 0; } int psx_netplay_request_save(int slot) { (void)slot; return 0; } int psx_netplay_request_load(int slot) { (void)slot; return 0; } int psx_netplay_in_load_barrier(void) { return 0; } +void psx_netplay_pump(void) {} int psx_netplay_poll_admit(void) { return 1; } void psx_netplay_finish_frame(void) {} +int psx_netplay_remote_lead(void) { return 0; } +int psx_netplay_input_delay(void) { return 2; } +int psx_netplay_catchup_budget(void) { return 0; } +void psx_netplay_catchup_consume_frame(void) {} void psx_netplay_wait_recv(int timeout_ms) { (void)timeout_ms; } #else /* PSX_HAS_RECOMP_NET */ @@ -807,11 +814,15 @@ static void decode_pad(const RNetInputSample *in, PsxNetPad *pad) static void apply_pad_slot(int slot, const PsxNetPad *pad) { if (slot < 0 || slot >= g_np.slot_count || slot >= PSX_MAX_PLAYERS || !pad) return; + const int on_tap = sio_pad_on_multitap(slot); sio_set_pad_connected(slot, 1); - sio_set_pad_config_capable(slot, 1); + sio_set_pad_config_capable(slot, on_tap ? 0 : 1); sio_set_pad_state_slot(slot, pad->buttons); - sio_set_pad_sticks(slot, pad->lx, pad->ly, pad->rx, pad->ry); - sio_request_pad_type(slot, pad->analog ? 1 : 0); + if (on_tap) + sio_set_pad_sticks(slot, 0x80, 0x80, 0x80, 0x80); + else + sio_set_pad_sticks(slot, pad->lx, pad->ly, pad->rx, pad->ry); + sio_request_pad_type(slot, (!on_tap && pad->analog) ? 1 : 0); } static void host_sample_local(rnet_u32 tick, RNetInputSample *out, void *ctx) @@ -1070,6 +1081,56 @@ void psx_netplay_bind_guest_saves(void) np_enter_guest_sandbox(); } +/* Delay-sync starvation hold (lockstep-safe; mirrors snes_host_barrier_admit). */ +#define PSX_STARVATION_ENTER_DEFAULT 4 +#define PSX_STARVATION_EXIT_DEFAULT 3 +#define PSX_STARVATION_EXIT_HR_LEAD_DEFAULT 0 +#define PSX_STARVATION_GRACE_TICKS 60 +#define PSX_STARVATION_RECOVERY_BURST 16 +#define PSX_CATCHUP_CAP 16 + +static struct { + int latched; + int enter_run; + int exit_run; + int recovery_amount; + int latch_logged; + int just_cleared; +} g_starv; + +/* Defined below poll_admit; used by the starvation runway check. */ +int psx_netplay_remote_lead(void); +int psx_netplay_input_delay(void); + +static int np_starv_env_int(const char *name, int def) +{ + const char *v = getenv(name); + long n; + char *end; + if (!v || !v[0]) + return def; + n = strtol(v, &end, 10); + if (end == v || *end != '\0' || n < 0 || n > 64) + return def; + return (int)n; +} + +static void np_starv_reset(void) +{ + memset(&g_starv, 0, sizeof(g_starv)); +} + +static int np_starv_runway_ok(void) +{ + int lead = psx_netplay_remote_lead(); + int delay = psx_netplay_input_delay(); + int hr_lead = np_starv_env_int("PSX_NET_STARVATION_EXIT_HR_LEAD", + PSX_STARVATION_EXIT_HR_LEAD_DEFAULT); + if (delay < 0) + delay = 0; + return lead >= delay + hr_lead; +} + void psx_netplay_shutdown(void) { if (g_np.session) { @@ -1079,6 +1140,7 @@ void psx_netplay_shutdown(void) } np_leave_guest_sandbox(); memset(&g_np, 0, sizeof(g_np)); + np_starv_reset(); } int psx_netplay_is_host(void) @@ -1090,9 +1152,6 @@ int psx_netplay_request_save(int slot) { if (!psx_netplay_active() || !rnet_session_is_running(g_np.session)) return 0; - printf("psxrecomp: netplay savestates are disabled\n"); - fflush(stdout); - return 1; if (g_np.local_slot != 0) return 1; /* guest: host-only; ignore */ if (np_xfer_busy() || !g_np.mc_sync_done) @@ -1103,7 +1162,9 @@ int psx_netplay_request_save(int slot) if (!savestate_request_save_protocol(slot)) return 1; /* Coord probe (size=0) does not stall admit — both peers must keep - * running until savestate_poll writes the .pst, then hash-probe stalls. */ + * running until savestate_poll writes the .pst, then hash-probe stalls. + * STATE_* rides the same UDP/relay path as inputs (LAN hub / server + * input relay fan-out). */ if (rnet_session_state_probe(g_np.session, RNET_STATE_OP_SAVE, (rnet_u8)slot, 0, 0) != 0) return 1; g_np.xfer = NP_XFER_SAVE_COORD; @@ -1118,9 +1179,6 @@ int psx_netplay_request_load(int slot) uint32_t size = 0, crc = 0; if (!psx_netplay_active() || !rnet_session_is_running(g_np.session)) return 0; - printf("psxrecomp: netplay savestates are disabled\n"); - fflush(stdout); - return 1; if (g_np.local_slot != 0) return 1; if (np_xfer_busy() || !g_np.mc_sync_done) @@ -1146,23 +1204,51 @@ int psx_netplay_in_load_barrier(void) return (g_np.xfer == NP_XFER_LOAD_APPLYING || g_np.xfer == NP_XFER_LOAD_READY) ? 1 : 0; } -int psx_netplay_poll_admit(void) +static void np_pump_session(void) { - rnet_u32 sim; - if (!psx_netplay_active()) return 1; - rnet_session_pump(g_np.session); np_guest_handle_probe(); np_apply_ready_state(); np_drive_load_barrier(); np_host_drive_xfer(); + if (rnet_session_is_running(g_np.session)) + np_maybe_start_mc_sync(); +} + +void psx_netplay_pump(void) +{ + if (!psx_netplay_active()) + return; + np_pump_session(); +} + +static int np_try_admit_gameplay(void) +{ + rnet_u32 sim = rnet_session_sim_tick(g_np.session); + if (rnet_session_try_admit(g_np.session, sim)) { + g_np.needs_advance = 1; + return 1; + } + force_session_pads_connected(g_np.slot_count); + return 0; +} + +int psx_netplay_poll_admit(void) +{ + rnet_u32 sim; + int enter_need; + int exit_need; + + if (!psx_netplay_active()) return 1; + + np_pump_session(); if (!rnet_session_is_running(g_np.session)) { psx_netplay_release_pads(); + np_starv_reset(); return 0; } - np_maybe_start_mc_sync(); /* Both peers stall until initial memcard hash-agree / transfer finishes. */ if (!g_np.mc_sync_done) return 0; @@ -1200,11 +1286,66 @@ int psx_netplay_poll_admit(void) if (g_np.needs_advance) return 1; sim = rnet_session_sim_tick(g_np.session); - if (rnet_session_try_admit(g_np.session, sim)) { - g_np.needs_advance = 1; + enter_need = np_starv_env_int("PSX_NET_STARVATION_ENTER_FRAMES", + PSX_STARVATION_ENTER_DEFAULT); + exit_need = np_starv_env_int("PSX_NET_STARVATION_EXIT_FRAMES", + PSX_STARVATION_EXIT_DEFAULT); + + /* Startup grace: do not latch before the delay rings warm up. */ + if (sim < (rnet_u32)PSX_STARVATION_GRACE_TICKS) { + g_starv.enter_run = 0; + g_starv.exit_run = 0; + g_starv.latched = 0; + g_starv.just_cleared = 0; + return np_try_admit_gameplay(); + } + + if (g_starv.latched) { + /* Pump already ran; hold try_admit until remote tip refills. */ + if (np_starv_runway_ok()) { + g_starv.exit_run++; + if (g_starv.exit_run >= exit_need) { + g_starv.latched = 0; + g_starv.exit_run = 0; + g_starv.latch_logged = 0; + g_starv.just_cleared = 1; + } else { + return 0; + } + } else { + g_starv.exit_run = 0; + return 0; + } + } + + if (np_try_admit_gameplay()) { + g_starv.enter_run = 0; + if (g_starv.just_cleared) { + g_starv.just_cleared = 0; + g_starv.recovery_amount = PSX_STARVATION_RECOVERY_BURST; + fprintf(stderr, + "psxrecomp: delay_sync_starvation cleared sim=%u lead=%d " + "D=%d — recovery burst %d\n", + (unsigned)psx_netplay_sim_tick(), psx_netplay_remote_lead(), + psx_netplay_input_delay(), PSX_STARVATION_RECOVERY_BURST); + } return 1; } - force_session_pads_connected(g_np.slot_count); + + g_starv.just_cleared = 0; + g_starv.enter_run++; + if (g_starv.enter_run >= enter_need) { + g_starv.latched = 1; + g_starv.enter_run = 0; + if (!g_starv.latch_logged) { + fprintf(stderr, + "psxrecomp: delay_sync_starvation latched sim=%u lead=%d " + "D=%d (enter=%d)\n", + (unsigned)psx_netplay_sim_tick(), psx_netplay_remote_lead(), + psx_netplay_input_delay(), enter_need); + g_starv.latch_logged = 1; + } + } return 0; } @@ -1217,6 +1358,56 @@ void psx_netplay_finish_frame(void) g_np.latched_for_tick = 0; } +int psx_netplay_remote_lead(void) +{ + RNetSessionStats st; + if (!psx_netplay_active()) + return 0; + memset(&st, 0, sizeof(st)); + rnet_session_get_stats(g_np.session, &st); + return st.remote_lead; +} + +int psx_netplay_input_delay(void) +{ + RNetSessionStats st; + if (!psx_netplay_active()) + return 2; + memset(&st, 0, sizeof(st)); + rnet_session_get_stats(g_np.session, &st); + return st.delay > 0 ? (int)st.delay : 2; +} + +int psx_netplay_catchup_budget(void) +{ + int lead; + int delay; + int extra; + int budget; + + if (!psx_netplay_active()) + return 0; + lead = psx_netplay_remote_lead(); + delay = psx_netplay_input_delay(); + if (delay < 0) + delay = 0; + extra = lead - delay; + if (extra < 0) + extra = 0; + budget = extra; + if (g_starv.recovery_amount > budget) + budget = g_starv.recovery_amount; + if (budget > PSX_CATCHUP_CAP) + budget = PSX_CATCHUP_CAP; + return budget; +} + +void psx_netplay_catchup_consume_frame(void) +{ + if (g_starv.recovery_amount > 0) + g_starv.recovery_amount--; +} + void psx_netplay_wait_recv(int timeout_ms) { if (!psx_netplay_active()) return; diff --git a/runtime/src/savestate.c b/runtime/src/savestate.c index b04e4ac0a..58ea9436a 100644 --- a/runtime/src/savestate.c +++ b/runtime/src/savestate.c @@ -136,9 +136,16 @@ int savestate_write_slot(int slot, const void* data, size_t size) { return 1; } -static int netplay_savestate_blocked(void) { +/* User APIs during netplay: guests cannot initiate; host must use + * psx_netplay_request_* so peers hash-probe and sync over STATE_*. */ +static int netplay_user_blocked(void) { if (!psx_netplay_active()) return 0; - fprintf(stderr, "savestate: disabled during netplay\n"); + if (!psx_netplay_is_host()) { + fprintf(stderr, "savestate: netplay guest cannot save/load (host-only)\n"); + return 1; + } + fprintf(stderr, + "savestate: during netplay use host Shift+F / F (synced path)\n"); return 1; } @@ -171,22 +178,22 @@ static int request_load_inner(int slot) { } int savestate_request_save(int slot) { - if (netplay_savestate_blocked()) return 0; + if (netplay_user_blocked()) return 0; return request_save_inner(slot); } int savestate_request_load(int slot) { - if (netplay_savestate_blocked()) return 0; + if (netplay_user_blocked()) return 0; return request_load_inner(slot); } int savestate_request_save_protocol(int slot) { - if (netplay_savestate_blocked()) return 0; + /* Follow-host sync: guests must write the host-authoritative .pst. */ return request_save_inner(slot); } int savestate_request_load_protocol(int slot) { - if (netplay_savestate_blocked()) return 0; + /* Follow-host sync: guests must apply the host-authoritative .pst. */ return request_load_inner(slot); } diff --git a/runtime/src/sio.c b/runtime/src/sio.c index cd79f833b..0a1ea7498 100644 --- a/runtime/src/sio.c +++ b/runtime/src/sio.c @@ -768,6 +768,18 @@ int sio_get_multitap_port(void) { #endif } +int sio_pad_on_multitap(int logical_slot) { +#if PSX_MAX_PLAYERS >= 5 + if (!sio_multitap_active()) return 0; + if (logical_slot < 0 || logical_slot >= PSX_MAX_PLAYERS) return 0; + /* Opposite-port lone pad may stay DualShock; every tap slot is digital. */ + return (logical_slot == mtap_standalone_logical()) ? 0 : 1; +#else + (void)logical_slot; + return 0; +#endif +} + void sio_connect_pad(int slot) { if (slot >= 0 && slot < PSX_MAX_PLAYERS) pad_connected |= (uint8_t)(1u << slot); @@ -781,6 +793,7 @@ void sio_set_pad_connected(int slot, int connected) { void sio_set_pad_config_capable(int slot, int capable) { if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; + if (sio_pad_on_multitap(slot)) capable = 0; pad_supports_config[slot] = capable ? 1 : 0; /* A plain digital pad can never be in config mode; clear any stale latch so * the next poll reports the digital id (0x41), not the config id (0xF3). */ @@ -803,6 +816,10 @@ void sio_set_pad_state_slot(int slot, uint16_t buttons) { void sio_set_pad_analog(int slot, int enabled, uint8_t lx, uint8_t ly, uint8_t rx, uint8_t ry) { if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; + if (sio_pad_on_multitap(slot)) { + enabled = 0; + lx = ly = rx = ry = 0x80; + } pad_analog[slot] = enabled ? 1 : 0; pad_type_req[slot] = -1; /* explicit set supersedes any pending request */ pad_stick[slot][0] = lx; pad_stick[slot][1] = ly; @@ -821,6 +838,7 @@ void sio_set_pad_sticks(int slot, uint8_t lx, uint8_t ly, uint8_t rx, uint8_t ry * can never split a poll or a config handshake. A no-op if already that type. */ void sio_request_pad_type(int slot, int analog) { if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; + if (sio_pad_on_multitap(slot)) analog = 0; int want = analog ? 1 : 0; pad_type_req[slot] = (pad_analog[slot] == want) ? -1 : (int8_t)want; } From 80d207024f1d4c99da9322e8e3c558a7bc3bf712 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Fri, 24 Jul 2026 18:27:07 -0400 Subject: [PATCH 24/38] netplay patch --- lib/recomp-net | 2 +- runtime/include/sio.h | 5 ++ runtime/src/main.cpp | 130 ++++++++++++++++++++++++++++++-------- runtime/src/psx_netplay.c | 26 ++++---- runtime/src/savestate.c | 19 ++++-- runtime/src/sio.c | 18 ++++++ 6 files changed, 154 insertions(+), 46 deletions(-) diff --git a/lib/recomp-net b/lib/recomp-net index b06b847d6..ed4c50a84 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit b06b847d67f1b45b1b62d30aeb7758ffe70fe7bd +Subproject commit ed4c50a841f034e4076e78dbe89e3692626d71de diff --git a/runtime/include/sio.h b/runtime/include/sio.h index 49e146f4d..3e2f6f456 100644 --- a/runtime/include/sio.h +++ b/runtime/include/sio.h @@ -79,6 +79,11 @@ int sio_get_multitap(void); /* phys_port: 0 = console Port 1, 1 = console Port 2. Default 0. */ void sio_set_multitap_port(int phys_port); int sio_get_multitap_port(void); +/* 1 when multitap is armed and `logical_slot` is a tap pad (not the lone + * pad on the opposite console port). SCPH-1070 taps are treated as plain + * digital controllers (0x41) — DualShock/analog on a tap is not reliable + * across titles, so host input and SIO type requests are forced digital. */ +int sio_pad_on_multitap(int logical_slot); /* Update pad button state. Buttons use PS1 convention: 0=pressed, 1=released. Bit layout: SELECT, L3, R3, START, UP, RIGHT, DOWN, LEFT, diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 41d6e3ee3..9d66b5729 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -2191,6 +2191,14 @@ static int effective_player_mode(const PlayerInput& p) { return p.mode; } +/* Pad mode for the SIO seat this sample will drive. Multitap taps are always + * plain digital (0x41) — DualShock on a SCPH-1070 tap is not reliable. */ +static int effective_player_mode_for_sio(const PlayerInput& p, int sio_slot) { + if (sio_pad_on_multitap(sio_slot)) + return (int)PSXRecompV4::PAD_MODE_DIGITAL; + return effective_player_mode(p); +} + /* Open/close SDL handles so they match g_players, and (re)assert each slot's * PSX connection + pad type. Safe to call repeatedly (hotplug, boot). * While delay-sync netplay is active, SIO connection/type are owned by @@ -2202,13 +2210,14 @@ static void refresh_player_devices(void) { if (p.kind != 2) close_player(p); /* keyboard/none: no handle */ else open_player(p, s); if (netplay) continue; - const int mode = effective_player_mode(p); + const int mode = effective_player_mode_for_sio(p, s); sio_set_pad_connected(s, p.kind != 0 ? 1 : 0); sio_set_pad_analog(s, pad_mode_boot_analog(mode), 0x80, 0x80, 0x80, 0x80); /* DIGITAL mode == a plain digital controller that ignores the DualShock * config-mode commands (real SCPH-1080 behaviour); ANALOG/HYBRID == a * config-capable DualShock. A digital pad that wrongly answered 0x43 - * sent Tomba 2's pad driver down the config path -> phantom 0x00 reads. */ + * sent Tomba 2's pad driver down the config path -> phantom 0x00 reads. + * Multitap taps are always digital (see sio_pad_on_multitap). */ sio_set_pad_config_capable(s, mode != PSXRecompV4::PAD_MODE_DIGITAL); } } @@ -2553,9 +2562,12 @@ static int capture_pad_slot(int s, PsxNetPad* out) { * analog axes below. An assigned device keeps its configured mode (a * launcher-selected analog DualShock stays analog, so its input path / SIO * handshake cadence is preserved exactly). Keyboard is always digital. - * A P1 with no assigned device but dev-any-input on presents as HYBRID. */ + * Multitap taps are forced digital. A P1 with no assigned device but + * dev-any-input on presents as HYBRID. */ int mode; - if (p.kind != 0) mode = effective_player_mode(p); + if (sio_pad_on_multitap(s)) + mode = (int)PSXRecompV4::PAD_MODE_DIGITAL; + else if (p.kind != 0) mode = effective_player_mode(p); else if (dev_here) mode = (int)PSXRecompV4::PAD_MODE_HYBRID; else mode = (int)PSXRecompV4::PAD_MODE_DIGITAL; int eff_analog; @@ -2614,8 +2626,9 @@ static int capture_pad_slot(int s, PsxNetPad* out) { /* Netplay-only capture: assigned PlayerInput for this slot only. Never merges * keyboard-all / all-controllers (dev_any_input). Same pad-mode / stick rules - * as capture_pad_slot otherwise. */ -static int capture_pad_slot_exclusive(int s, PsxNetPad* out) { + * as capture_pad_slot otherwise. present_sio_slot is the delay-sync seat this + * blob will publish to (may differ from host card `s` on guests). */ +static int capture_pad_slot_exclusive(int s, PsxNetPad* out, int present_sio_slot) { if (!out) return 0; out->buttons = 0xFFFFu; out->lx = out->ly = out->rx = out->ry = 0x80u; @@ -2627,7 +2640,8 @@ static int capture_pad_slot_exclusive(int s, PsxNetPad* out) { const bool dev_here = false; if (p.kind == 0) return 0; /* no device in this port */ - int mode = effective_player_mode(p); + const int sio_slot = (present_sio_slot >= 0) ? present_sio_slot : s; + int mode = effective_player_mode_for_sio(p, sio_slot); int eff_analog; if (mode == PSXRecompV4::PAD_MODE_DIGITAL) { eff_analog = 0; @@ -2657,6 +2671,8 @@ static int capture_pad_slot_exclusive(int s, PsxNetPad* out) { } static void apply_pad_slot_to_sio(int s, const PsxNetPad& pad) { + if (sio_pad_on_multitap(s)) + sio_set_pad_config_capable(s, 0); sio_set_pad_state_slot(s, pad.buttons); sio_set_pad_sticks(s, pad.lx, pad.ly, pad.rx, pad.ry); sio_request_pad_type(s, pad.analog ? 1 : 0); @@ -2664,22 +2680,42 @@ static void apply_pad_slot_to_sio(int s, const PsxNetPad& pad) { /* Local human pad for delay-sync: sample the host PlayerInput selected for * this peer (see --net-input-player / auto), then recomp-net maps that blob - * onto local_slot (host→sim P1, guest→sim P2). Never writes SIO. Exclusive + * onto local_slot (lobby seat → sim P1/P2/…). Never writes SIO. Exclusive * capture — no keyboard-all / all-controllers merge — so peers hash-agree. */ static void capture_local_human_pad(PsxNetPad* out) { int idx = psx_netplay_input_player(); if (idx < 0 || idx >= PSX_MAX_PLAYERS) idx = 0; - if (!capture_pad_slot_exclusive(idx, out)) { - /* Fallback: if auto picked empty local slot, try P1 (two-machine guest). */ - if (idx != 0 && capture_pad_slot_exclusive(0, out)) { + /* Present as the lobby seat (multitap taps → digital), not the host card. */ + const int seat = psx_netplay_local_slot(); + const int present = (seat >= 0) ? seat : idx; + if (capture_pad_slot_exclusive(idx, out, present)) { + out->connected = 1; + psx_netplay_normalize_pad(out); + return; + } + /* Fallbacks: NETPLAY/P1 card, lobby seat card, then any assigned device. */ + if (idx != 0 && capture_pad_slot_exclusive(0, out, present)) { + out->connected = 1; + psx_netplay_normalize_pad(out); + return; + } + if (seat >= 0 && seat < PSX_MAX_PLAYERS && seat != idx && seat != 0 && + capture_pad_slot_exclusive(seat, out, present)) { + out->connected = 1; + psx_netplay_normalize_pad(out); + return; + } + for (int s = 0; s < PSX_MAX_PLAYERS; ++s) { + if (s == idx || s == 0 || s == seat) continue; + if (capture_pad_slot_exclusive(s, out, present)) { out->connected = 1; psx_netplay_normalize_pad(out); return; } - out->buttons = 0xFFFFu; - out->lx = out->ly = out->rx = out->ry = 0x80u; - out->analog = 1; } + out->buttons = 0xFFFFu; + out->lx = out->ly = out->rx = out->ry = 0x80u; + out->analog = 1; out->connected = 1; psx_netplay_normalize_pad(out); } @@ -3293,8 +3329,15 @@ static void sdl_vblank_present(void) { if (g_offline_pad_count >= 3 && fntrace_is_game_started() && !sio_get_multitap()) { sio_set_multitap(1); + /* Tap seats drop to plain digital as soon as the tap is live. */ + for (int s = 0; s < PSX_MAX_PLAYERS; ++s) { + if (!sio_pad_on_multitap(s)) continue; + sio_set_pad_config_capable(s, 0); + sio_set_pad_analog(s, 0, 0x80, 0x80, 0x80, 0x80); + } std::fprintf(stdout, - "psxrecomp: multitap armed (console Port %d)\n", + "psxrecomp: multitap armed (console Port %d; " + "tap pads forced digital)\n", sio_get_multitap_port() + 1); } if (g_headless) @@ -4785,7 +4828,19 @@ namespace { (void)psx_lobby_set_match_caps(&caps); } - int ae_np_input_delay_get(void*) { return g_lnch_lobby_input_delay; } + int ae_np_input_delay_get(void*) { + /* Online guests show host-authoritative match_caps. */ + if (!g_lnch_hosting_lan && !g_lnch_joined_lan) { + const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); + if (caps && caps->valid) { + int d = caps->input_delay; + if (d < 2) d = 2; + if (d > 20) d = 20; + return d; + } + } + return g_lnch_lobby_input_delay; + } int ae_np_input_delay_set(void*, int delay_frames) { if (delay_frames < 2) delay_frames = 2; if (delay_frames > 20) delay_frames = 20; @@ -4793,7 +4848,14 @@ namespace { ae_np_push_match_caps(nullptr); return 0; } - int ae_np_force_input_relay_get(void*) { return g_lnch_force_input_relay; } + int ae_np_force_input_relay_get(void*) { + if (!g_lnch_hosting_lan && !g_lnch_joined_lan) { + const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); + if (caps && caps->valid) + return caps->force_input_relay ? 1 : 0; + } + return g_lnch_force_input_relay; + } int ae_np_force_input_relay_set(void*, int force) { g_lnch_force_input_relay = force ? 1 : 0; ae_np_push_match_caps(nullptr); @@ -5803,7 +5865,8 @@ namespace { local_slot = g_lnch_hosting_lan ? state.host_slot : 1; g_lnch_pending_direct_launch.local_slot = local_slot; } - g_lnch_pending_direct_launch.input_player = 0; + /* -1 => resolve at netplay start (prefer local_slot's device). */ + g_lnch_pending_direct_launch.input_player = -1; g_lnch_pending_direct_launch.session_id = g_lnch_lan_session_id; g_lnch_pending_direct_launch.input_delay = g_lnch_lobby_input_delay; g_lnch_pending_direct_launch.max_slots = @@ -5877,14 +5940,16 @@ namespace { const PsxLobbyJoinInfo* ji = psx_lobby_join_info(); if (!ji || !ji->ok) return 0; const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); + /* Host match_caps are required online — do not silently default D/relay. */ + if (!caps || !caps->valid) return 0; out->enabled = 1; out->local_slot = ji->local_slot; - out->input_player = 0; + /* -1 => resolve at netplay start (prefer local_slot's device). */ + out->input_player = -1; std::snprintf(out->bind_hostport, sizeof(out->bind_hostport), "%s", ji->bind_hostport); std::snprintf(out->peer_hostport, sizeof(out->peer_hostport), "%s", ji->peer_hostport); out->session_id = ji->session_id; - out->input_delay = (caps && caps->valid) ? caps->input_delay - : g_lnch_lobby_input_delay; + out->input_delay = caps->input_delay; out->max_slots = ji->max_slots >= 2 ? ji->max_slots : (g_lnch_game_players >= 2 ? g_lnch_game_players : 2); if (out->max_slots > PSX_MAX_PLAYERS) out->max_slots = PSX_MAX_PLAYERS; @@ -7375,7 +7440,7 @@ std::string player_device[PSX_MAX_PLAYERS]; /* Dev-any-input keeps P1 connected even with no assigned controller so the * keyboard / any plugged-in controller can drive port 1 standalone. */ const bool dev_p1 = (dev_any_input_enabled() && s == 0); - const int mode = effective_player_mode(g_players[s]); + const int mode = effective_player_mode_for_sio(g_players[s], s); sio_set_pad_connected(s, (g_players[s].kind != 0 || dev_p1) ? 1 : 0); sio_set_pad_analog(s, pad_mode_boot_analog(mode), 0x80, 0x80, 0x80, 0x80); sio_set_pad_config_capable(s, mode != PSXRecompV4::PAD_MODE_DIGITAL); @@ -7718,12 +7783,23 @@ std::string player_device[PSX_MAX_PLAYERS]; return 1; } /* Resolve which host PlayerInput feeds this peer's net sample. - * Auto: prefer g_players[local_slot] when assigned (same-PC: host - * C40 on P1 + guest keyboard on P2); else player 0 (two-machine). */ + * Auto (-1): always prefer dashboard P1 ("PLAYER N / NETPLAY") — that + * pad is published as lobby local_slot. Seat-card P2/P3… are only used + * when P1 is empty (legacy same-PC layout). Else sole assigned / P1. */ if (net_cfg.input_player < 0 || net_cfg.input_player >= PSX_MAX_PLAYERS) { - const int prefer = net_cfg.local_slot; - if (prefer >= 0 && prefer < PSX_MAX_PLAYERS && g_players[prefer].kind != 0) - net_cfg.input_player = prefer; + const int seat = net_cfg.local_slot; + int sole = -1, n_assigned = 0; + for (int i = 0; i < PSX_MAX_PLAYERS; ++i) { + if (g_players[i].kind == 0) continue; + ++n_assigned; + sole = i; + } + if (g_players[0].kind != 0) + net_cfg.input_player = 0; + else if (seat >= 0 && seat < PSX_MAX_PLAYERS && g_players[seat].kind != 0) + net_cfg.input_player = seat; + else if (n_assigned == 1) + net_cfg.input_player = sole; else net_cfg.input_player = 0; } diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index d45efb339..463a7067c 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -118,7 +118,8 @@ static void force_session_pads_connected(int slot_count) sio_set_multitap(0); for (i = 0; i < slot_count; ++i) { sio_connect_pad(i); - sio_set_pad_config_capable(i, 1); + /* Multitap taps are plain digital (sio clamps); lone port pad may be DS. */ + sio_set_pad_config_capable(i, sio_pad_on_multitap(i) ? 0 : 1); } } @@ -132,7 +133,8 @@ void psx_netplay_release_pads(void) for (i = 0; i < n; ++i) { sio_set_pad_state_slot(i, 0xFFFFu); sio_set_pad_sticks(i, 0x80, 0x80, 0x80, 0x80); - sio_request_pad_type(i, 1); + /* Tap slots stay digital; standalone port may request DualShock. */ + sio_request_pad_type(i, sio_pad_on_multitap(i) ? 0 : 1); } } @@ -807,11 +809,15 @@ static void decode_pad(const RNetInputSample *in, PsxNetPad *pad) static void apply_pad_slot(int slot, const PsxNetPad *pad) { if (slot < 0 || slot >= g_np.slot_count || slot >= PSX_MAX_PLAYERS || !pad) return; + const int on_tap = sio_pad_on_multitap(slot); sio_set_pad_connected(slot, 1); - sio_set_pad_config_capable(slot, 1); + sio_set_pad_config_capable(slot, on_tap ? 0 : 1); sio_set_pad_state_slot(slot, pad->buttons); - sio_set_pad_sticks(slot, pad->lx, pad->ly, pad->rx, pad->ry); - sio_request_pad_type(slot, pad->analog ? 1 : 0); + if (on_tap) + sio_set_pad_sticks(slot, 0x80, 0x80, 0x80, 0x80); + else + sio_set_pad_sticks(slot, pad->lx, pad->ly, pad->rx, pad->ry); + sio_request_pad_type(slot, (!on_tap && pad->analog) ? 1 : 0); } static void host_sample_local(rnet_u32 tick, RNetInputSample *out, void *ctx) @@ -1090,9 +1096,6 @@ int psx_netplay_request_save(int slot) { if (!psx_netplay_active() || !rnet_session_is_running(g_np.session)) return 0; - printf("psxrecomp: netplay savestates are disabled\n"); - fflush(stdout); - return 1; if (g_np.local_slot != 0) return 1; /* guest: host-only; ignore */ if (np_xfer_busy() || !g_np.mc_sync_done) @@ -1103,7 +1106,9 @@ int psx_netplay_request_save(int slot) if (!savestate_request_save_protocol(slot)) return 1; /* Coord probe (size=0) does not stall admit — both peers must keep - * running until savestate_poll writes the .pst, then hash-probe stalls. */ + * running until savestate_poll writes the .pst, then hash-probe stalls. + * STATE_* rides the same UDP/relay path as inputs (LAN hub / server + * input relay fan-out). */ if (rnet_session_state_probe(g_np.session, RNET_STATE_OP_SAVE, (rnet_u8)slot, 0, 0) != 0) return 1; g_np.xfer = NP_XFER_SAVE_COORD; @@ -1118,9 +1123,6 @@ int psx_netplay_request_load(int slot) uint32_t size = 0, crc = 0; if (!psx_netplay_active() || !rnet_session_is_running(g_np.session)) return 0; - printf("psxrecomp: netplay savestates are disabled\n"); - fflush(stdout); - return 1; if (g_np.local_slot != 0) return 1; if (np_xfer_busy() || !g_np.mc_sync_done) diff --git a/runtime/src/savestate.c b/runtime/src/savestate.c index b04e4ac0a..58ea9436a 100644 --- a/runtime/src/savestate.c +++ b/runtime/src/savestate.c @@ -136,9 +136,16 @@ int savestate_write_slot(int slot, const void* data, size_t size) { return 1; } -static int netplay_savestate_blocked(void) { +/* User APIs during netplay: guests cannot initiate; host must use + * psx_netplay_request_* so peers hash-probe and sync over STATE_*. */ +static int netplay_user_blocked(void) { if (!psx_netplay_active()) return 0; - fprintf(stderr, "savestate: disabled during netplay\n"); + if (!psx_netplay_is_host()) { + fprintf(stderr, "savestate: netplay guest cannot save/load (host-only)\n"); + return 1; + } + fprintf(stderr, + "savestate: during netplay use host Shift+F / F (synced path)\n"); return 1; } @@ -171,22 +178,22 @@ static int request_load_inner(int slot) { } int savestate_request_save(int slot) { - if (netplay_savestate_blocked()) return 0; + if (netplay_user_blocked()) return 0; return request_save_inner(slot); } int savestate_request_load(int slot) { - if (netplay_savestate_blocked()) return 0; + if (netplay_user_blocked()) return 0; return request_load_inner(slot); } int savestate_request_save_protocol(int slot) { - if (netplay_savestate_blocked()) return 0; + /* Follow-host sync: guests must write the host-authoritative .pst. */ return request_save_inner(slot); } int savestate_request_load_protocol(int slot) { - if (netplay_savestate_blocked()) return 0; + /* Follow-host sync: guests must apply the host-authoritative .pst. */ return request_load_inner(slot); } diff --git a/runtime/src/sio.c b/runtime/src/sio.c index cd79f833b..0a1ea7498 100644 --- a/runtime/src/sio.c +++ b/runtime/src/sio.c @@ -768,6 +768,18 @@ int sio_get_multitap_port(void) { #endif } +int sio_pad_on_multitap(int logical_slot) { +#if PSX_MAX_PLAYERS >= 5 + if (!sio_multitap_active()) return 0; + if (logical_slot < 0 || logical_slot >= PSX_MAX_PLAYERS) return 0; + /* Opposite-port lone pad may stay DualShock; every tap slot is digital. */ + return (logical_slot == mtap_standalone_logical()) ? 0 : 1; +#else + (void)logical_slot; + return 0; +#endif +} + void sio_connect_pad(int slot) { if (slot >= 0 && slot < PSX_MAX_PLAYERS) pad_connected |= (uint8_t)(1u << slot); @@ -781,6 +793,7 @@ void sio_set_pad_connected(int slot, int connected) { void sio_set_pad_config_capable(int slot, int capable) { if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; + if (sio_pad_on_multitap(slot)) capable = 0; pad_supports_config[slot] = capable ? 1 : 0; /* A plain digital pad can never be in config mode; clear any stale latch so * the next poll reports the digital id (0x41), not the config id (0xF3). */ @@ -803,6 +816,10 @@ void sio_set_pad_state_slot(int slot, uint16_t buttons) { void sio_set_pad_analog(int slot, int enabled, uint8_t lx, uint8_t ly, uint8_t rx, uint8_t ry) { if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; + if (sio_pad_on_multitap(slot)) { + enabled = 0; + lx = ly = rx = ry = 0x80; + } pad_analog[slot] = enabled ? 1 : 0; pad_type_req[slot] = -1; /* explicit set supersedes any pending request */ pad_stick[slot][0] = lx; pad_stick[slot][1] = ly; @@ -821,6 +838,7 @@ void sio_set_pad_sticks(int slot, uint8_t lx, uint8_t ly, uint8_t rx, uint8_t ry * can never split a poll or a config handshake. A no-op if already that type. */ void sio_request_pad_type(int slot, int analog) { if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; + if (sio_pad_on_multitap(slot)) analog = 0; int want = analog ? 1 : 0; pad_type_req[slot] = (pad_analog[slot] == want) ? -1 : (int8_t)want; } From 88290fa168d475e711277cbaf817acd9c16a2273 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Fri, 24 Jul 2026 20:13:15 -0400 Subject: [PATCH 25/38] delay sync patches --- lib/recomp-net | 2 +- runtime/include/psx_lobby_client.h | 1 + runtime/include/psx_netplay.h | 1 + runtime/src/main.cpp | 25 +++++++++++++++++ runtime/src/psx_lobby_client.c | 4 ++- runtime/src/psx_netplay.c | 43 ++++++++++++++++++++++-------- 6 files changed, 63 insertions(+), 13 deletions(-) diff --git a/lib/recomp-net b/lib/recomp-net index 5336f24b6..2df4d54f1 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit 5336f24b6d31499c95983fb6753d3de91d5913d6 +Subproject commit 2df4d54f1d619d4bd4425135e0f37cc6b4cb2710 diff --git a/runtime/include/psx_lobby_client.h b/runtime/include/psx_lobby_client.h index 66d83ef28..341312eaa 100644 --- a/runtime/include/psx_lobby_client.h +++ b/runtime/include/psx_lobby_client.h @@ -50,6 +50,7 @@ typedef struct PsxLobbyMatchCaps { int auto_skip_fmv; /* 0/1 */ int input_delay; /* recomp-net delay frames */ int force_input_relay; /* 0/1 — server input relay (vs P2P) */ + int force_turn; /* 0/1 — ICE relay-only (Force TURN for UDP) */ char language[PSX_LOBBY_LANG_LEN]; } PsxLobbyMatchCaps; diff --git a/runtime/include/psx_netplay.h b/runtime/include/psx_netplay.h index 13191f3b7..d568338e3 100644 --- a/runtime/include/psx_netplay.h +++ b/runtime/include/psx_netplay.h @@ -51,6 +51,7 @@ typedef struct PsxNetplayConfig { int input_player; /* host device index; -1 = auto */ int input_delay; int force_input_relay; /* 1 = lobby-server UDP input relay */ + int force_turn; /* 1 = ICE relay-only (Force TURN for UDP) */ uint32_t session_id; char bind_hostport[64]; char peer_hostport[64]; diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 4d0a1216c..c66c96b9c 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -4016,6 +4016,7 @@ namespace { RecompLauncherCNetplayLaunch g_lnch_pending_direct_launch{}; int g_lnch_lobby_input_delay = 2; int g_lnch_force_input_relay = 0; + int g_lnch_force_turn = 0; int g_lnch_host_max_slots = 2; /* Delay-sync READY/START waits for every seat in slot_count. Use seated @@ -4817,6 +4818,7 @@ namespace { if (caps.input_delay < 2) caps.input_delay = 2; if (caps.input_delay > 20) caps.input_delay = 20; caps.force_input_relay = g_lnch_force_input_relay != 0; + caps.force_turn = g_lnch_force_turn != 0; return caps; } @@ -4831,6 +4833,7 @@ namespace { if (caps.input_delay < 2) caps.input_delay = 2; if (caps.input_delay > 20) caps.input_delay = 20; caps.force_input_relay = g_lnch_force_input_relay != 0; + caps.force_turn = g_lnch_force_turn != 0; (void)psx_lobby_set_match_caps(&caps); } @@ -4867,6 +4870,21 @@ namespace { ae_np_push_match_caps(nullptr); return 0; } + int ae_np_force_turn_get(void*) { + if (!g_lnch_hosting_lan && !g_lnch_joined_lan) { + const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); + if (caps && caps->valid) + return caps->force_turn ? 1 : 0; + } + return g_lnch_force_turn; + } + int ae_np_force_turn_set(void*, int force) { + if (g_lnch_hosting_lan || g_lnch_joined_lan) + return 0; /* LAN/Direct IP does not use ICE TURN */ + g_lnch_force_turn = force ? 1 : 0; + ae_np_push_match_caps(nullptr); + return 0; + } /* Seat ceiling for the active room (listing / LOBBY UI). 0 if unknown. */ int ae_np_lobby_max_slots(void*) { @@ -5884,6 +5902,7 @@ namespace { if (g_lnch_pending_direct_launch.max_slots > kAeLanMaxSlots) g_lnch_pending_direct_launch.max_slots = kAeLanMaxSlots; g_lnch_pending_direct_launch.force_input_relay = 0; + g_lnch_pending_direct_launch.force_turn = 0; g_lnch_pending_direct_launch.player_count = ae_np_lan_occupied(state); if (g_lnch_hosting_lan) { const size_t colon = state.endpoint.rfind(':'); @@ -5977,6 +5996,8 @@ namespace { } out->force_input_relay = (caps && caps->valid && caps->force_input_relay) ? 1 : 0; + out->force_turn = + (caps && caps->valid && caps->force_turn) ? 1 : 0; return 1; } @@ -6018,6 +6039,8 @@ namespace { ae_np_force_input_relay_get, ae_np_force_input_relay_set, ae_np_lobby_max_slots, + ae_np_force_turn_get, + ae_np_force_turn_set, }; } // namespace #endif @@ -7239,6 +7262,7 @@ std::string player_device[PSX_MAX_PLAYERS]; net_cfg.session_id = ls.netplay_launch.session_id; net_cfg.input_delay = ls.netplay_launch.input_delay; net_cfg.force_input_relay = ls.netplay_launch.force_input_relay ? 1 : 0; + net_cfg.force_turn = ls.netplay_launch.force_turn ? 1 : 0; net_cfg.player_count = ls.netplay_launch.player_count; net_cfg.slot_count = ae_np_session_slot_count( ls.netplay_launch.player_count, ls.netplay_launch.max_slots, @@ -8223,6 +8247,7 @@ std::string player_device[PSX_MAX_PLAYERS]; net_cfg.session_id = ls.netplay_launch.session_id; net_cfg.input_delay = ls.netplay_launch.input_delay; net_cfg.force_input_relay = ls.netplay_launch.force_input_relay ? 1 : 0; + net_cfg.force_turn = ls.netplay_launch.force_turn ? 1 : 0; net_cfg.player_count = ls.netplay_launch.player_count; net_cfg.slot_count = ae_np_session_slot_count( ls.netplay_launch.player_count, ls.netplay_launch.max_slots, diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index 0c57276d1..b55a7ab9b 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -345,6 +345,7 @@ static void parse_match_caps_object(const char *obj, PsxLobbyMatchCaps *out) if (out->input_delay < 0) out->input_delay = 0; if (out->input_delay > 16) out->input_delay = 16; out->force_input_relay = json_get_bool(obj, "force_input_relay", 0); + out->force_turn = json_get_bool(obj, "force_turn", 0); json_get_str(obj, "language", out->language, sizeof(out->language)); out->valid = 1; } @@ -374,7 +375,7 @@ static int append_match_caps_json(char *dst, size_t dst_cap, const PsxLobbyMatch ",\"match_caps\":{\"v\":1,\"aspect_num\":%d,\"aspect_den\":%d," "\"turbo_loads\":%s,\"bios_hle\":%s,\"fast_boot\":%s," "\"auto_skip_fmv\":%s,\"input_delay\":%d,\"force_input_relay\":%s," - "\"language\":\"%s\"}", + "\"force_turn\":%s,\"language\":\"%s\"}", caps->aspect_num, caps->aspect_den, caps->turbo_loads ? "true" : "false", caps->bios_hle ? "true" : "false", @@ -382,6 +383,7 @@ static int append_match_caps_json(char *dst, size_t dst_cap, const PsxLobbyMatch caps->auto_skip_fmv ? "true" : "false", caps->input_delay, caps->force_input_relay ? "true" : "false", + caps->force_turn ? "true" : "false", lang); } diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index 28bb58cf2..e7004f4cd 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -54,6 +54,7 @@ void psx_netplay_config_defaults(PsxNetplayConfig *cfg) cfg->input_player = -1; cfg->input_delay = 2; cfg->force_input_relay = 0; + cfg->force_turn = 0; cfg->session_id = 1; strncpy(cfg->bind_hostport, "0.0.0.0:7777", sizeof(cfg->bind_hostport) - 1); cfg->peer_hostport[0] = '\0'; @@ -1007,7 +1008,9 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) rnet_config_init_defaults(&rcfg); rcfg.slot_count = (rnet_u8)slots; rcfg.local_slot = (rnet_u8)local; - rcfg.input_delay = (rnet_u8)(cfg->input_delay < 0 ? 0 : (cfg->input_delay > 16 ? 16 : cfg->input_delay)); + /* Max delay 20 matches RNET_MAX_BUNDLE 21 (neutral prefix + tip). */ + rcfg.input_delay = (rnet_u8)(cfg->input_delay < 0 ? 0 + : (cfg->input_delay > 20 ? 20 : cfg->input_delay)); rcfg.session_id = cfg->session_id ? cfg->session_id : 1u; /* Host resolves auto (-1) before start; accept 0..PSX_MAX_PLAYERS-1. */ @@ -1086,8 +1089,11 @@ void psx_netplay_bind_guest_saves(void) #define PSX_STARVATION_EXIT_DEFAULT 3 #define PSX_STARVATION_EXIT_HR_LEAD_DEFAULT 0 #define PSX_STARVATION_GRACE_TICKS 60 -#define PSX_STARVATION_RECOVERY_BURST 16 -#define PSX_CATCHUP_CAP 16 +/* Default 0: after starvation clears, resume ~1 sim/wall frame and let + * remote_lead rebuild toward D instead of a turbo recovery burst. + * Override: PSX_NET_STARVATION_RECOVERY_BURST / PSX_NET_CATCHUP_CAP. */ +#define PSX_STARVATION_RECOVERY_BURST_DEFAULT 0 +#define PSX_CATCHUP_CAP_DEFAULT 0 static struct { int latched; @@ -1321,13 +1327,23 @@ int psx_netplay_poll_admit(void) if (np_try_admit_gameplay()) { g_starv.enter_run = 0; if (g_starv.just_cleared) { + int burst = np_starv_env_int("PSX_NET_STARVATION_RECOVERY_BURST", + PSX_STARVATION_RECOVERY_BURST_DEFAULT); g_starv.just_cleared = 0; - g_starv.recovery_amount = PSX_STARVATION_RECOVERY_BURST; - fprintf(stderr, - "psxrecomp: delay_sync_starvation cleared sim=%u lead=%d " - "D=%d — recovery burst %d\n", - (unsigned)psx_netplay_sim_tick(), psx_netplay_remote_lead(), - psx_netplay_input_delay(), PSX_STARVATION_RECOVERY_BURST); + g_starv.recovery_amount = burst; + if (burst > 0) { + fprintf(stderr, + "psxrecomp: delay_sync_starvation cleared sim=%u lead=%d " + "D=%d — recovery burst %d\n", + (unsigned)psx_netplay_sim_tick(), psx_netplay_remote_lead(), + psx_netplay_input_delay(), burst); + } else { + fprintf(stderr, + "psxrecomp: delay_sync_starvation cleared sim=%u lead=%d " + "D=%d — resume 1:1 (rebuild input buffer)\n", + (unsigned)psx_netplay_sim_tick(), psx_netplay_remote_lead(), + psx_netplay_input_delay()); + } } return 1; } @@ -1384,21 +1400,26 @@ int psx_netplay_catchup_budget(void) int delay; int extra; int budget; + int cap; if (!psx_netplay_active()) return 0; + cap = np_starv_env_int("PSX_NET_CATCHUP_CAP", PSX_CATCHUP_CAP_DEFAULT); + if (cap <= 0 && g_starv.recovery_amount <= 0) + return 0; lead = psx_netplay_remote_lead(); delay = psx_netplay_input_delay(); if (delay < 0) delay = 0; + /* Only spend surplus above D; keep the delay runway intact. */ extra = lead - delay; if (extra < 0) extra = 0; budget = extra; if (g_starv.recovery_amount > budget) budget = g_starv.recovery_amount; - if (budget > PSX_CATCHUP_CAP) - budget = PSX_CATCHUP_CAP; + if (budget > cap) + budget = cap; return budget; } From ab99a6acac7c14453a89e6dca29c482f633b105c Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Fri, 24 Jul 2026 20:13:26 -0400 Subject: [PATCH 26/38] delay sync patches --- runtime/include/psx_lobby_client.h | 1 + runtime/include/psx_netplay.h | 1 + runtime/src/main.cpp | 25 +++++++++++++++++++++++++ runtime/src/psx_lobby_client.c | 4 +++- runtime/src/psx_netplay.c | 1 + 5 files changed, 31 insertions(+), 1 deletion(-) diff --git a/runtime/include/psx_lobby_client.h b/runtime/include/psx_lobby_client.h index 66d83ef28..341312eaa 100644 --- a/runtime/include/psx_lobby_client.h +++ b/runtime/include/psx_lobby_client.h @@ -50,6 +50,7 @@ typedef struct PsxLobbyMatchCaps { int auto_skip_fmv; /* 0/1 */ int input_delay; /* recomp-net delay frames */ int force_input_relay; /* 0/1 — server input relay (vs P2P) */ + int force_turn; /* 0/1 — ICE relay-only (Force TURN for UDP) */ char language[PSX_LOBBY_LANG_LEN]; } PsxLobbyMatchCaps; diff --git a/runtime/include/psx_netplay.h b/runtime/include/psx_netplay.h index 13191f3b7..d568338e3 100644 --- a/runtime/include/psx_netplay.h +++ b/runtime/include/psx_netplay.h @@ -51,6 +51,7 @@ typedef struct PsxNetplayConfig { int input_player; /* host device index; -1 = auto */ int input_delay; int force_input_relay; /* 1 = lobby-server UDP input relay */ + int force_turn; /* 1 = ICE relay-only (Force TURN for UDP) */ uint32_t session_id; char bind_hostport[64]; char peer_hostport[64]; diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 1d9606eb4..3e03fb183 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -4016,6 +4016,7 @@ namespace { RecompLauncherCNetplayLaunch g_lnch_pending_direct_launch{}; int g_lnch_lobby_input_delay = 2; int g_lnch_force_input_relay = 0; + int g_lnch_force_turn = 0; int g_lnch_host_max_slots = 2; /* Delay-sync READY/START waits for every seat in slot_count. Use seated @@ -4817,6 +4818,7 @@ namespace { if (caps.input_delay < 2) caps.input_delay = 2; if (caps.input_delay > 20) caps.input_delay = 20; caps.force_input_relay = g_lnch_force_input_relay != 0; + caps.force_turn = g_lnch_force_turn != 0; return caps; } @@ -4831,6 +4833,7 @@ namespace { if (caps.input_delay < 2) caps.input_delay = 2; if (caps.input_delay > 20) caps.input_delay = 20; caps.force_input_relay = g_lnch_force_input_relay != 0; + caps.force_turn = g_lnch_force_turn != 0; (void)psx_lobby_set_match_caps(&caps); } @@ -4867,6 +4870,21 @@ namespace { ae_np_push_match_caps(nullptr); return 0; } + int ae_np_force_turn_get(void*) { + if (!g_lnch_hosting_lan && !g_lnch_joined_lan) { + const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); + if (caps && caps->valid) + return caps->force_turn ? 1 : 0; + } + return g_lnch_force_turn; + } + int ae_np_force_turn_set(void*, int force) { + if (g_lnch_hosting_lan || g_lnch_joined_lan) + return 0; /* LAN/Direct IP does not use ICE TURN */ + g_lnch_force_turn = force ? 1 : 0; + ae_np_push_match_caps(nullptr); + return 0; + } /* Seat ceiling for the active room (listing / LOBBY UI). 0 if unknown. */ int ae_np_lobby_max_slots(void*) { @@ -5884,6 +5902,7 @@ namespace { if (g_lnch_pending_direct_launch.max_slots > kAeLanMaxSlots) g_lnch_pending_direct_launch.max_slots = kAeLanMaxSlots; g_lnch_pending_direct_launch.force_input_relay = 0; + g_lnch_pending_direct_launch.force_turn = 0; g_lnch_pending_direct_launch.player_count = ae_np_lan_occupied(state); if (g_lnch_hosting_lan) { const size_t colon = state.endpoint.rfind(':'); @@ -5977,6 +5996,8 @@ namespace { } out->force_input_relay = (caps && caps->valid && caps->force_input_relay) ? 1 : 0; + out->force_turn = + (caps && caps->valid && caps->force_turn) ? 1 : 0; return 1; } @@ -6018,6 +6039,8 @@ namespace { ae_np_force_input_relay_get, ae_np_force_input_relay_set, ae_np_lobby_max_slots, + ae_np_force_turn_get, + ae_np_force_turn_set, }; } // namespace #endif @@ -7239,6 +7262,7 @@ std::string player_device[PSX_MAX_PLAYERS]; net_cfg.session_id = ls.netplay_launch.session_id; net_cfg.input_delay = ls.netplay_launch.input_delay; net_cfg.force_input_relay = ls.netplay_launch.force_input_relay ? 1 : 0; + net_cfg.force_turn = ls.netplay_launch.force_turn ? 1 : 0; net_cfg.player_count = ls.netplay_launch.player_count; net_cfg.slot_count = ae_np_session_slot_count( ls.netplay_launch.player_count, ls.netplay_launch.max_slots, @@ -8223,6 +8247,7 @@ std::string player_device[PSX_MAX_PLAYERS]; net_cfg.session_id = ls.netplay_launch.session_id; net_cfg.input_delay = ls.netplay_launch.input_delay; net_cfg.force_input_relay = ls.netplay_launch.force_input_relay ? 1 : 0; + net_cfg.force_turn = ls.netplay_launch.force_turn ? 1 : 0; net_cfg.player_count = ls.netplay_launch.player_count; net_cfg.slot_count = ae_np_session_slot_count( ls.netplay_launch.player_count, ls.netplay_launch.max_slots, diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index 0c57276d1..b55a7ab9b 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -345,6 +345,7 @@ static void parse_match_caps_object(const char *obj, PsxLobbyMatchCaps *out) if (out->input_delay < 0) out->input_delay = 0; if (out->input_delay > 16) out->input_delay = 16; out->force_input_relay = json_get_bool(obj, "force_input_relay", 0); + out->force_turn = json_get_bool(obj, "force_turn", 0); json_get_str(obj, "language", out->language, sizeof(out->language)); out->valid = 1; } @@ -374,7 +375,7 @@ static int append_match_caps_json(char *dst, size_t dst_cap, const PsxLobbyMatch ",\"match_caps\":{\"v\":1,\"aspect_num\":%d,\"aspect_den\":%d," "\"turbo_loads\":%s,\"bios_hle\":%s,\"fast_boot\":%s," "\"auto_skip_fmv\":%s,\"input_delay\":%d,\"force_input_relay\":%s," - "\"language\":\"%s\"}", + "\"force_turn\":%s,\"language\":\"%s\"}", caps->aspect_num, caps->aspect_den, caps->turbo_loads ? "true" : "false", caps->bios_hle ? "true" : "false", @@ -382,6 +383,7 @@ static int append_match_caps_json(char *dst, size_t dst_cap, const PsxLobbyMatch caps->auto_skip_fmv ? "true" : "false", caps->input_delay, caps->force_input_relay ? "true" : "false", + caps->force_turn ? "true" : "false", lang); } diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index 28bb58cf2..d8663f16a 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -54,6 +54,7 @@ void psx_netplay_config_defaults(PsxNetplayConfig *cfg) cfg->input_player = -1; cfg->input_delay = 2; cfg->force_input_relay = 0; + cfg->force_turn = 0; cfg->session_id = 1; strncpy(cfg->bind_hostport, "0.0.0.0:7777", sizeof(cfg->bind_hostport) - 1); cfg->peer_hostport[0] = '\0'; From f864d854800a0e74d6e043e1e9bfff6395dc82c8 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Fri, 24 Jul 2026 20:33:20 -0400 Subject: [PATCH 27/38] Update recomp-net --- lib/recomp-net | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/recomp-net b/lib/recomp-net index 2df4d54f1..b389f167a 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit 2df4d54f1d619d4bd4425135e0f37cc6b4cb2710 +Subproject commit b389f167ad7201db051ceb1237341496183ed002 From 5f00ee5d999f20785990307c81b196d9956962c7 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Fri, 24 Jul 2026 20:36:05 -0400 Subject: [PATCH 28/38] mingw netplay patch & build --- runtime/src/lobby_ws/rnet_ws.c | 22 ++++++++++++++++++++-- runtime/src/psx_lobby_client.c | 15 +++++++++++++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/runtime/src/lobby_ws/rnet_ws.c b/runtime/src/lobby_ws/rnet_ws.c index 6e037733e..bb2b1f8c2 100644 --- a/runtime/src/lobby_ws/rnet_ws.c +++ b/runtime/src/lobby_ws/rnet_ws.c @@ -13,6 +13,24 @@ #include #endif +static int socket_would_block(void) +{ +#if defined(_WIN32) + return WSAGetLastError() == WSAEWOULDBLOCK; +#else + return errno == EAGAIN || errno == EWOULDBLOCK; +#endif +} + +static int socket_interrupted(void) +{ +#if defined(_WIN32) + return WSAGetLastError() == WSAEINTR; +#else + return errno == EINTR; +#endif +} + static const char *B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; @@ -89,7 +107,7 @@ static int send_all(int fd, const void *buf, size_t len) ssize_t n = send(fd, p + sent, len - sent, 0); #endif if (n < 0) { - if (errno == EINTR) { + if (socket_interrupted()) { continue; } return -1; @@ -177,7 +195,7 @@ int rnet_ws_read_text(int fd, char *buf, size_t cap, int *closed) return -1; } if (n < 0) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { + if (socket_would_block()) { return 0; } return -1; diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index b55a7ab9b..896bbd507 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -76,6 +76,17 @@ void psx_lobby_clear_launch_pending(void) {} #include #endif +/* Winsock sets WSAGetLastError(), not errno — bare errno checks drop the + * non-blocking WS handshake on Windows (list/create look permanently dead). */ +static int socket_would_block(void) +{ +#if defined(_WIN32) + return WSAGetLastError() == WSAEWOULDBLOCK; +#else + return errno == EAGAIN || errno == EWOULDBLOCK; +#endif +} + typedef struct { int fd; int connected; @@ -1026,7 +1037,7 @@ void psx_lobby_pump(void) if (!g_lc.handshake_done) { n = recv(g_lc.fd, buf, sizeof(buf), 0); if (n < 0) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { + if (socket_would_block()) { return; } psx_lobby_disconnect(); @@ -1080,7 +1091,7 @@ void psx_lobby_pump(void) uint8_t peek[1]; n = recv(g_lc.fd, (char *)peek, 1, MSG_PEEK); if (n < 0) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { + if (socket_would_block()) { break; } psx_lobby_disconnect(); From 59e8f8a63efd0f25b123b1df8c3037c33b0dfc46 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Fri, 24 Jul 2026 20:40:00 -0400 Subject: [PATCH 29/38] mingw netplay patch & build --- lib/recomp-net | 2 +- runtime/src/lobby_ws/rnet_ws.c | 22 ++++++++++++++++++++-- runtime/src/psx_lobby_client.c | 15 +++++++++++++-- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/lib/recomp-net b/lib/recomp-net index 2df4d54f1..6c8eee98b 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit 2df4d54f1d619d4bd4425135e0f37cc6b4cb2710 +Subproject commit 6c8eee98b8b87abe5dca0b6b5588861d4721ca85 diff --git a/runtime/src/lobby_ws/rnet_ws.c b/runtime/src/lobby_ws/rnet_ws.c index 6e037733e..bb2b1f8c2 100644 --- a/runtime/src/lobby_ws/rnet_ws.c +++ b/runtime/src/lobby_ws/rnet_ws.c @@ -13,6 +13,24 @@ #include #endif +static int socket_would_block(void) +{ +#if defined(_WIN32) + return WSAGetLastError() == WSAEWOULDBLOCK; +#else + return errno == EAGAIN || errno == EWOULDBLOCK; +#endif +} + +static int socket_interrupted(void) +{ +#if defined(_WIN32) + return WSAGetLastError() == WSAEINTR; +#else + return errno == EINTR; +#endif +} + static const char *B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; @@ -89,7 +107,7 @@ static int send_all(int fd, const void *buf, size_t len) ssize_t n = send(fd, p + sent, len - sent, 0); #endif if (n < 0) { - if (errno == EINTR) { + if (socket_interrupted()) { continue; } return -1; @@ -177,7 +195,7 @@ int rnet_ws_read_text(int fd, char *buf, size_t cap, int *closed) return -1; } if (n < 0) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { + if (socket_would_block()) { return 0; } return -1; diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index b55a7ab9b..896bbd507 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -76,6 +76,17 @@ void psx_lobby_clear_launch_pending(void) {} #include #endif +/* Winsock sets WSAGetLastError(), not errno — bare errno checks drop the + * non-blocking WS handshake on Windows (list/create look permanently dead). */ +static int socket_would_block(void) +{ +#if defined(_WIN32) + return WSAGetLastError() == WSAEWOULDBLOCK; +#else + return errno == EAGAIN || errno == EWOULDBLOCK; +#endif +} + typedef struct { int fd; int connected; @@ -1026,7 +1037,7 @@ void psx_lobby_pump(void) if (!g_lc.handshake_done) { n = recv(g_lc.fd, buf, sizeof(buf), 0); if (n < 0) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { + if (socket_would_block()) { return; } psx_lobby_disconnect(); @@ -1080,7 +1091,7 @@ void psx_lobby_pump(void) uint8_t peek[1]; n = recv(g_lc.fd, (char *)peek, 1, MSG_PEEK); if (n < 0) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { + if (socket_would_block()) { break; } psx_lobby_disconnect(); From fbad0be1b95e6fee0c41abb101909a904d632a85 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Mon, 27 Jul 2026 11:03:10 -0400 Subject: [PATCH 30/38] launcher performance, load state performance, game launch performance it stops checking the entire PSX disc for full hash check every time the launcher runs, assuming the user's hash checked it when selecting the disc the first time and saving it to settings, so it just validates the file name and size instead, then it shares lifecycle with the launcher and the game to load the game faster, and several optimizations were made to loadstate paths which I think in the end relied on reanchoring host timings to the restored clock that came with the state --- runtime/include/boot_state.h | 14 +- runtime/include/cdrom.h | 12 + runtime/include/dirty_ram_interp.h | 4 + runtime/include/gpu.h | 5 +- runtime/include/gpu_gl_renderer.h | 9 + runtime/include/interrupts.h | 22 + runtime/include/load_accel.h | 2 + runtime/include/overlay_loader.h | 6 + runtime/include/pst_wire.h | 9 + runtime/include/psx_cycles.h | 20 +- runtime/include/psx_lobby_client.h | 41 ++ runtime/include/psx_netplay.h | 13 +- runtime/runtime.cmake | 8 + runtime/src/boot_state.c | 271 +++++++-- runtime/src/cdrom.c | 59 ++ runtime/src/gpu.c | 23 +- runtime/src/gpu_gl_renderer.c | 35 ++ runtime/src/gpu_sw_renderer.c | 80 +++ runtime/src/interrupts.c | 46 ++ runtime/src/load_accel.c | 5 + runtime/src/main.cpp | 930 +++++++++++++++++++++++------ runtime/src/memory.c | 14 + runtime/src/overlay_loader.c | 50 +- runtime/src/psx_cycles.c | 31 +- runtime/src/psx_lobby_client.c | 410 ++++++++++++- runtime/src/psx_netplay.c | 652 +++++++++++++++++++- runtime/src/savestate.c | 54 +- 27 files changed, 2504 insertions(+), 321 deletions(-) diff --git a/runtime/include/boot_state.h b/runtime/include/boot_state.h index fae60d572..78ec3a11b 100644 --- a/runtime/include/boot_state.h +++ b/runtime/include/boot_state.h @@ -36,8 +36,13 @@ extern "C" { #define BOOT_STATE_MAGIC 0x50535842u /* "PSXB" */ /* v1 = incomplete RAM-only; v2 = full machine but host-struct memcpy (padding); - * v3 = little-endian field wire (portable Win/Linux/macOS ARM). */ -#define BOOT_STATE_VERSION 3u + * v3 = little-endian field wire (portable Win/Linux/macOS ARM); + * v4 = v3 + optional zlib on large sections (section pad bit0 = compressed). */ +#define BOOT_STATE_VERSION 4u +/* Older readers reject v4; load still accepts v3 uncompressed blobs. */ +#define BOOT_STATE_VERSION_MIN_READ 3u +/* Section pad bit0: payload is u32 LE uncompressed_len + zlib deflate bytes. */ +#define BOOT_STATE_SEC_ZLIB 1u /* * On-disk header (v3): nine little-endian uint32 fields at offset 0 (36 bytes), @@ -61,11 +66,12 @@ typedef struct { #define BOOT_STATE_HEADER_WIRE_BYTES 36u /* - * Section stream (v3): section_count records, each laid out as + * Section stream (v3/v4): section_count records, each laid out as * uint32_t tag; LE (one of BS_SEC_*) - * uint32_t pad; LE 0 + * uint32_t pad; LE flags (v3: 0; v4: BOOT_STATE_SEC_ZLIB optional) * uint64_t len; LE payload byte count * uint8_t payload[len]; (module payloads are LE field wires too) + * When BOOT_STATE_SEC_ZLIB is set, payload = u32 LE raw_len + zlib(raw). * An unknown tag, a length mismatch, or a missing required section on load is a * hard reject (incomplete restore is never allowed) -> normal boot + recapture. */ diff --git a/runtime/include/cdrom.h b/runtime/include/cdrom.h index 388ae9c9c..8e82d60eb 100644 --- a/runtime/include/cdrom.h +++ b/runtime/include/cdrom.h @@ -70,6 +70,18 @@ int cdrom_load_in_progress(void); * bridge used by cdrom_load_in_progress(). Diagnostics only. */ int cdrom_data_read_active(void); +/* After savestate restore: clamp long CD second-response / read-start delays + * and arm a short boost window so post-load ReadTOC/seek/Init waits do not + * freeze the picture for ~1s+. Completions still fire (IRQs preserved). */ +void cdrom_accelerate_after_savestate(void); +/* Call once per host vblank while the boost window is armed. */ +void cdrom_savestate_boost_vblank(void); +/* Non-zero while boost is armed AND a CD wait is outstanding (pending + * second response or non-XA read). Lets turbo_loads unpace those waits. */ +int cdrom_savestate_cd_wait_active(void); +/* Remaining boost vblanks (0 when idle). Diagnostics / post-load probe. */ +int cdrom_savestate_boost_vblanks_remaining(void); + /* MMIO read/write (0x1F801800-0x1F801803) */ uint32_t cdrom_read(uint32_t addr); void cdrom_write(uint32_t addr, uint32_t value); diff --git a/runtime/include/dirty_ram_interp.h b/runtime/include/dirty_ram_interp.h index d1435465b..accdd1675 100644 --- a/runtime/include/dirty_ram_interp.h +++ b/runtime/include/dirty_ram_interp.h @@ -111,6 +111,10 @@ static inline int overlay_cache_window_contains(uint32_t phys) { uint32_t dirty_ram_get_bitmap(void); uint32_t dirty_ram_get_bitmap_word(uint32_t word_index); uint32_t dirty_ram_get_bitmap_word_count(void); +void dirty_ram_set_bitmap_words(const uint32_t* words, uint32_t count); +/* After bulk RAM restore (savestate): bump overlay page gens + lazy-miss epoch + * so native overlays re-hash against restored bytes. */ +void overlay_watch_invalidate_after_ram_restore(void); void dirty_ram_mark_executable_range(uint32_t phys, uint32_t len); void dirty_ram_register_text_image(uint32_t phys_lo, const uint8_t *bytes, uint32_t len); diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index 355b83449..d6761a085 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -40,8 +40,9 @@ void gpu_display_pixel_rgb(const GpuDisplayInfo* di, uint32_t x, uint32_t y, uint8_t* r, uint8_t* g, uint8_t* b); uint32_t gpu_display_pixel_argb(const GpuDisplayInfo* di, uint32_t x, uint32_t y); /* Depth24: RGB columns covered by CPU→VRAM uploads since the last reset. - * Returns crtc_w when unknown / full coverage. Present uses this to blank a - * trailing margin without shrinking the CRTC-derived width globally. */ + * Returns 0 when no uploads yet, crtc_w when coverage is full/unknown-beyond, + * else the covered RGB width. Present black-fills [limit, crtc_w) without + * shrinking the CRTC-derived draw width (avoids a flickering black pillar). */ uint32_t gpu_depth24_rgb_limit(uint32_t display_x, uint32_t crtc_w); void gpu_depth24_upload_span_reset(void); /* GP1(06h)/GP1(07h)/GP1(08h) fields for debug (gpu_state). */ diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 7522d8464..8cf75021f 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -64,6 +64,15 @@ void gl_renderer_flush_cpu_uploads(void); * reloaded identical frame still reaches the window (double/triple buffer). */ void gl_renderer_invalidate_present(void); +/* Post-savestate freeze probe: skip/swap/dirty-mark counters (GL present path). + * take() returns deltas since the previous take/reset. Safe no-ops when GL is + * inactive. rect_dirty tests the current present-tile dirty bits. */ +void gl_renderer_present_probe_reset(void); +void gl_renderer_present_probe_take(uint64_t *skip_delta, uint64_t *swap_delta, + uint64_t *dirty_mark_delta, + int *force_remaining); +int gl_renderer_present_rect_dirty(int disp_x, int disp_y, int w, int h); + /* THE present path for 15-bit frames: blit the display region straight from * the authoritative VRAM FBO into a letterboxed rect (no readback). * Deterministic — used for every 15-bit frame. linear = filter on scale. diff --git a/runtime/include/interrupts.h b/runtime/include/interrupts.h index 362664c2f..31ae91777 100644 --- a/runtime/include/interrupts.h +++ b/runtime/include/interrupts.h @@ -24,6 +24,12 @@ struct CPUState; void interrupts_init(void); +/* Save-state restore: re-anchor host-only IRQ pacing after psx_cycle_count is + * overwritten from a snapshot. Clears absolute-cycle cooldowns / deferred + * switches that would otherwise strand delivery until the restored clock + * catches up to the pre-load host timeline (warm-reload picture freeze). */ +void interrupts_resync_after_restore(void); + /* Central IRQ-raise choke point: sets the I_STAT bit AND records the raise edge * into the always-on device-event ring (device_trace) with the guest cycle. * Every hardware source (VBLANK/GPU/CDROM/DMA/timers/SIO/SPU) funnels its raise @@ -43,6 +49,17 @@ int psx_interrupt_delivery_needed(const struct CPUState* cpu); void psx_interrupt_delivery_diag(uint64_t *need_defer, uint64_t *need_irq, uint64_t *skip_none, uint64_t *skip_sr, uint64_t *skip_cooldown, uint64_t *skip_nested); + +/* Hot-path counters for psx_check_interrupts (monotonic). Any out_* may be NULL. + * entry = every call + * fast_sr = early return: pending I_STAT but IEc/IM2 clear + * fast_none = early return: nothing pending + * mid = total_checks (past fast paths; toward / into deliver eval) + * eval = reached irq_deliver_eval label + * irq_deliv = g_irq_deliver_count (any HW IRQ exception taken) */ +void psx_interrupt_check_path_diag(uint64_t *entry, uint64_t *fast_sr, + uint64_t *fast_none, uint64_t *mid, + uint64_t *eval, uint64_t *irq_deliv); /* Interrupt check with the compiled guest PC to resume if a game-installed * handler later RFEs to the sentinel outside the synchronous host window. */ void psx_check_interrupts_at(struct CPUState* cpu, uint32_t resume_pc); @@ -70,6 +87,11 @@ uint32_t cycles_to_next_event(void); /* Query whether we are currently inside an exception handler dispatch. */ int psx_get_in_exception(void); +/* Most recent block-leader IRQ-check guest PC / compiled resume latch. + * Used by the post-savestate freeze probe (vblank-time "where was the game"). */ +uint32_t psx_last_irq_check_pc(void); +uint32_t psx_compiled_irq_resume_pc(void); + /* Snapshot internal counters for the freeze_check diagnostic. Any out_* * pointer may be NULL. All counters are monotonically non-decreasing * (dispatch_count resets each VBlank). */ diff --git a/runtime/include/load_accel.h b/runtime/include/load_accel.h index 526c52257..3610899b7 100644 --- a/runtime/include/load_accel.h +++ b/runtime/include/load_accel.h @@ -31,6 +31,8 @@ void psx_vsync_query_hle_set_horizon_any(int on); void psx_vsync_query_hle_add_event_horizon_site(uint32_t return_pc); void psx_vsync_query_hle_add_extra_event_horizon_site(uint32_t return_pc); void psx_vsync_query_hle_stats_json(char* buf, int cap); +/* Cumulative VSync(-1) event-horizon skips (for post-load probe deltas). */ +void psx_vsync_query_hle_horizon_totals(uint64_t *hits, uint64_t *cycles); #ifdef __cplusplus } diff --git a/runtime/include/overlay_loader.h b/runtime/include/overlay_loader.h index 649e29ab6..3d824a7e0 100644 --- a/runtime/include/overlay_loader.h +++ b/runtime/include/overlay_loader.h @@ -120,6 +120,12 @@ uint64_t overlay_loader_candidate_overflow(void); uint64_t overlay_loader_pair_aliases(void); int overlay_loader_dump_lazy_at(uint32_t addr, char *out, int cap); +/* Overlay CI wrapper early-return attribution (PSX_POST_LOAD_PROBE). */ +void overlay_loader_get_ci_skip_diag(uint64_t *unit, uint64_t *supp, + uint64_t *none, uint64_t *sr, + uint64_t *deliv, uint64_t *enter); +int overlay_loader_call_unit_depth(void); + #ifdef __cplusplus } #endif diff --git a/runtime/include/pst_wire.h b/runtime/include/pst_wire.h index 8f1f840d1..0eb16531a 100644 --- a/runtime/include/pst_wire.h +++ b/runtime/include/pst_wire.h @@ -131,6 +131,11 @@ static inline int pst_w_pod(PstW *w, const void *src, size_t nbytes, size_t elem const uint8_t *s = (const uint8_t *)src; if (!elem || (nbytes % elem) != 0) return 0; if (elem == 1) return pst_w_bytes(w, src, nbytes); +#if defined(__BYTE_ORDER__) && (__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__) + /* Host LE: wire bytes match native POD layout — skip per-element swizzle. */ + if (elem == 2 || elem == 4 || elem == 8) + return pst_w_bytes(w, src, nbytes); +#endif for (size_t off = 0; off < nbytes; off += elem) { if (elem == 2) { uint16_t v; @@ -155,6 +160,10 @@ static inline int pst_r_pod(PstR *r, void *dst, size_t nbytes, size_t elem) { uint8_t *d = (uint8_t *)dst; if (!elem || (nbytes % elem) != 0) return 0; if (elem == 1) return pst_r_bytes(r, dst, nbytes); +#if defined(__BYTE_ORDER__) && (__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__) + if (elem == 2 || elem == 4 || elem == 8) + return pst_r_bytes(r, dst, nbytes); +#endif for (size_t off = 0; off < nbytes; off += elem) { if (elem == 2) { uint16_t v; diff --git a/runtime/include/psx_cycles.h b/runtime/include/psx_cycles.h index a47549118..e86c450bc 100644 --- a/runtime/include/psx_cycles.h +++ b/runtime/include/psx_cycles.h @@ -60,6 +60,14 @@ extern uint32_t g_psx_cyc_batch_limit; * block. Interrupt/MMIO edges still publish the accumulated guest cycles. */ extern int g_psx_cyc_bb_defer; +/* Opt-in advance attribution for PSX_POST_LOAD_PROBE (armed from main). + * Zero when disarmed — hot path pays one predictable branch. */ +extern int g_plp_cycle_diag; +extern uint64_t g_plp_adv_calls; +extern uint32_t g_plp_adv_max_chunk; +extern uint64_t g_plp_adv_sum; +extern uint64_t g_plp_svc_calls; + /* Advance guest time. The common production path is inlined: bump the * counter and only service devices when the next event deadline is due. * Guest-visible timing is unchanged (service_to_now replays exact events). @@ -87,6 +95,11 @@ static inline void psx_advance_cycles(uint32_t cycles) { } #endif if (cycles == 0u) return; + if (g_plp_cycle_diag) { + g_plp_adv_calls++; + g_plp_adv_sum += (uint64_t)cycles; + if (cycles > g_plp_adv_max_chunk) g_plp_adv_max_chunk = cycles; + } #if defined(PSX_COSIM) psx_advance_cycles_slow(cycles); return; @@ -136,8 +149,11 @@ extern uint32_t g_idle_skip_last_pc; extern uint32_t g_idle_skip_last_quantum; /* Save-state restore: re-anchor the deadline device model after psx_cycle_count - * is overwritten from a snapshot. */ -void psx_cycles_resync_after_restore(void); + * is overwritten from a snapshot. Also clears host-only CPU timing residual + * (gte_ts_done / muldiv_ts_done / load-absorb) that is not in the wire format — + * a pre-load absolute deadline past the rewound clock otherwise becomes one + * multi-vblank psx_advance_cycles stall (warm-load picture freeze). */ +void psx_cycles_resync_after_restore(struct CPUState *cpu); /* Soft rematch / session_reboot: zero the guest clock and deadline bookkeeping. * Soft-exit longjmps out of vblank (inside psx_devices_service_to_now) leave diff --git a/runtime/include/psx_lobby_client.h b/runtime/include/psx_lobby_client.h index 341312eaa..0d60e908f 100644 --- a/runtime/include/psx_lobby_client.h +++ b/runtime/include/psx_lobby_client.h @@ -1,6 +1,7 @@ #ifndef PSX_LOBBY_CLIENT_H #define PSX_LOBBY_CLIENT_H +#include #include #ifdef __cplusplus @@ -139,6 +140,46 @@ int psx_lobby_set_match_caps(const PsxLobbyMatchCaps *caps); int psx_lobby_member_count(void); int psx_lobby_member_get(int index, PsxLobbyMember *out); +/* Waiting-room RTT to the lobby host in ms for `slot`, or -1 if unknown. + * Host's own seat is always -1. Guests measure via signal ping; hosts learn + * guest RTT from peer reports. */ +int psx_lobby_member_latency_ms(int slot); + +/* True when member.player_id matches psx_lobby_host_player_id(). + * Prefer this over `slot == 0` — seats can move. */ +int psx_lobby_member_is_host(const PsxLobbyMember *member); + +/* + * ICE signaling relay (MotK WS op:signal). text is SDP/candidate (max 2047). + * send returns 0 if queued/written; poll returns 1 when an inbound signal was + * copied out (LOCAL_* types as emitted by the peer — remap to REMOTE_* before + * rnet_session_push_signal). + */ +int psx_lobby_send_signal(int type, int flag, const char *text); +int psx_lobby_poll_signal(int *type, int *flag, char *text, size_t text_cap); + +/* + * Coturn / ICE credentials minted by the WS lobby + * (`get_turn_credentials` → `turn_credentials`). Valid until disconnect or TTL. + * Strings are stable until the next successful mint or disconnect — safe to + * pass into RNetIceConfig for juice_create. + */ +typedef struct PsxLobbyTurnCredentials { + int valid; /* 1 when ok mint cached and not expired */ + char stun_host[128]; + int stun_port; + char turn_host[128]; + int turn_port; + char username[192]; + char password[128]; + uint32_t ttl_secs; +} PsxLobbyTurnCredentials; + +/* Queue WS get_turn_credentials. Returns 0 if sent/queued. */ +int psx_lobby_request_turn_credentials(void); +/* Non-NULL; valid==0 when unavailable / expired / STUN-only. */ +const PsxLobbyTurnCredentials *psx_lobby_turn_credentials(void); + /* Local ready flag (from last lobby_update matching our player_id). */ int psx_lobby_local_ready(void); /* True when every seated player is ready and player_count >= 2. */ diff --git a/runtime/include/psx_netplay.h b/runtime/include/psx_netplay.h index d568338e3..dcdcf439f 100644 --- a/runtime/include/psx_netplay.h +++ b/runtime/include/psx_netplay.h @@ -9,8 +9,8 @@ extern "C" { #endif /* - * Delay-sync netplay facade over recomp-net (LAN peer UDP for now). - * Lobby UI / ICE signaling are later work — this layer is CLI/env driven. + * Delay-sync netplay facade over recomp-net (LAN UDP or MotK ICE). + * Online hosted lobbies use ICE + WS signaling; Direct IP / LAN stay on UDP. * * Lockstep contract (matches recomp-net host_integration.md): * wait_admit (publish pads for tick T) → guest runs frame T → @@ -52,6 +52,9 @@ typedef struct PsxNetplayConfig { int input_delay; int force_input_relay; /* 1 = lobby-server UDP input relay */ int force_turn; /* 1 = ICE relay-only (Force TURN for UDP) */ + /* 0 = auto (MotK room → ICE, else LAN), 1 = force ICE, 2 = force LAN. + * Env PSX_NET_TRANSPORT=lan|ice overrides. */ + int transport; uint32_t session_id; char bind_hostport[64]; char peer_hostport[64]; @@ -62,6 +65,12 @@ void psx_netplay_apply_env(PsxNetplayConfig *cfg); int psx_netplay_active(void); int psx_netplay_is_running(void); +/* "ice" | "lan" | "none" */ +const char *psx_netplay_transport_name(void); +/* 1 when ICE agent reached FAILED (online path). */ +int psx_netplay_ice_failed(void); +/* Optional JSONL samples when PSX_NET_DIAG=1 (saves/netplay/net_diag.jsonl). */ +void psx_netplay_diag_tick(void); int psx_netplay_local_slot(void); /* Resolved host player index used for local capture. */ int psx_netplay_input_player(void); diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 2d73ae1e8..9afe2d5dd 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -196,6 +196,10 @@ if(PSX_NETPLAY AND NOT RECOMP_NET_ROOT) endif() if(PSX_NETPLAY AND RECOMP_NET_ROOT AND EXISTS "${RECOMP_NET_ROOT}/CMakeLists.txt") if(NOT TARGET recomp_net) + option(PSX_NET_ICE "Build recomp-net with ICE/libjuice for MotK online" ON) + if(PSX_NET_ICE) + set(RNET_ENABLE_ICE ON CACHE BOOL "Build libjuice ICE transport" FORCE) + endif() set(RNET_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) set(RNET_BUILD_TESTS OFF CACHE BOOL "" FORCE) add_subdirectory("${RECOMP_NET_ROOT}" "${CMAKE_BINARY_DIR}/recomp-net") @@ -463,6 +467,10 @@ function(psxrecomp_add_runtime_target target) target_link_libraries(${target} PRIVATE ${SDL2_LIBRARIES}) endif() + # zlib: boot_state v4 savestate compression (RAM/VRAM/SPU blobs). + find_package(ZLIB REQUIRED) + target_link_libraries(${target} PRIVATE ZLIB::ZLIB) + # Build identity: stamp the psxrecomp commit into the binary so a crash report # can be correlated to an exact build (issue #1 user reports had no version). # Computed at configure time from the psxrecomp repo (this file's dir); empty diff --git a/runtime/src/boot_state.c b/runtime/src/boot_state.c index b4acb4175..e96942e5f 100644 --- a/runtime/src/boot_state.c +++ b/runtime/src/boot_state.c @@ -1,11 +1,36 @@ #include "boot_state.h" #include "overlay_api.h" /* PSX_OVERLAY_CODEGEN_HASH / _ABI_TAG / _CODEGEN_VER */ +#include "dirty_ram_interp.h" #include "gpu_render.h" /* gr_vram_transfer_in / gr_vram_transfer_out */ +#include "cpu_state.h" /* gte_canonicalize_cpu_state after CPU wire restore */ #include "psx_cycles.h" #include "pst_wire.h" #include #include #include +#include +#if defined(_WIN32) +# include +#else +# include +#endif + +static double boot_state_mono_ms(void) { +#if defined(_WIN32) + static LARGE_INTEGER freq; + LARGE_INTEGER c; + if (!freq.QuadPart) QueryPerformanceFrequency(&freq); + QueryPerformanceCounter(&c); + return (double)c.QuadPart * 1000.0 / (double)freq.QuadPart; +#else + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec * 1000.0 + (double)ts.tv_nsec / 1.0e6; +#endif +} + +/* Compress payloads at/above this size (RAM/VRAM/SPU/dirty dominate I/O). */ +#define BOOT_STATE_ZLIB_MIN 256u #define RAM_SIZE (2u * 1024u * 1024u) #define SPAD_SIZE (1024u) @@ -16,9 +41,6 @@ /* ---- core accessors (existing runtime modules) ---- */ extern uint8_t* memory_get_ram_ptr(void); extern uint8_t* memory_get_scratchpad_ptr(void); -extern uint32_t dirty_ram_get_bitmap_word(uint32_t word_index); -extern uint32_t dirty_ram_get_bitmap_word_count(void); -extern void dirty_ram_set_bitmap_words(const uint32_t* words, uint32_t count); extern uint32_t i_stat; extern uint32_t i_mask; extern uint64_t psx_cycle_count; @@ -80,36 +102,44 @@ static int write_header_le(FILE* f, const BootStateHeader* h) { return fwrite_all(f, buf, sizeof buf); } -static int read_header_le(FILE* f, BootStateHeader* h) { - uint8_t buf[BOOT_STATE_HEADER_WIRE_BYTES]; - PstR r; - if (fread(buf, 1, sizeof buf, f) != sizeof buf) return 0; - pst_r_init(&r, buf, sizeof buf); - memset(h, 0, sizeof *h); - if (!pst_r_u32(&r, &h->magic) || - !pst_r_u32(&r, &h->version) || - !pst_r_u32(&r, &h->bios_checksum) || - !pst_r_u32(&r, &h->entry_pc) || - !pst_r_u32(&r, &h->codegen_hash) || - !pst_r_i32(&r, &h->abi_tag) || - !pst_r_u32(&r, &h->codegen_ver) || - !pst_r_u32(&r, &h->section_count) || - !pst_r_u32(&r, &h->reserved)) - return 0; - return 1; -} - -static int write_section(FILE* f, uint32_t tag, const void* data, uint64_t len) { +static int write_section_raw(FILE* f, uint32_t tag, uint32_t flags, + const void* data, uint64_t len) { uint8_t hdr[16]; PstW w; pst_w_init(&w, hdr, sizeof hdr); - if (!pst_w_u32(&w, tag) || !pst_w_u32(&w, 0u) || !pst_w_u64(&w, len)) + if (!pst_w_u32(&w, tag) || !pst_w_u32(&w, flags) || !pst_w_u64(&w, len)) return 0; if (!fwrite_all(f, hdr, sizeof hdr)) return 0; if (len && !fwrite_all(f, data, (size_t)len)) return 0; return 1; } +/* Prefer zlib for large blobs (smaller disk + faster load on slow storage). + * Falls back to raw if compressBound/compress fails. */ +static int write_section(FILE* f, uint32_t tag, const void* data, uint64_t len) { + if (!data && len) return 0; + if (len >= BOOT_STATE_ZLIB_MIN && len <= 0xffffffffu) { + uLong bound = compressBound((uLong)len); + uint8_t* packed = (uint8_t*)malloc(4u + (size_t)bound); + if (packed) { + PstW lw; + uLong dest_len = bound; + pst_w_init(&lw, packed, 4); + if (pst_w_u32(&lw, (uint32_t)len) && + compress2(packed + 4, &dest_len, (const Bytef*)data, (uLong)len, + Z_BEST_SPEED) == Z_OK) { + uint64_t payload = 4u + (uint64_t)dest_len; + int ok = write_section_raw(f, tag, BOOT_STATE_SEC_ZLIB, + packed, payload); + free(packed); + return ok; + } + free(packed); + } + } + return write_section_raw(f, tag, 0u, data, len); +} + static int write_module_section(FILE* f, uint32_t tag, uint32_t (*bytes)(void), void (*write)(uint8_t*)) { @@ -208,7 +238,8 @@ int boot_state_save(const CPUState* cpu, uint32_t bios_checksum, if (!vbuf) ok = 0; else { gr_vram_transfer_out(0, 0, VRAM_W, VRAM_H, vbuf); - /* VRAM is uint16 LE guest pixels — emit as LE u16 stream. */ + /* VRAM is uint16 LE guest pixels — emit as LE u16 stream. + * pst_w_pod is a memcpy on LE hosts (see pst_wire.h). */ uint8_t* wire = (uint8_t*)malloc(VRAM_SIZE); if (!wire) ok = 0; else { @@ -269,6 +300,9 @@ static int apply_section(uint32_t tag, const uint8_t* p, uint32_t len, if (!pst_r_u32(&r, &cpu->gte_data[i])) return 0; for (int i = 0; i < 32; i++) if (!pst_r_u32(&r, &cpu->gte_ctrl[i])) return 0; + /* Architectural normalize + drop host-only projection provenance that + * belonged to the pre-load timeline (not part of the wire format). */ + gte_canonicalize_cpu_state(cpu); return 1; } case BS_SEC_RAM: @@ -325,19 +359,27 @@ static int apply_section(uint32_t tag, const uint8_t* p, uint32_t len, case BS_SEC_GPU: return gpu_snapshot_read(p, len); case BS_SEC_VRAM: { - uint16_t* vbuf; - PstR r; if (len != VRAM_SIZE) return 0; - vbuf = (uint16_t*)malloc(VRAM_SIZE); - if (!vbuf) return 0; - pst_r_init(&r, p, len); - if (!pst_r_pod(&r, vbuf, VRAM_SIZE, 2)) { +#if defined(__BYTE_ORDER__) && (__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__) + /* Wire == host layout: upload straight from the section buffer. */ + gr_vram_transfer_in(0, 0, VRAM_W, VRAM_H, (const uint16_t*)p); + return 1; +#else + { + uint16_t* vbuf; + PstR r; + vbuf = (uint16_t*)malloc(VRAM_SIZE); + if (!vbuf) return 0; + pst_r_init(&r, p, len); + if (!pst_r_pod(&r, vbuf, VRAM_SIZE, 2)) { + free(vbuf); + return 0; + } + gr_vram_transfer_in(0, 0, VRAM_W, VRAM_H, vbuf); free(vbuf); - return 0; + return 1; } - gr_vram_transfer_in(0, 0, VRAM_W, VRAM_H, vbuf); - free(vbuf); - return 1; +#endif } case BS_SEC_SPU: return spu_snapshot_read(p, len); @@ -378,52 +420,165 @@ static int apply_section(uint32_t tag, const uint8_t* p, uint32_t len, int boot_state_load(const char* path, uint32_t bios_checksum, uint32_t entry_pc, CPUState* cpu) { FILE* f = fopen(path, "rb"); + long sz; + uint8_t* file = NULL; + size_t file_len = 0; + const uint8_t* cur; + const uint8_t* end; + BootStateHeader h; + PstR hr; + const uint32_t required = + (1u< 64L * 1024L * 1024L) { + fclose(f); + return 0; + } + if (fseek(f, 0, SEEK_SET) != 0) { fclose(f); return 0; } + file_len = (size_t)sz; + file = (uint8_t*)malloc(file_len); + if (!file) { fclose(f); return 0; } + if (fread(file, 1, file_len, f) != file_len) { + free(file); + fclose(f); + return 0; + } + fclose(f); + t_after_read = boot_state_mono_ms(); - BootStateHeader h; - if (!read_header_le(f, &h)) { fclose(f); return 0; } + /* Parse header from the in-memory image (one I/O, then CPU-side inflate). */ + pst_r_init(&hr, file, BOOT_STATE_HEADER_WIRE_BYTES); + memset(&h, 0, sizeof h); + if (!pst_r_u32(&hr, &h.magic) || + !pst_r_u32(&hr, &h.version) || + !pst_r_u32(&hr, &h.bios_checksum) || + !pst_r_u32(&hr, &h.entry_pc) || + !pst_r_u32(&hr, &h.codegen_hash) || + !pst_r_i32(&hr, &h.abi_tag) || + !pst_r_u32(&hr, &h.codegen_ver) || + !pst_r_u32(&hr, &h.section_count) || + !pst_r_u32(&hr, &h.reserved)) { + free(file); + return 0; + } if (h.magic != BOOT_STATE_MAGIC || - h.version != BOOT_STATE_VERSION || + h.version < BOOT_STATE_VERSION_MIN_READ || + h.version > BOOT_STATE_VERSION || h.bios_checksum != bios_checksum || h.entry_pc != entry_pc || h.codegen_hash != (uint32_t)PSX_OVERLAY_CODEGEN_HASH || h.abi_tag != (int32_t)PSX_OVERLAY_ABI_TAG || h.codegen_ver != (uint32_t)PSX_OVERLAY_CODEGEN_VER) { - fclose(f); + free(file); return 0; } - const uint32_t required = - (1u< 64u * 1024u * 1024u || (uint64_t)(end - cur) < len) { ok = 0; break; } - if (len > 64u * 1024u * 1024u) { ok = 0; break; } - uint8_t* buf = (uint8_t*)malloc(len ? (size_t)len : 1); - if (!buf) { ok = 0; break; } - if (len && fread(buf, 1, (size_t)len, f) != (size_t)len) { free(buf); ok = 0; break; } - if (!apply_section(tag, buf, (uint32_t)len, cpu, entry_pc)) ok = 0; + payload = cur; + cur += (size_t)len; + + if (h.version >= 4u && pad == BOOT_STATE_SEC_ZLIB) { + PstR lr; + uint32_t raw_len = 0; + uLong dest_len; + double t_inf; + if (len < 4u) { ok = 0; break; } + pst_r_init(&lr, payload, 4); + if (!pst_r_u32(&lr, &raw_len) || raw_len == 0 || + raw_len > 64u * 1024u * 1024u) { + ok = 0; break; + } + inflated = (uint8_t*)malloc(raw_len); + if (!inflated) { ok = 0; break; } + dest_len = (uLong)raw_len; + t_inf = boot_state_mono_ms(); + if (uncompress(inflated, &dest_len, payload + 4, + (uLong)(len - 4u)) != Z_OK || + dest_len != (uLong)raw_len) { + free(inflated); + ok = 0; + break; + } + inflate_ms += boot_state_mono_ms() - t_inf; + apply_ptr = inflated; + apply_len = raw_len; + } else if (pad != 0u) { + /* v3 requires pad==0; v4 unknown/extra flags are a hard reject. */ + ok = 0; + break; + } else { + if (len > 0xffffffffu) { ok = 0; break; } + apply_ptr = payload; + apply_len = (uint32_t)len; + } + + t_sec = boot_state_mono_ms(); + if (!apply_section(tag, apply_ptr, apply_len, cpu, entry_pc)) ok = 0; else if (tag < 32) seen |= (1u << tag); - free(buf); + { + double dt = boot_state_mono_ms() - t_sec; + if (tag == BS_SEC_RAM) apply_ram_ms += dt; + else if (tag == BS_SEC_VRAM) apply_vram_ms += dt; + else if (tag == BS_SEC_SPURAM) apply_spuram_ms += dt; + else apply_other_ms += dt; + } + free(inflated); } - fclose(f); + free(file); if (!ok || (seen & required) != required) return 0; + + /* RAM was memcpy'd; force overlay revalidation before resume. */ + overlay_watch_invalidate_after_ram_restore(); + + { + const double total_ms = boot_state_mono_ms() - t0; + fprintf(stderr, + "savestate: load_timing read=%.1f inflate=%.1f " + "apply_ram=%.1f apply_vram=%.1f apply_spuram=%.1f " + "apply_other=%.1f total=%.1f ms (file=%zu)\n", + t_after_read - t0, inflate_ms, + apply_ram_ms, apply_vram_ms, apply_spuram_ms, + apply_other_ms, total_ms, file_len); + } return 1; } diff --git a/runtime/src/cdrom.c b/runtime/src/cdrom.c index 889fa0284..954dded27 100644 --- a/runtime/src/cdrom.c +++ b/runtime/src/cdrom.c @@ -2649,6 +2649,65 @@ int cdrom_data_read_active(void) { return reading && !xa_stream_active; } +/* Savestate post-load: authentic CD second-response delays (ReadTOC ~30M + * cycles, Init ~1.1M, far seeks, etc.) leave the restored frame on screen + * for up to ~1s+ of wall time. Clamp those timers so the next cdrom_advance + * delivers the IRQ; keep XA/FMV cadence untouched. */ +#define CDROM_SAVESTATE_DELAY_CAP 4096 +#define CDROM_SAVESTATE_BOOST_VBLANKS 180 /* ~3s at 60Hz */ + +static int s_savestate_cd_boost_vblanks = 0; + +static void cdrom_apply_savestate_delay_caps(int log_clamps) { + if (pending.pending && pending.delay > CDROM_SAVESTATE_DELAY_CAP) { + if (log_clamps) + fprintf(stderr, + "cdrom: savestate clamped pending cmd=0x%02X delay %d -> %d\n", + (unsigned)pending.cmd, pending.delay, + CDROM_SAVESTATE_DELAY_CAP); + pending.delay = CDROM_SAVESTATE_DELAY_CAP; + } + if (reading && !xa_stream_active && + read_delay > CDROM_SAVESTATE_DELAY_CAP) { + if (log_clamps) + fprintf(stderr, + "cdrom: savestate clamped read_delay %d -> %d\n", + read_delay, CDROM_SAVESTATE_DELAY_CAP); + read_delay = CDROM_SAVESTATE_DELAY_CAP; + s_cd_timing_next_due = psx_cycle_count + (uint64_t)read_delay; + } + if (cdrom_irq_present_delay > CDROM_SAVESTATE_DELAY_CAP) + cdrom_irq_present_delay = CDROM_SAVESTATE_DELAY_CAP; +} + +void cdrom_accelerate_after_savestate(void) { + const char *probe = getenv("PSX_POST_LOAD_PROBE"); + const int log_probe = (probe && probe[0] == '1'); + s_savestate_cd_boost_vblanks = CDROM_SAVESTATE_BOOST_VBLANKS; + cdrom_apply_savestate_delay_caps(/*log_clamps*/log_probe); + if (log_probe) + fprintf(stderr, + "cdrom: savestate boost armed for %d vblanks (delay cap %d)\n", + CDROM_SAVESTATE_BOOST_VBLANKS, CDROM_SAVESTATE_DELAY_CAP); +} + +void cdrom_savestate_boost_vblank(void) { + if (s_savestate_cd_boost_vblanks <= 0) return; + cdrom_apply_savestate_delay_caps(/*log_clamps*/0); + s_savestate_cd_boost_vblanks--; +} + +int cdrom_savestate_cd_wait_active(void) { + if (s_savestate_cd_boost_vblanks <= 0) return 0; + if (pending.pending && pending.delay > 0) return 1; + if (reading && !xa_stream_active) return 1; + return 0; +} + +int cdrom_savestate_boost_vblanks_remaining(void) { + return s_savestate_cd_boost_vblanks; +} + /* ---- boot snapshot: complete CD-ROM controller FSM (see boot_state.h) ---- */ /* Every functional controller/drive/FIFO/IRQ-latch/XA-decode/timing global is * listed here exactly once. The X-macro guarantees bytes()/write()/read() can diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 4398753df..9ec6a5914 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -2070,22 +2070,32 @@ static uint8_t gpu_vram_byte(uint32_t byte_x, uint32_t y) { /* Depth24: note/query/reset the CPU→VRAM upload span tracked above. Used to * hide trailing RGB columns when a movie blit doesn't fill the full CRTC - * width — MotK's Star Wars crawl leaves ~8px of stale VRAM on the right. */ + * width — MotK's Star Wars crawl leaves ~8px of stale VRAM on the right, and + * movie cutovers can flash a large colorful junk block for one frame. */ static void depth24_note_upload(uint32_t x, uint32_t w) { if (!(display_depth & 1u) || w == 0u) return; + /* Left-anchored FB-class blit: start a fresh coverage window so the next + * movie's first incomplete decode doesn't inherit the previous span. */ + uint32_t dx = display_area_x & 1023u; + if (w >= 64u && x <= dx + 16u) + s_d24_upload_x1 = 0; uint32_t x1 = x + w; if (x1 > 1024u) x1 = 1024u; if (x1 > s_d24_upload_x1) s_d24_upload_x1 = x1; } uint32_t gpu_depth24_rgb_limit(uint32_t display_x, uint32_t crtc_w) { - if (!(display_depth & 1u) || s_d24_upload_x1 == 0u || crtc_w == 0u) + if (!(display_depth & 1u) || crtc_w == 0u) return crtc_w; + /* No uploads yet → treat as uncovered (present blanks until first blit). */ + if (s_d24_upload_x1 == 0u) + return 0u; uint32_t dx = display_x & 1023u; - if (s_d24_upload_x1 <= dx) return crtc_w; + if (s_d24_upload_x1 <= dx) return 0u; uint32_t hw = s_d24_upload_x1 - dx; uint32_t rgb = (hw * 2u) / 3u; - if (rgb == 0u || rgb >= crtc_w) return crtc_w; + if (rgb == 0u) return 0u; + if (rgb >= crtc_w) return crtc_w; return rgb; } @@ -4205,10 +4215,13 @@ static void gp1_display_mode(uint32_t val) { * bit 5: vertical interlace (0=off, 1=on) * bit 6: horizontal resolution 2 (0=normal, 1=368) * bit 7: "reverseflag" */ + uint32_t new_depth = (val >> 4) & 1; hres1 = val & 3; vres = (val >> 2) & 1; video_mode = (val >> 3) & 1; - display_depth = (val >> 4) & 1; + if (new_depth != display_depth) + s_d24_upload_x1 = 0; /* rising/falling: drop stale coverage */ + display_depth = new_depth; vertical_interlace = (val >> 5) & 1; hres2 = (val >> 6) & 1; reverse_flag = (val >> 7) & 1; diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 0d928b4e7..3fa95bfd3 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -613,6 +613,11 @@ static int s_last_dx, s_last_dy, s_last_dw, s_last_dh; * (and FPS) keep advancing — especially on a 2nd+ load of the same slot. */ static int s_force_present_remaining = 0; +/* Post-load freeze probe (main.cpp): accumulate skip/swap/dirty marks. */ +static uint64_t s_probe_skip = 0; +static uint64_t s_probe_swap = 0; +static uint64_t s_probe_dirty_marks = 0; + static void present_dirty_rect(int x0, int y0, int x1, int y1, int set) { if (x0 < 0) x0 = 0; if (y0 < 0) y0 = 0; if (x1 >= VRAM_W) x1 = VRAM_W - 1; if (y1 >= VRAM_H) y1 = VRAM_H - 1; @@ -622,6 +627,7 @@ static void present_dirty_rect(int x0, int y0, int x1, int y1, int set) { for (int ty = y0 / PRES_TILE; ty <= y1 / PRES_TILE; ty++) { if (set) s_present_dirty[ty] |= mask; else s_present_dirty[ty] &= ~mask; } + if (set) s_probe_dirty_marks++; } static int present_dirty_test(int x0, int y0, int x1, int y1) { @@ -2599,6 +2605,7 @@ void gl_renderer_present(const uint32_t *pixels, int src_w, int src_h, int linea latency_ring_mark(LAT_SWAP_BEGIN); SDL_GL_SwapWindow(s_win); latency_ring_mark(LAT_SWAP_END); + s_probe_swap++; present_force_consumed(); s_last_present_path = GL_PRES_CPU; } @@ -2613,6 +2620,7 @@ void gl_renderer_present_blank(void) { latency_ring_mark(LAT_SWAP_BEGIN); SDL_GL_SwapWindow(s_win); latency_ring_mark(LAT_SWAP_END); + s_probe_swap++; present_force_consumed(); s_last_present_path = GL_PRES_BLANK; } @@ -2630,6 +2638,29 @@ void gl_renderer_invalidate_present(void) { interp_reset_history(); } +void gl_renderer_present_probe_reset(void) { + s_probe_skip = 0; + s_probe_swap = 0; + s_probe_dirty_marks = 0; +} + +void gl_renderer_present_probe_take(uint64_t *skip_delta, uint64_t *swap_delta, + uint64_t *dirty_mark_delta, + int *force_remaining) { + if (skip_delta) { *skip_delta = s_probe_skip; s_probe_skip = 0; } + if (swap_delta) { *swap_delta = s_probe_swap; s_probe_swap = 0; } + if (dirty_mark_delta) { + *dirty_mark_delta = s_probe_dirty_marks; + s_probe_dirty_marks = 0; + } + if (force_remaining) *force_remaining = s_force_present_remaining; +} + +int gl_renderer_present_rect_dirty(int disp_x, int disp_y, int w, int h) { + if (!s_raster_ok || w <= 0 || h <= 0) return 0; + return present_dirty_test(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1); +} + void gl_renderer_flush_cpu_uploads(void) { if (!s_raster_ok) return; flush_flat_batch(); @@ -3521,6 +3552,7 @@ void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, s_last_dx == disp_x && s_last_dy == disp_y && s_last_dw == w && s_last_dh == h && !present_dirty_test(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1)) { + s_probe_skip++; gl_perf_present_enter(); gl_perf_present_exit(0); return; @@ -3558,6 +3590,7 @@ void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, latency_ring_mark(LAT_SWAP_BEGIN); SDL_GL_SwapWindow(s_win); latency_ring_mark(LAT_SWAP_END); + s_probe_swap++; gl_perf_present_exit(0); present_dirty_rect(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1, 0); present_force_consumed(); @@ -3621,6 +3654,7 @@ int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) s_last_dx == disp_x && s_last_dy == disp_y && s_last_dw == g_wide_w && s_last_dh == disp_h && !present_dirty_test(0, disp_y, VRAM_W - 1, disp_y + disp_h - 1)) { + s_probe_skip++; gl_perf_present_enter(); gl_perf_present_exit(1); return 1; @@ -3654,6 +3688,7 @@ int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) latency_ring_mark(LAT_SWAP_BEGIN); SDL_GL_SwapWindow(s_win); latency_ring_mark(LAT_SWAP_END); + s_probe_swap++; gl_perf_present_exit(1); present_dirty_rect(0, disp_y, VRAM_W - 1, disp_y + disp_h - 1, 0); present_force_consumed(); diff --git a/runtime/src/gpu_sw_renderer.c b/runtime/src/gpu_sw_renderer.c index 86b8459c5..51e6484cf 100644 --- a/runtime/src/gpu_sw_renderer.c +++ b/runtime/src/gpu_sw_renderer.c @@ -1533,7 +1533,69 @@ uint16_t sw_vram_read(int x, int y) { /* Bulk VRAM transfers */ /* ------------------------------------------------------------------ */ +/* Rebuild the supersampled mirror from canonical VRAM after a bulk replace. + * Row-wise replication (memcpy) beats the per-texel nested loop used by the + * general transfer path — savestate restore hits full 1024×512 often. */ +static void hr_rebuild_from_vram(void) { + if (!g_hr || !g_vram) return; + const int s = g_scale; + if (s <= 1) return; + uint16_t *row = (uint16_t *)malloc((size_t)g_hr_w * sizeof(uint16_t)); + if (!row) { + /* Fall back to slow per-pixel replication if OOM (should not happen). */ + for (int py = 0; py < VRAM_HEIGHT; py++) { + for (int px = 0; px < VRAM_WIDTH; px++) { + uint16_t pixel = g_vram[py * VRAM_WIDTH + px]; + int bx = px * s, by = py * s; + for (int dy = 0; dy < s; dy++) { + uint16_t *dst = g_hr + (size_t)(by + dy) * (size_t)g_hr_w; + for (int dx = 0; dx < s; dx++) + dst[bx + dx] = pixel; + } + } + } + return; + } + for (int y = 0; y < VRAM_HEIGHT; y++) { + const uint16_t *src = g_vram + (size_t)y * VRAM_WIDTH; + for (int x = 0; x < VRAM_WIDTH; x++) { + uint16_t p = src[x]; + uint16_t *d = row + (size_t)x * (size_t)s; + for (int dx = 0; dx < s; dx++) + d[dx] = p; + } + for (int dy = 0; dy < s; dy++) { + memcpy(g_hr + ((size_t)(y * s + dy) * (size_t)g_hr_w), + row, (size_t)g_hr_w * sizeof(uint16_t)); + } + } + free(row); +} + void sw_vram_transfer_in(int x, int y, int w, int h, const uint16_t *data) { + if (!data || !g_vram || w <= 0 || h <= 0) return; + + /* Full-VRAM replace (savestate / boot_state): memcpy + one HR rebuild. + * The general path is a wrapped per-pixel loop that, with supersampling, + * does s² stores per guest texel — multi-second hitches on 2×/4×. */ + if (x == 0 && y == 0 && w == VRAM_WIDTH && h == VRAM_HEIGHT) { + memcpy(g_vram, data, (size_t)VRAM_WIDTH * (size_t)VRAM_HEIGHT * sizeof(uint16_t)); + if (g_hr) hr_rebuild_from_vram(); + return; + } + + /* Contiguous non-wrapping rect, no HR: memcpy each row. */ + x &= (VRAM_WIDTH - 1); + y &= (VRAM_HEIGHT - 1); + if (!g_hr && x + w <= VRAM_WIDTH && y + h <= VRAM_HEIGHT) { + for (int row = 0; row < h; row++) { + memcpy(g_vram + (size_t)(y + row) * VRAM_WIDTH + (size_t)x, + data + (size_t)row * (size_t)w, + (size_t)w * sizeof(uint16_t)); + } + return; + } + int idx = 0; int s = g_scale; for (int row = 0; row < h; row++) { @@ -1556,6 +1618,24 @@ void sw_vram_transfer_in(int x, int y, int w, int h, const uint16_t *data) { } void sw_vram_transfer_out(int x, int y, int w, int h, uint16_t *data) { + if (!data || !g_vram || w <= 0 || h <= 0) return; + + if (x == 0 && y == 0 && w == VRAM_WIDTH && h == VRAM_HEIGHT) { + memcpy(data, g_vram, (size_t)VRAM_WIDTH * (size_t)VRAM_HEIGHT * sizeof(uint16_t)); + return; + } + + x &= (VRAM_WIDTH - 1); + y &= (VRAM_HEIGHT - 1); + if (x + w <= VRAM_WIDTH && y + h <= VRAM_HEIGHT) { + for (int row = 0; row < h; row++) { + memcpy(data + (size_t)row * (size_t)w, + g_vram + (size_t)(y + row) * VRAM_WIDTH + (size_t)x, + (size_t)w * sizeof(uint16_t)); + } + return; + } + int idx = 0; for (int row = 0; row < h; row++) { int py = (y + row) & (VRAM_HEIGHT - 1); diff --git a/runtime/src/interrupts.c b/runtime/src/interrupts.c index c44af965d..8b3fd84e9 100644 --- a/runtime/src/interrupts.c +++ b/runtime/src/interrupts.c @@ -450,6 +450,9 @@ static uint32_t s_compiled_interrupt_resume_pc = 0; static uint32_t s_last_interrupt_check_pc = 0; static uint64_t s_last_interrupt_check_cycle = UINT64_MAX; +uint32_t psx_last_irq_check_pc(void) { return s_last_interrupt_check_pc; } +uint32_t psx_compiled_irq_resume_pc(void) { return s_compiled_interrupt_resume_pc; } + /* Deferred cooperative thread switch from nested exception delivery. * * A genuine in-exception ChangeThread (kind-30, escape site below) must be @@ -607,6 +610,27 @@ void interrupts_init(void) { g_vblank_raise_count = 0; last_sio_seq_seen = sio_get_seq(); last_sio_progress_cycle = psx_get_cycle_count(); + s_defer_switch_pending = 0; + s_defer_switch_target = 0; + s_defer_switch_from = 0; +} + +void interrupts_resync_after_restore(void) { + /* Absolute guest-cycle timestamps from the pre-load timeline are invalid + * once psx_cycle_count rewinds (typical: save → play N seconds → load). + * Leaving post_exception_cooldown_until in the future blocks every IRQ + * delivery — including VBlank — until the restored clock catches up, + * freezing the picture for those N seconds while host FPS stays at 60. */ + post_exception_cooldown_until = 0; + cycles_since_vblank = 0; + dispatch_count = 0; + in_exception = 0; + exception_nest_depth = 0; + last_sio_seq_seen = sio_get_seq(); + last_sio_progress_cycle = psx_get_cycle_count(); + s_defer_switch_pending = 0; + s_defer_switch_target = 0; + s_defer_switch_from = 0; } /* @@ -706,6 +730,23 @@ void psx_interrupt_delivery_diag(uint64_t *need_defer, uint64_t *need_irq, if (skip_nested) *skip_nested = s_skip_nested; } +/* Hot-path attribution for post-load freeze probe (see psx_interrupt_check_path_diag). */ +static uint64_t s_irq_path_entry; +static uint64_t s_irq_path_fast_sr; +static uint64_t s_irq_path_fast_none; +static uint64_t s_irq_path_eval; + +void psx_interrupt_check_path_diag(uint64_t *entry, uint64_t *fast_sr, + uint64_t *fast_none, uint64_t *mid, + uint64_t *eval, uint64_t *irq_deliv) { + if (entry) *entry = s_irq_path_entry; + if (fast_sr) *fast_sr = s_irq_path_fast_sr; + if (fast_none) *fast_none = s_irq_path_fast_none; + if (mid) *mid = total_checks; + if (eval) *eval = s_irq_path_eval; + if (irq_deliv) *irq_deliv = g_irq_deliver_count; +} + int psx_interrupt_delivery_needed(const CPUState* cpu) { if (s_defer_switch_pending) { s_need_defer++; return 1; } if ((i_stat & i_mask) == 0) { s_skip_none++; return 0; } @@ -740,6 +781,8 @@ void psx_check_interrupts(CPUState* cpu) { #define COSIM_IRQ_NOTE(kind_) cosim_irq_note(cpu, (kind_), COSIM_IRQ_TAKE_PC(), g_dirty_safe_resume_pc, s_compiled_interrupt_resume_pc, cpu->cop0[COP0_SR]) #endif + s_irq_path_entry++; + /* MotK VLC / FMV hot edge: sticky unmasked I_STAT (CD/VBlank) while * IEc or IM2 is clear — no architectural delivery possible. Skip the * mid-path bookkeeping / irq_deliver_eval that used to run every BB. @@ -749,6 +792,7 @@ void psx_check_interrupts(CPUState* cpu) { if (!in_exception && !s_defer_switch_pending && (i_stat & i_mask) != 0) { uint32_t sr = cpu->cop0[COP0_SR]; if (!(sr & 0x01u) || !(sr & (1u << 10))) { + s_irq_path_fast_sr++; if ((++s_fast_maintenance & 0x3FFFu) == 0) { extern void savestate_poll(CPUState* cpu, uint32_t resume_pc); savestate_poll(cpu, s_compiled_interrupt_resume_pc); @@ -795,6 +839,7 @@ void psx_check_interrupts(CPUState* cpu) { psx_idle_note_check(cpu, check_pc); } if ((i_stat & i_mask) == 0 && sw_pending == 0) { + s_irq_path_fast_none++; if ((++s_fast_maintenance & 0x3FFFu) == 0) { extern void savestate_poll(CPUState* cpu, uint32_t resume_pc); savestate_poll(cpu, s_compiled_interrupt_resume_pc); @@ -956,6 +1001,7 @@ void psx_check_interrupts(CPUState* cpu) { } irq_deliver_eval: + s_irq_path_eval++; /* Check if any interrupts are pending (INTC hardware or COP0 software). */ if ((i_stat & i_mask) == 0 && sw_pending == 0) { irq_record_outcome(EV_NONE, 0, 0); PSX_CHECK_INTERRUPTS_RETURN(); } /* Nested delivery (hardware semantics). Real R3000A has no 'in exception' diff --git a/runtime/src/load_accel.c b/runtime/src/load_accel.c index d32dbfb78..3a43a141d 100644 --- a/runtime/src/load_accel.c +++ b/runtime/src/load_accel.c @@ -260,3 +260,8 @@ void psx_vsync_query_hle_stats_json(char* buf, int cap) { (unsigned long long)s_extra_horizon_hits, (unsigned long long)s_extra_horizon_cycles); } + +void psx_vsync_query_hle_horizon_totals(uint64_t *hits, uint64_t *cycles) { + if (hits) *hits = s_horizon_hits + s_extra_horizon_hits; + if (cycles) *cycles = s_horizon_cycles + s_extra_horizon_cycles; +} diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 3e03fb183..346010a18 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -26,6 +26,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "overlay_backend.h" #include "gpu.h" +#include "interrupts.h" #include "present_ring.h" #include "load_transition_ring.h" #include "gpu_sw_renderer.h" @@ -64,6 +65,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #if defined(RECOMP_LAUNCHER) #include "recomp_launcher.h" /* shared recomp-ui Dear ImGui launcher */ #include "launcher_profile.h" /* per-system variant profile (theme/caps bundle) */ +#include "launcher_boot_timing.h" /* PSX_LAUNCHER_BOOT_TIMING stamps */ #endif #include #if defined(PSX_WEB) @@ -71,6 +73,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #endif #include #include +#include #include #include #include @@ -347,6 +350,61 @@ static Smooth60State g_smooth_60_state; * survive soft-return and poison FMV/FPS after session_reboot). */ static bool s_disabled_frame_presented = false; static bool s_force_present_after_load = false; +/* After LOADED: optional freeze probe (PSX_POST_LOAD_PROBE=1). Off by default. */ +static int s_post_load_probe_enabled = -1; /* -1 = unread env */ +static int s_post_load_probe_left = 0; +static int s_post_load_probe_i = 0; +static uint64_t s_post_load_probe_gp00 = 0; +static uint64_t s_post_load_probe_skip_tot = 0; +static uint64_t s_post_load_probe_swap_tot = 0; +static uint64_t s_post_load_probe_dirty_tot = 0; +static uint64_t s_post_load_probe_gp0_tot = 0; +static uint64_t s_post_load_probe_vb_raise0 = 0; +static uint64_t s_post_load_probe_vb_deliv0 = 0; +static uint64_t s_post_load_probe_vb_ack0 = 0; +static uint64_t s_post_load_probe_dirty_blks0 = 0; +static uint64_t s_post_load_probe_chk_entry0 = 0; +static uint64_t s_post_load_probe_chk_fsr0 = 0; +static uint64_t s_post_load_probe_chk_fnone0 = 0; +static uint64_t s_post_load_probe_chk_mid0 = 0; +static uint64_t s_post_load_probe_chk_eval0 = 0; +static uint64_t s_post_load_probe_chk_deliv0 = 0; +static uint64_t s_post_load_probe_cyc0 = 0; +static uint64_t s_post_load_probe_ci_unit0 = 0; +static uint64_t s_post_load_probe_ci_supp0 = 0; +static uint64_t s_post_load_probe_ci_none0 = 0; +static uint64_t s_post_load_probe_ci_sr0 = 0; +static uint64_t s_post_load_probe_ci_deliv0 = 0; +static uint64_t s_post_load_probe_ci_enter0 = 0; +static uint64_t s_post_load_probe_adv_calls0 = 0; +static uint64_t s_post_load_probe_adv_sum0 = 0; +static uint64_t s_post_load_probe_svc0 = 0; +static uint64_t s_post_load_probe_dirty_insns0 = 0; +static uint64_t s_post_load_probe_dirty_pump0 = 0; +static uint64_t s_post_load_probe_stores0 = 0; +static uint64_t s_post_load_probe_idle_n0 = 0; +static uint64_t s_post_load_probe_idle_cyc0 = 0; +static uint64_t s_post_load_probe_hz_hits0 = 0; +static uint64_t s_post_load_probe_hz_cyc0 = 0; +static Uint64 s_post_load_probe_host_t0 = 0; +static int s_post_load_probe_stall_run = 0; +static int s_post_load_probe_in_stall = 0; +#define POST_LOAD_STALL_PC_CAP 8 +static uint32_t s_stall_pc[POST_LOAD_STALL_PC_CAP]; +static uint32_t s_stall_pc_n[POST_LOAD_STALL_PC_CAP]; +static int s_stall_pc_used = 0; +#define POST_LOAD_LIVE_PC_CAP 8 +static uint32_t s_live_pc[POST_LOAD_LIVE_PC_CAP]; +static uint32_t s_live_pc_n[POST_LOAD_LIVE_PC_CAP]; +static int s_live_pc_used = 0; + +static int post_load_probe_env_on(void) { + if (s_post_load_probe_enabled < 0) { + const char *e = std::getenv("PSX_POST_LOAD_PROBE"); + s_post_load_probe_enabled = (e && e[0] == '1') ? 1 : 0; + } + return s_post_load_probe_enabled; +} static Uint64 s_fps_last_time = 0; static uint64_t s_fps_last_frame = 0; static std::string s_fps_base_title; @@ -357,6 +415,14 @@ static int s_fmv_skip_present_skip = 0; static int s_netplay_depth24_present_skip = 0; static uint32_t s_fmv_skip_last_mdec = 0; static int s_fmv_skip_hold = 0; +/* MotK FMV cutover: after an idle gap, blank the first depth24+MDEC presents + * so one-frame RGB888 junk (stale VRAM) never reaches the window. */ +static int s_d24_prev_mdec = 0; +static int s_d24_saw_gap = 0; +static int s_d24_cutover_blank = 0; +/* Savestate restore → audio pump: re-anchor last_cycles (declared early so + * psx_frontend_on_savestate_loaded can set it). */ +static int g_audio_cycle_resync = 0; static void smooth_60_reset(void) { g_smooth_60_state.previous_source.clear(); @@ -379,9 +445,374 @@ static void present_session_reset(void) { s_netplay_depth24_present_skip = 0; s_fmv_skip_last_mdec = 0; s_fmv_skip_hold = 0; + s_d24_prev_mdec = 0; + s_d24_saw_gap = 0; + s_d24_cutover_blank = 0; smooth_60_reset(); } +static void post_load_probe_stall_pc_note(uint32_t pc) { + if (!pc) return; + for (int i = 0; i < s_stall_pc_used; i++) { + if (s_stall_pc[i] == pc) { + if (s_stall_pc_n[i] < 0xffffffffu) s_stall_pc_n[i]++; + return; + } + } + if (s_stall_pc_used >= POST_LOAD_STALL_PC_CAP) return; + s_stall_pc[s_stall_pc_used] = pc; + s_stall_pc_n[s_stall_pc_used] = 1; + s_stall_pc_used++; +} + +static void post_load_probe_stall_pc_dump(const char *why) { + if (s_stall_pc_used <= 0) return; + std::fprintf(stderr, "post_load_probe stall_pcs (%s):", why); + for (int i = 0; i < s_stall_pc_used; i++) { + std::fprintf(stderr, " 0x%08X×%u", + (unsigned)s_stall_pc[i], (unsigned)s_stall_pc_n[i]); + } + std::fprintf(stderr, "\n"); +} + +static void post_load_probe_live_pc_note(uint32_t pc) { + if (!pc) return; + for (int i = 0; i < s_live_pc_used; i++) { + if (s_live_pc[i] == pc) { + if (s_live_pc_n[i] < 0xffffffffu) s_live_pc_n[i]++; + return; + } + } + if (s_live_pc_used >= POST_LOAD_LIVE_PC_CAP) return; + s_live_pc[s_live_pc_used] = pc; + s_live_pc_n[s_live_pc_used] = 1; + s_live_pc_used++; +} + +static void post_load_probe_live_pc_dump(const char *why) { + if (s_live_pc_used <= 0) return; + std::fprintf(stderr, "post_load_probe live_pcs (%s):", why); + for (int i = 0; i < s_live_pc_used; i++) { + std::fprintf(stderr, " 0x%08X×%u", + (unsigned)s_live_pc[i], (unsigned)s_live_pc_n[i]); + } + std::fprintf(stderr, "\n"); +} + +static void post_load_probe_arm(void) { + if (!post_load_probe_env_on()) { + s_post_load_probe_left = 0; + g_plp_cycle_diag = 0; + return; + } + extern uint64_t g_vblank_raise_count, g_vblank_deliver_count, g_vblank_ack_count; + extern uint64_t g_dirty_ram_blocks_run; + extern uint64_t g_dirty_ram_insns_run; + extern uint64_t g_dirty_pump_count; + extern uint64_t g_guest_store_count; + uint64_t e = 0, fsr = 0, fn = 0, mid = 0, ev = 0, id = 0; + psx_interrupt_check_path_diag(&e, &fsr, &fn, &mid, &ev, &id); + uint64_t ci_u = 0, ci_s = 0, ci_n = 0, ci_sr = 0, ci_d = 0, ci_e = 0; + overlay_loader_get_ci_skip_diag(&ci_u, &ci_s, &ci_n, &ci_sr, &ci_d, &ci_e); + uint64_t hz_h = 0, hz_c = 0; + psx_vsync_query_hle_horizon_totals(&hz_h, &hz_c); + g_plp_cycle_diag = 1; + g_plp_adv_calls = 0; + g_plp_adv_max_chunk = 0; + g_plp_adv_sum = 0; + g_plp_svc_calls = 0; + s_post_load_probe_left = 300; + s_post_load_probe_i = 0; + s_post_load_probe_gp00 = gpu_get_gp0_count(); + s_post_load_probe_skip_tot = 0; + s_post_load_probe_swap_tot = 0; + s_post_load_probe_dirty_tot = 0; + s_post_load_probe_gp0_tot = 0; + s_post_load_probe_vb_raise0 = g_vblank_raise_count; + s_post_load_probe_vb_deliv0 = g_vblank_deliver_count; + s_post_load_probe_vb_ack0 = g_vblank_ack_count; + s_post_load_probe_dirty_blks0 = g_dirty_ram_blocks_run; + s_post_load_probe_chk_entry0 = e; + s_post_load_probe_chk_fsr0 = fsr; + s_post_load_probe_chk_fnone0 = fn; + s_post_load_probe_chk_mid0 = mid; + s_post_load_probe_chk_eval0 = ev; + s_post_load_probe_chk_deliv0 = id; + s_post_load_probe_cyc0 = psx_get_cycle_count(); + s_post_load_probe_ci_unit0 = ci_u; + s_post_load_probe_ci_supp0 = ci_s; + s_post_load_probe_ci_none0 = ci_n; + s_post_load_probe_ci_sr0 = ci_sr; + s_post_load_probe_ci_deliv0 = ci_d; + s_post_load_probe_ci_enter0 = ci_e; + s_post_load_probe_adv_calls0 = 0; + s_post_load_probe_adv_sum0 = 0; + s_post_load_probe_svc0 = 0; + s_post_load_probe_dirty_insns0 = g_dirty_ram_insns_run; + s_post_load_probe_dirty_pump0 = g_dirty_pump_count; + s_post_load_probe_stores0 = g_guest_store_count; + s_post_load_probe_idle_n0 = g_idle_skip_count; + s_post_load_probe_idle_cyc0 = g_idle_skip_cycles; + s_post_load_probe_hz_hits0 = hz_h; + s_post_load_probe_hz_cyc0 = hz_c; + s_post_load_probe_host_t0 = SDL_GetPerformanceCounter(); + s_post_load_probe_stall_run = 0; + s_post_load_probe_in_stall = 0; + s_stall_pc_used = 0; + s_live_pc_used = 0; + gl_renderer_present_probe_reset(); + std::fprintf(stderr, + "savestate: post_load_probe armed (300 vblanks; " + "PSX_POST_LOAD_PROBE=1; live_pc/ci/adv diag on)\n"); +} + +static void post_load_probe_on_vblank(int turbo_active, int present_reached) { + if (s_post_load_probe_left <= 0) return; + s_post_load_probe_i++; + s_post_load_probe_left--; + + uint64_t skip = 0, swap = 0, dirty_marks = 0; + int force_left = 0; + gl_renderer_present_probe_take(&skip, &swap, &dirty_marks, &force_left); + s_post_load_probe_skip_tot += skip; + s_post_load_probe_swap_tot += swap; + s_post_load_probe_dirty_tot += dirty_marks; + + const uint64_t gp0_now = gpu_get_gp0_count(); + const uint64_t gp0_delta = gp0_now - s_post_load_probe_gp00; + s_post_load_probe_gp00 = gp0_now; + s_post_load_probe_gp0_tot += gp0_delta; + + extern uint64_t g_vblank_raise_count, g_vblank_deliver_count, g_vblank_ack_count; + extern uint64_t g_dirty_ram_blocks_run; + extern uint32_t i_stat, i_mask; + extern CPUState *debug_cpu_ptr; + const uint64_t vb_r = g_vblank_raise_count - s_post_load_probe_vb_raise0; + const uint64_t vb_d = g_vblank_deliver_count - s_post_load_probe_vb_deliv0; + const uint64_t vb_a = g_vblank_ack_count - s_post_load_probe_vb_ack0; + s_post_load_probe_vb_raise0 = g_vblank_raise_count; + s_post_load_probe_vb_deliv0 = g_vblank_deliver_count; + s_post_load_probe_vb_ack0 = g_vblank_ack_count; + const uint64_t dirty_blks = g_dirty_ram_blocks_run - s_post_load_probe_dirty_blks0; + s_post_load_probe_dirty_blks0 = g_dirty_ram_blocks_run; + + uint32_t tcb = 0, gp_a = 0, gp_b = 0, gp_reg = 0; + uint32_t sr = 0, cause = 0; + int iec = 0, im2 = 0; + if (debug_cpu_ptr) { + gp_reg = debug_cpu_ptr->gpr[28]; + tcb = psx_sched_current_tcb(debug_cpu_ptr); + sr = debug_cpu_ptr->cop0[12]; /* COP0 Status */ + cause = debug_cpu_ptr->cop0[13]; /* COP0 Cause */ + iec = (sr & 0x1u) ? 1 : 0; + im2 = (sr & (1u << 10)) ? 1 : 0; + /* func_8004FD14 frame-counter compare at 0x800501E8. */ + if (debug_cpu_ptr->read_word) { + gp_a = debug_cpu_ptr->read_word(gp_reg + 2552u); + gp_b = debug_cpu_ptr->read_word(gp_reg + 2632u); + } + } + /* Hot-path check_interrupts attribution (not delivery_needed — that only + * samples at present time and was misleading). */ + uint64_t chk_e = 0, chk_fsr = 0, chk_fn = 0, chk_mid = 0, chk_ev = 0, chk_id = 0; + psx_interrupt_check_path_diag(&chk_e, &chk_fsr, &chk_fn, &chk_mid, &chk_ev, &chk_id); + const uint64_t d_entry = chk_e - s_post_load_probe_chk_entry0; + const uint64_t d_fsr = chk_fsr - s_post_load_probe_chk_fsr0; + const uint64_t d_fnone = chk_fn - s_post_load_probe_chk_fnone0; + const uint64_t d_mid = chk_mid - s_post_load_probe_chk_mid0; + const uint64_t d_eval = chk_ev - s_post_load_probe_chk_eval0; + const uint64_t d_irqd = chk_id - s_post_load_probe_chk_deliv0; + s_post_load_probe_chk_entry0 = chk_e; + s_post_load_probe_chk_fsr0 = chk_fsr; + s_post_load_probe_chk_fnone0 = chk_fn; + s_post_load_probe_chk_mid0 = chk_mid; + s_post_load_probe_chk_eval0 = chk_ev; + s_post_load_probe_chk_deliv0 = chk_id; + const uint64_t cyc_now = psx_get_cycle_count(); + const uint64_t d_cyc = cyc_now - s_post_load_probe_cyc0; + s_post_load_probe_cyc0 = cyc_now; + + uint64_t ci_u = 0, ci_s = 0, ci_n = 0, ci_sr = 0, ci_d = 0, ci_e = 0; + overlay_loader_get_ci_skip_diag(&ci_u, &ci_s, &ci_n, &ci_sr, &ci_d, &ci_e); + const uint64_t d_ci_unit = ci_u - s_post_load_probe_ci_unit0; + const uint64_t d_ci_supp = ci_s - s_post_load_probe_ci_supp0; + const uint64_t d_ci_none = ci_n - s_post_load_probe_ci_none0; + const uint64_t d_ci_sr = ci_sr - s_post_load_probe_ci_sr0; + const uint64_t d_ci_deliv = ci_d - s_post_load_probe_ci_deliv0; + const uint64_t d_ci_enter = ci_e - s_post_load_probe_ci_enter0; + s_post_load_probe_ci_unit0 = ci_u; + s_post_load_probe_ci_supp0 = ci_s; + s_post_load_probe_ci_none0 = ci_n; + s_post_load_probe_ci_sr0 = ci_sr; + s_post_load_probe_ci_deliv0 = ci_d; + s_post_load_probe_ci_enter0 = ci_e; + + const uint64_t adv_calls = g_plp_adv_calls; + const uint64_t adv_sum = g_plp_adv_sum; + const uint32_t adv_max = g_plp_adv_max_chunk; + const uint64_t svc_calls = g_plp_svc_calls; + const uint64_t d_adv_calls = adv_calls - s_post_load_probe_adv_calls0; + const uint64_t d_adv_sum = adv_sum - s_post_load_probe_adv_sum0; + const uint64_t d_svc = svc_calls - s_post_load_probe_svc0; + s_post_load_probe_adv_calls0 = adv_calls; + s_post_load_probe_adv_sum0 = adv_sum; + s_post_load_probe_svc0 = svc_calls; + g_plp_adv_max_chunk = 0; /* per-frame max */ + + extern uint64_t g_dirty_ram_insns_run; + extern uint64_t g_dirty_pump_count; + extern uint64_t g_guest_store_count; + const uint64_t d_dirty_insns = g_dirty_ram_insns_run - s_post_load_probe_dirty_insns0; + const uint64_t d_dirty_pump = g_dirty_pump_count - s_post_load_probe_dirty_pump0; + const uint64_t d_stores = g_guest_store_count - s_post_load_probe_stores0; + s_post_load_probe_dirty_insns0 = g_dirty_ram_insns_run; + s_post_load_probe_dirty_pump0 = g_dirty_pump_count; + s_post_load_probe_stores0 = g_guest_store_count; + + const uint64_t d_idle_n = g_idle_skip_count - s_post_load_probe_idle_n0; + const uint64_t d_idle_cyc = g_idle_skip_cycles - s_post_load_probe_idle_cyc0; + s_post_load_probe_idle_n0 = g_idle_skip_count; + s_post_load_probe_idle_cyc0 = g_idle_skip_cycles; + + uint64_t hz_h = 0, hz_c = 0; + psx_vsync_query_hle_horizon_totals(&hz_h, &hz_c); + const uint64_t d_hz_hits = hz_h - s_post_load_probe_hz_hits0; + const uint64_t d_hz_cyc = hz_c - s_post_load_probe_hz_cyc0; + s_post_load_probe_hz_hits0 = hz_h; + s_post_load_probe_hz_cyc0 = hz_c; + + const Uint64 host_now = SDL_GetPerformanceCounter(); + const Uint64 host_freq = SDL_GetPerformanceFrequency(); + const double host_ms = (host_freq > 0) + ? (1000.0 * (double)(host_now - s_post_load_probe_host_t0) / + (double)host_freq) + : 0.0; + s_post_load_probe_host_t0 = host_now; + + GpuDisplayInfo di; + gpu_get_display_info(&di); + const int rect_dirty = (di.width > 0 && di.height > 0) + ? gl_renderer_present_rect_dirty((int)di.display_x, (int)di.display_y, + (int)di.width, (int)di.height) + : 0; + + CDROMDebugState cd; + cdrom_debug_snapshot(&cd); + const int cd_wait = cdrom_savestate_cd_wait_active(); + const int boost_left = cdrom_savestate_boost_vblanks_remaining(); + const int xa = cdrom_xa_stream_active(); + + const uint32_t irq_pc = psx_last_irq_check_pc(); + const uint32_t resume_pc = psx_compiled_irq_resume_pc(); + extern uint32_t g_debug_current_func_addr; + extern uint32_t g_debug_last_store_pc; + extern int g_psx_dispatch_depth; + const uint32_t func = g_debug_current_func_addr; + const uint32_t store_pc = g_debug_last_store_pc; + const uint32_t live_pc = debug_cpu_ptr ? debug_cpu_ptr->pc : 0u; + const uint32_t live_ra = debug_cpu_ptr ? debug_cpu_ptr->gpr[31] : 0u; + const int unit_depth = overlay_loader_call_unit_depth(); + const int disp_depth = g_psx_dispatch_depth; + int cooldown_left = 0; + int in_exc = 0; + psx_get_freeze_diag(NULL, NULL, &in_exc, &cooldown_left, NULL, NULL); + + const int stalled = (gp0_delta == 0); + if (stalled) { + s_post_load_probe_stall_run++; + s_post_load_probe_in_stall = 1; + post_load_probe_stall_pc_note(irq_pc ? irq_pc : resume_pc); + post_load_probe_stall_pc_note(func); + post_load_probe_live_pc_note(live_pc); + } else if (s_post_load_probe_in_stall) { + std::fprintf(stderr, + "post_load_probe STALL_END at #%d after %d vblanks " + "(irq_pc=0x%08X resume=0x%08X func=0x%08X " + "live=0x%08X ra=0x%08X unit=%d disp=%d)\n", + s_post_load_probe_i, s_post_load_probe_stall_run, + (unsigned)irq_pc, (unsigned)resume_pc, (unsigned)func, + (unsigned)live_pc, (unsigned)live_ra, + unit_depth, disp_depth); + post_load_probe_stall_pc_dump("end"); + post_load_probe_live_pc_dump("end"); + s_post_load_probe_in_stall = 0; + s_post_load_probe_stall_run = 0; + s_stall_pc_used = 0; + s_live_pc_used = 0; + } + + /* Dense samples during soft-stall; otherwise first 32 + every 15. */ + const int log_line = + stalled || + (s_post_load_probe_i <= 32) || + (s_post_load_probe_i % 15 == 0) || + (s_post_load_probe_left == 0); + if (log_line) { + std::fprintf(stderr, + "post_load_probe #%d: live=0x%08X ra=0x%08X " + "irq_pc=0x%08X resume=0x%08X func=0x%08X " + "store=0x%08X idle=0x%08X unit=%d disp=%d turbo=%d reached=%d " + "swap=%llu skip=%llu dirty_marks=%llu force=%d rect_dirty=%d " + "fb=%ux%u@(%u,%u) dis=%d d24=%d gp0=%llu " + "cd(pend=%d cmd=0x%02X dly=%d read=%d rdly=%d xa=%d wait=%d boost=%d) " + "exc=%d cool=%d istat=0x%X imask=0x%X " + "vb(r=%llu d=%llu a=%llu) tcb=0x%08X " + "gp9f8=%d gpA48=%d dirty_blks=%llu dins=%llu dpump=%llu stores=%llu " + "sr=0x%08X iec=%d im2=%d cause=0x%08X cyc=%llu host_ms=%.2f " + "chk(e=%llu fsr=%llu fn=%llu mid=%llu eval=%llu irq=%llu) " + "ci(unit=%llu supp=%llu none=%llu sr=%llu deliv=%llu enter=%llu) " + "adv(n=%llu sum=%llu max=%u svc=%llu) " + "idle_skip(n=%llu cyc=%llu) hz(n=%llu cyc=%llu)\n", + s_post_load_probe_i, + (unsigned)live_pc, (unsigned)live_ra, + (unsigned)irq_pc, (unsigned)resume_pc, (unsigned)func, + (unsigned)store_pc, (unsigned)g_idle_skip_last_pc, + unit_depth, disp_depth, + turbo_active, present_reached, + (unsigned long long)swap, (unsigned long long)skip, + (unsigned long long)dirty_marks, force_left, rect_dirty, + (unsigned)di.width, (unsigned)di.height, + (unsigned)di.display_x, (unsigned)di.display_y, + di.disabled ? 1 : 0, di.depth24 ? 1 : 0, + (unsigned long long)gp0_delta, + cd.pending_pending, (unsigned)cd.pending_cmd, cd.pending_delay, + cd.reading, cd.read_delay, xa, cd_wait, boost_left, + in_exc, cooldown_left, (unsigned)i_stat, (unsigned)i_mask, + (unsigned long long)vb_r, (unsigned long long)vb_d, + (unsigned long long)vb_a, (unsigned)tcb, + (int)gp_a, (int)gp_b, (unsigned long long)dirty_blks, + (unsigned long long)d_dirty_insns, (unsigned long long)d_dirty_pump, + (unsigned long long)d_stores, + (unsigned)sr, iec, im2, (unsigned)cause, + (unsigned long long)d_cyc, host_ms, + (unsigned long long)d_entry, (unsigned long long)d_fsr, + (unsigned long long)d_fnone, (unsigned long long)d_mid, + (unsigned long long)d_eval, (unsigned long long)d_irqd, + (unsigned long long)d_ci_unit, (unsigned long long)d_ci_supp, + (unsigned long long)d_ci_none, (unsigned long long)d_ci_sr, + (unsigned long long)d_ci_deliv, (unsigned long long)d_ci_enter, + (unsigned long long)d_adv_calls, (unsigned long long)d_adv_sum, + (unsigned)adv_max, (unsigned long long)d_svc, + (unsigned long long)d_idle_n, (unsigned long long)d_idle_cyc, + (unsigned long long)d_hz_hits, (unsigned long long)d_hz_cyc); + } + if (s_post_load_probe_left == 0) { + if (s_post_load_probe_in_stall) { + post_load_probe_stall_pc_dump("done-still-stalled"); + post_load_probe_live_pc_dump("done-still-stalled"); + } + g_plp_cycle_diag = 0; + std::fprintf(stderr, + "post_load_probe DONE: n=%d swap_tot=%llu skip_tot=%llu " + "dirty_tot=%llu gp0_tot=%llu\n", + s_post_load_probe_i, + (unsigned long long)s_post_load_probe_swap_tot, + (unsigned long long)s_post_load_probe_skip_tot, + (unsigned long long)s_post_load_probe_dirty_tot, + (unsigned long long)s_post_load_probe_gp0_tot); + } +} + /* Called from savestate_poll after a successful restore (before scheduler * longjmp). Clears present latches and forces the next vblank to show the * restored VRAM — including a blank if display was disabled in the snapshot. */ @@ -394,11 +825,14 @@ extern "C" void psx_frontend_on_savestate_loaded(void) { s_frame_pacer = FramePacer{ 0 }; s_fps_last_time = 0; s_fps_last_frame = 0; + /* Re-anchor guest-cycle→sample budgeting (pump clears queued PCM too). */ + g_audio_cycle_resync = 1; /* GL present-dirty early-out can skip SwapWindow when the restored frame * matches the last swap (typical on 2nd+ load of the same slot). Invalidate * tiles + force several swaps so the window actually updates. Safe no-op * when the GL pipeline was never brought up. */ gl_renderer_invalidate_present(); + post_load_probe_arm(); } static uint64_t smooth_60_frame_hash(const uint32_t* pixels, size_t count) { @@ -1273,6 +1707,16 @@ static void sdl_audio_pump(bool discard_output = false) { static uint64_t last_cycles = 0; static uint64_t cycle_carry = 0; const uint64_t now_cycles = psx_cycle_count; + if (g_audio_cycle_resync) { + last_cycles = now_cycles; + cycle_carry = 0; + g_audio_cycle_resync = 0; + if (legacy) + SDL_ClearQueuedAudio(sdl_audio_device); + else + g_audio_unmute_resync = 1; /* skip mute-drain underrun reports */ + return; + } if (last_cycles == 0) last_cycles = now_cycles; uint64_t delta = (now_cycles - last_cycles) + cycle_carry; last_cycles = now_cycles; @@ -3076,16 +3520,59 @@ static void load_transition_note(int read_active, int load_active, prev_turbo = turbo_active; } -/* Depth24 FMV: last ~8 RGB columns can be stale/chroma junk while CRTC width - * stays full (e.g. MotK 512). Present width is never shrunk — cropping the - * GL/SDL rect left a flickering black pillar when upload coverage varied. +/* Tick MotK-style depth24 cutover state once per present. Arm a short full- + * frame blank when MDEC returns after an idle gap (or after leaving depth24). */ +static void depth24_cutover_tick(int depth24) { + const int mdec_on = depth24 && mdec_recently_active(3); + if (!depth24) { + s_d24_prev_mdec = 0; + s_d24_cutover_blank = 0; + s_d24_saw_gap = 1; /* next depth24+MDEC is a fresh movie cutover */ + return; + } + if (!mdec_on) + s_d24_saw_gap = 1; + if (s_d24_saw_gap && mdec_on && !s_d24_prev_mdec) { + /* Hide the transitional present(s) that still show stale RGB888 junk. */ + s_d24_cutover_blank = 2; + s_d24_saw_gap = 0; + } + s_d24_prev_mdec = mdec_on; +} + +/* Depth24 FMV: CRTC width stays full (e.g. MotK 512) while MDEC uploads may + * not cover the right side yet — cutover flashes a large colorful junk block + * when leftover VRAM is read as RGB888. Present width is never shrunk + * (cropping caused a flickering black pillar); black-fill uncovered columns. * - * Do NOT replicate the last good column: on MotK's starfield intro that turns - * a single tinted edge pixel into an 8-wide horizontal streak (flickering - * stretch into the pillar). Black-fill the margin instead. Require a dense - * chroma signal so sparse stars never trip the repair. */ -static void depth24_fix_trailing_margin(uint32_t *buf, uint32_t w, uint32_t h) { - if (!buf || w < 24u || h == 0u) return; + * 0) Cutover hold: full-frame black for the first presents after an MDEC gap + * 1) Upload-span blank: [gpu_depth24_rgb_limit .. w) + * 2) Chroma fringe: if span reports full, still black the last ~8 cols when + * dense chroma junk is present (MotK crawl). Do NOT replicate the last + * good column — that stretched a tinted edge into an 8-wide streak. */ +static void depth24_fix_trailing_margin(uint32_t *buf, uint32_t w, uint32_t h, + uint32_t display_x) { + if (!buf || w == 0u || h == 0u) return; + + if (s_d24_cutover_blank > 0) { + s_d24_cutover_blank--; + const uint32_t n = w * h; + for (uint32_t i = 0; i < n; i++) + buf[i] = 0xFF000000u; + return; + } + + uint32_t good = gpu_depth24_rgb_limit(display_x, w); + if (good > w) good = w; + if (good < w) { + for (uint32_t y = 0; y < h; y++) { + for (uint32_t x = good; x < w; x++) + buf[y * w + x] = 0xFF000000u; + } + return; /* span blank already covered the junk region */ + } + + if (w < 24u) return; const uint32_t margin = 8u; const uint32_t edge = w - margin; const uint32_t total = margin * h; @@ -3112,6 +3599,16 @@ static void depth24_fix_trailing_margin(uint32_t *buf, uint32_t w, uint32_t h) { /* Called from gpu_vblank_tick() at each simulated vblank. */ static void sdl_vblank_present(void) { + int probe_turbo = 0; + int probe_reached = 0; + struct PostLoadProbeScope { + int *turbo; + int *reached; + ~PostLoadProbeScope() { + post_load_probe_on_vblank(*turbo, *reached); + } + } probe_scope{&probe_turbo, &probe_reached}; + #ifndef PSX_NO_DEBUG_TOOLS debug_server_set_fmv_quiet(mdec_recently_active(2)); /* Debug server: pause gate, poll commands, record frame, check watchpoints. */ @@ -3310,8 +3807,10 @@ static void sdl_vblank_present(void) { netplay_barrier_admit(override_); if (skip_pace_ || psx_return_to_lobby_requested()) return; /* Post-starvation / behind-peer catch-up: skip wall pace so admits - * can burn down remote tip (mirrors snes_host_catchup_budget). */ - if (psx_netplay_catchup_budget() > 0) { + * can burn down remote tip (mirrors snes_host_catchup_budget). + * Never unpace during depth24 FMV — catch-up would race XA/video + * ahead of wall clock (~90fps) and make movies play too fast. */ + if (!gpu_display_is_depth24() && psx_netplay_catchup_budget() > 0) { psx_netplay_catchup_consume_frame(); return; } @@ -3356,9 +3855,13 @@ static void sdl_vblank_present(void) { * sampled into SIO. Always-on; queried via the debug server "latency". */ latency_ring_frame_begin(); + /* Post-savestate CD delay boost (ReadTOC/seek clamp window). */ + cdrom_savestate_boost_vblank(); + /* Turbo-active test shared by the pacing/present gate below. */ int turbo_loads_active = 0; - int logical_load_active = fntrace_is_game_started() && cdrom_load_in_progress(); + int logical_load_active = fntrace_is_game_started() && + (cdrom_load_in_progress() || cdrom_savestate_cd_wait_active()); int load_run_value = 0; static int load_run = 0; static int release_run = 0; @@ -3389,6 +3892,7 @@ static void sdl_vblank_present(void) { * old fast_boot snapshot restore; all guest timing is authentic. */ if (psx_bios_hle_boot_turbo_active()) turbo_loads_active = 1; + probe_turbo = turbo_loads_active; load_transition_note(cdrom_data_read_active(), logical_load_active, turbo_loads_active, load_run_value); @@ -3509,12 +4013,12 @@ static void sdl_vblank_present(void) { } } - /* Netplay FMV: skip present every other depth24 vblank (admit still runs - * in the RAII tail). Present-first frame, then alternate. */ + /* Netplay FMV: skip present every other depth24 vblank to cut GPU cost + * (admit + wall pace still run in the RAII tail every tick). Do NOT skip + * pace here — that let MotK movies run ~90fps once decode was fast enough. */ if (psx_netplay_active() && gpu_display_is_depth24()) { if (s_netplay_depth24_present_skip) { s_netplay_depth24_present_skip = 0; - netplay_tail.skip_pace(); return; } s_netplay_depth24_present_skip = 1; @@ -3561,6 +4065,7 @@ static void sdl_vblank_present(void) { } /* ---- Display from our VRAM ---- */ + probe_reached = 1; uint32_t w = 0, h = 0; uint32_t present_w = 0; /* display width actually presented (w + native-wide EXTRA) */ int active_scale = 1; /* hi-res mirror used only for 15-bit display */ @@ -3574,6 +4079,7 @@ static void sdl_vblank_present(void) { GpuDisplayInfo di; gpu_get_display_info(&di); depth24_frame = di.depth24 != 0; + depth24_cutover_tick(depth24_frame ? 1 : 0); if (di.disabled || di.width == 0 || di.height == 0) { smooth_60_present(nullptr, 0, 0, false); present_ring_commit(PRES_PATH_BLANK, (uint16_t)di.width, @@ -3684,9 +4190,10 @@ static void sdl_vblank_present(void) { for (uint32_t x = 0; x < present_w; x++) sdl_pixel_buf[y * present_w + x] = gpu_display_pixel_argb(&di, x, y); - /* Trailing margin: replicate last good column into junk cols + /* Trailing / cutover blank: black-fill uncovered RGB cols * inside the full-width buffer — never shrink present width. */ - depth24_fix_trailing_margin(sdl_pixel_buf, present_w, h); + depth24_fix_trailing_margin(sdl_pixel_buf, present_w, h, + di.display_x); vk_renderer_present_cpu(sdl_pixel_buf, (int)present_w, (int)h, 0 /* nearest */, fmv_frame ? 1 : 0); } else if (wide_present && @@ -3748,11 +4255,12 @@ static void sdl_vblank_present(void) { } } - /* Depth24 trailing margin: MotK CRTC is 512 RGB but the last ~8 cols - * can be stale. Fix pixels in-place at full present_w — never crop the - * GL/SDL draw width (that caused a flickering black pillar). */ + /* Depth24 trailing / cutover blank: MotK CRTC is 512 RGB but uploads + * may not cover the right side yet (colorful junk flash). Fix pixels + * in-place at full present_w — never crop the GL/SDL draw width. */ if (di.depth24 && active_scale == 1 && !wide_present) - depth24_fix_trailing_margin(sdl_pixel_buf, present_w, h); + depth24_fix_trailing_margin(sdl_pixel_buf, present_w, h, + di.display_x); smooth_60_present(sdl_pixel_buf, present_w * (uint32_t)active_scale, @@ -5754,7 +6262,7 @@ namespace { out->is_host = (std::strcmp(host_id, mem.player_id) == 0) ? 1 : 0; else out->is_host = (mem.slot == 0) ? 1 : 0; - out->latency_ms = -1; + out->latency_ms = psx_lobby_member_latency_ms(mem.slot); return 1; } if (ae_np_use_lan_members()) { @@ -5786,7 +6294,7 @@ namespace { out->is_host = (std::strcmp(host_id, mem.player_id) == 0) ? 1 : 0; else out->is_host = (mem.slot == 0) ? 1 : 0; - out->latency_ms = -1; + out->latency_ms = psx_lobby_member_latency_ms(mem.slot); return 1; } @@ -6051,10 +6559,16 @@ int main(int argc, char** argv) { std::setvbuf(stderr, nullptr, _IOLBF, 0); std::fprintf(stderr, "psxrecomp: main() entered\n"); std::fflush(stderr); +#if defined(RECOMP_LAUNCHER) + launcher_boot_timing_mark("host:main_enter"); +#endif /* Install crash handlers early so they catch issues during init too. * Writes psx_last_run_report.json on signal/SEH/atexit/fail-fast. */ psx_crash_trace_install_handlers(); +#if defined(RECOMP_LAUNCHER) + launcher_boot_timing_mark("host:crash_handlers"); +#endif const char* bios_path = PSX_DEFAULT_BIOS_PATH; const char* game_config_path = nullptr; @@ -6248,6 +6762,19 @@ std::string player_device[PSX_MAX_PLAYERS]; std::string text_guard_exe_path; uint32_t text_guard_load_addr = 0; + /* Overlay cache init is deferred until after the launcher window so ABI + * preflight / resident DLL loads do not delay first paint. */ + bool deferred_overlay_cache = false; + std::filesystem::path deferred_overlay_project_root; + std::vector deferred_overlay_native_block; + std::string deferred_overlay_backend; + bool deferred_has_overlay_ac = false; + std::string deferred_overlay_ac; + bool deferred_has_overlay_ac_tcc = false; + std::string deferred_overlay_ac_tcc; + bool deferred_overlay_capture_history = false; + std::string deferred_overlay_capture_persist_dir; + if (game_config_path) { try { const auto gc = PSXRecompV4::load_game_config(game_config_path); @@ -6474,167 +7001,20 @@ std::string player_device[PSX_MAX_PLAYERS]; "psxrecomp: overlay_region_floor = 0x%05X (game text end)\n", g_overlay_region_floor); } - /* Overlay DLL cache (Layer A). Off unless enabled in [runtime]; - * when on, capture overlay bytes and scan cache// for - * precompiled overlay DLLs. */ + /* Overlay DLL cache (Layer A): stash config now; heavy init + * (cache scan / ABI preflight / resident LoadLibrary) runs after + * the launcher window so first UI paint is not blocked. */ if (gc.runtime.overlay_cache) { - std::filesystem::path exe_dir = exe_dir_from_argv(argv[0]); - std::string cache_dir = (exe_dir / "cache").string(); - std::filesystem::path captures_path = - resolve_overlay_capture_path(gc.project_root, exe_dir, game_id); - if (!overlay_capture_set_path(captures_path.string().c_str())) { - captures_path = exe_dir / "overlay_captures.json"; - if (!overlay_capture_set_path(captures_path.string().c_str())) - throw std::runtime_error("overlay capture path exceeds runtime limit"); - } - overlay_capture_set_enabled(1); - std::fprintf(stdout, - "psxrecomp: additive overlay capture store = %s (+ .d history)\n", - captures_path.string().c_str()); - std::string capture_persist_dir; - if (gc.runtime.overlay_capture_history && - !gc.runtime.overlay_capture_persist_dir.empty()) { - std::filesystem::path persist = - gc.project_root / gc.runtime.overlay_capture_persist_dir; - std::error_code persist_ec; - std::filesystem::create_directories(persist, persist_ec); - if (persist_ec) { - std::fprintf(stderr, - "psxrecomp: cannot create overlay capture history %s: %s\n", - persist.string().c_str(), persist_ec.message().c_str()); - } else { - capture_persist_dir = persist.string(); - } - } - overlay_capture_configure_history( - gc.runtime.overlay_capture_history ? 1 : 0, - capture_persist_dir.empty() ? nullptr : - capture_persist_dir.c_str(), - game_id.c_str()); - overlay_loader_init(cache_dir.c_str(), game_id.c_str()); - for (uint32_t addr : gc.runtime.overlay_native_block) { - overlay_loader_native_block_add(addr); - } - if (!gc.runtime.overlay_native_block.empty()) { - std::fprintf(stdout, - "psxrecomp: overlay native blocklist seeded with %zu entr%s\n", - gc.runtime.overlay_native_block.size(), - gc.runtime.overlay_native_block.size() == 1 ? "y" : "ies"); - } - /* Scoped pre-DMA journaling and the shutdown snapshot remain - * active whenever the cache is on, including toolchain-less - * production machines. The periodic pressure trigger exists to - * feed a live compiler; leave it off when no provider command - * exists, otherwise it rewrites manifests every cooldown while - * being unable to reduce interpreter residency. */ - overlay_autocapture_set_enabled(0); - /* Resolve the overlay tier first so we wire the RIGHT compiler's - * autocompile command. gcc is "available" only when a gcc cmd is - * configured AND a gcc toolchain is actually reachable (a real - * dev/production box). auto => gcc if so, else tcc; auto-no-gcc => - * tcc even with gcc present (simulate a toolchain-less user box). - * env PSX_OVERLAY_BACKEND overrides. Tiers: static > gcc > tcc > - * interp. */ - const char *cfg_backend = gc.runtime.overlay_backend.empty() - ? nullptr : gc.runtime.overlay_backend.c_str(); - int gcc_avail = gc.runtime.has_overlay_autocompile_cmd - && autocompile_toolchain_available(); - OverlayBackend eff = overlay_backend_resolve(cfg_backend, gcc_avail); - /* gcc and tcc run the IDENTICAL recompiler->C->DLL->load pipeline; - * only the compiler binary differs. Wire the autocompile spawn with - * the command for the resolved tier (tcc cmd for the tcc tier, gcc - * cmd otherwise). gcc shards already on disk still LOAD either way - * (the loader is compiler-blind), so a tcc box uses shipped gcc - * shards first and fills the rest with tcc. */ - std::string built_tcc_cmd; /* runtime-constructed bundled tcc cmd */ - std::string env_ac_cmd; - const std::string *ac_cmd = nullptr; - if (eff == OVERLAY_BACKEND_TCC) { - if (gc.runtime.has_overlay_autocompile_cmd_tcc) { - ac_cmd = &gc.runtime.overlay_autocompile_cmd_tcc; /* explicit override (dev) */ - } else { - /* PRODUCTION: construct the tcc autocompile cmd from the - * self-contained toolchain bundled beside the exe - * (/overlay_toolchain/ = embedded python + tcc + - * recompiler + compile_overlays.py + runtime headers). No - * system python or gcc required. */ - extern int g_psx_cps_mode; - std::filesystem::path xd = exe_dir_from_argv(argv[0]); - std::filesystem::path tk = xd / "overlay_toolchain"; - std::filesystem::path py = tk / "python" / "python.exe"; - if (std::filesystem::exists(py)) { - auto cmd_quote = [](const std::string& s) { - return std::string("\"") + s + "\""; - }; - built_tcc_cmd = - cmd_quote(py.string()) + " " + - cmd_quote((tk / "compile_overlays.py").string()) + - " --captures " + cmd_quote(captures_path.string()) + - " --game-toml " + cmd_quote(std::string( - game_config_path ? game_config_path : "game.toml")) + - " --recompiler " + cmd_quote((tk / "psxrecomp-game.exe").string()) + - " --runtime-include " + cmd_quote((tk / "include").string()) + - " --out-dir " + cmd_quote((xd / "cache").string()) + - (g_psx_cps_mode ? " --cps" : "") + - " --compiler tcc --tcc " + - cmd_quote((tk / "tcc" / "tcc.exe").string()); - ac_cmd = &built_tcc_cmd; - std::fprintf(stdout, - "psxrecomp: tcc tier using bundled toolchain (%s)\n", - tk.string().c_str()); - } else { - std::fprintf(stdout, - "psxrecomp: tcc tier active but no bundled toolchain at %s " - "(overlay gaps -> interpreter)\n", tk.string().c_str()); - } - } - } else { - if (gc.runtime.has_overlay_autocompile_cmd) - ac_cmd = &gc.runtime.overlay_autocompile_cmd; - } - /* A developer may run a game config from one checkout against a - * runtime/recompiler built in another worktree. Let the launch - * pin the producer command to that exact worktree so the baked - * codegen hash, additive-capture reader, and runtime headers - * cannot silently drift through a game-repo junction. */ - if (const char *e = std::getenv("PSX_OVERLAY_AUTOCOMPILE_CMD")) { - if (e[0]) { - env_ac_cmd = e; - ac_cmd = &env_ac_cmd; - std::fprintf(stdout, - "psxrecomp: overlay autocompile command overridden by environment\n"); - } - } - if (const char *e = std::getenv("PSX_OVERLAY_AUTOCOMPILE_OFF")) { - if (e[0] && e[0] != '0') { - ac_cmd = nullptr; - std::fprintf(stdout, - "psxrecomp: overlay autocompile disabled by environment\n"); - } - } - if (ac_cmd) { - /* Pin the compile's WRITE cache + READ captures to the SAME - * canonical locations the loader uses (cache_dir = /cache, - * /overlay_captures.json — set above). The framework owns - * the cache location; no game.toml --out-dir/--captures can make - * the write drift from the read. Single source of truth, all - * games, dev or prod. */ - autocompile_set_cache_paths(cache_dir.c_str(), - captures_path.string().c_str()); - std::string ac_cwd = gc.project_root.string(); - if (const char *e = std::getenv("PSX_OVERLAY_AUTOCOMPILE_CWD")) { - if (e[0]) ac_cwd = e; - } - autocompile_configure(ac_cmd->c_str(), ac_cwd.c_str()); - overlay_autocapture_set_enabled(1); - std::fprintf(stdout, - "psxrecomp: overlay autocompile enabled (%s); cache=%s; captures=%s\n", - overlay_backend_name(eff), cache_dir.c_str(), - captures_path.string().c_str()); - } - code_provider_init(cfg_backend, gcc_avail); - /* (sljit removed 2026-07-15: overlay_loader_apply_live_policy was - * called here once the backend resolved.) */ + deferred_overlay_cache = true; + deferred_overlay_project_root = gc.project_root; + deferred_overlay_native_block = gc.runtime.overlay_native_block; + deferred_overlay_backend = gc.runtime.overlay_backend; + deferred_has_overlay_ac = gc.runtime.has_overlay_autocompile_cmd; + deferred_overlay_ac = gc.runtime.overlay_autocompile_cmd; + deferred_has_overlay_ac_tcc = gc.runtime.has_overlay_autocompile_cmd_tcc; + deferred_overlay_ac_tcc = gc.runtime.overlay_autocompile_cmd_tcc; + deferred_overlay_capture_history = gc.runtime.overlay_capture_history; + deferred_overlay_capture_persist_dir = gc.runtime.overlay_capture_persist_dir; } std::fprintf(stdout, "psxrecomp: loaded game config %s (%s, %s)\n", game_config_path, game_name.c_str(), game_id.c_str()); @@ -6644,6 +7024,9 @@ std::string player_device[PSX_MAX_PLAYERS]; return 1; } } +#if defined(RECOMP_LAUNCHER) + launcher_boot_timing_mark("host:game_config_done"); +#endif if (!game_name.empty()) s_picker_game_name = game_name; @@ -6861,12 +7244,155 @@ std::string player_device[PSX_MAX_PLAYERS]; } } +#if defined(RECOMP_LAUNCHER) + launcher_boot_timing_mark("host:pre_overlay_worker"); +#endif + /* Overlay cache: run ABI preflight / resident DLL loads on a worker so the + * launcher can open immediately and init overlaps with UI time. Join before + * guest boot. When the launcher is skipped, the join still runs below. */ + std::thread overlay_init_thread; + std::exception_ptr overlay_init_exc; + auto run_deferred_overlay_init = [&]() { + std::filesystem::path exe_dir = exe_dir_from_argv(argv[0]); + std::string cache_dir = (exe_dir / "cache").string(); + std::filesystem::path captures_path = + resolve_overlay_capture_path(deferred_overlay_project_root, exe_dir, game_id); + if (!overlay_capture_set_path(captures_path.string().c_str())) { + captures_path = exe_dir / "overlay_captures.json"; + if (!overlay_capture_set_path(captures_path.string().c_str())) { + throw std::runtime_error( + "overlay capture path exceeds runtime limit"); + } + } + overlay_capture_set_enabled(1); + std::fprintf(stdout, + "psxrecomp: additive overlay capture store = %s (+ .d history)\n", + captures_path.string().c_str()); + std::string capture_persist_dir; + if (deferred_overlay_capture_history && + !deferred_overlay_capture_persist_dir.empty()) { + std::filesystem::path persist = + deferred_overlay_project_root / deferred_overlay_capture_persist_dir; + std::error_code persist_ec; + std::filesystem::create_directories(persist, persist_ec); + if (persist_ec) { + std::fprintf(stderr, + "psxrecomp: cannot create overlay capture history %s: %s\n", + persist.string().c_str(), persist_ec.message().c_str()); + } else { + capture_persist_dir = persist.string(); + } + } + overlay_capture_configure_history( + deferred_overlay_capture_history ? 1 : 0, + capture_persist_dir.empty() ? nullptr : + capture_persist_dir.c_str(), + game_id.c_str()); + overlay_loader_init(cache_dir.c_str(), game_id.c_str()); + for (uint32_t addr : deferred_overlay_native_block) { + overlay_loader_native_block_add(addr); + } + if (!deferred_overlay_native_block.empty()) { + std::fprintf(stdout, + "psxrecomp: overlay native blocklist seeded with %zu entr%s\n", + deferred_overlay_native_block.size(), + deferred_overlay_native_block.size() == 1 ? "y" : "ies"); + } + overlay_autocapture_set_enabled(0); + const char *cfg_backend = deferred_overlay_backend.empty() + ? nullptr : deferred_overlay_backend.c_str(); + int gcc_avail = deferred_has_overlay_ac + && autocompile_toolchain_available(); + OverlayBackend eff = overlay_backend_resolve(cfg_backend, gcc_avail); + std::string built_tcc_cmd; + std::string env_ac_cmd; + const std::string *ac_cmd = nullptr; + if (eff == OVERLAY_BACKEND_TCC) { + if (deferred_has_overlay_ac_tcc) { + ac_cmd = &deferred_overlay_ac_tcc; + } else { + extern int g_psx_cps_mode; + std::filesystem::path xd = exe_dir_from_argv(argv[0]); + std::filesystem::path tk = xd / "overlay_toolchain"; + std::filesystem::path py = tk / "python" / "python.exe"; + if (std::filesystem::exists(py)) { + auto cmd_quote = [](const std::string& s) { + return std::string("\"") + s + "\""; + }; + built_tcc_cmd = + cmd_quote(py.string()) + " " + + cmd_quote((tk / "compile_overlays.py").string()) + + " --captures " + cmd_quote(captures_path.string()) + + " --game-toml " + cmd_quote(std::string( + game_config_path ? game_config_path : "game.toml")) + + " --recompiler " + cmd_quote((tk / "psxrecomp-game.exe").string()) + + " --runtime-include " + cmd_quote((tk / "include").string()) + + " --out-dir " + cmd_quote((xd / "cache").string()) + + (g_psx_cps_mode ? " --cps" : "") + + " --compiler tcc --tcc " + + cmd_quote((tk / "tcc" / "tcc.exe").string()); + ac_cmd = &built_tcc_cmd; + std::fprintf(stdout, + "psxrecomp: tcc tier using bundled toolchain (%s)\n", + tk.string().c_str()); + } else { + std::fprintf(stdout, + "psxrecomp: tcc tier active but no bundled toolchain at %s " + "(overlay gaps -> interpreter)\n", tk.string().c_str()); + } + } + } else { + if (deferred_has_overlay_ac) + ac_cmd = &deferred_overlay_ac; + } + if (const char *e = std::getenv("PSX_OVERLAY_AUTOCOMPILE_CMD")) { + if (e[0]) { + env_ac_cmd = e; + ac_cmd = &env_ac_cmd; + std::fprintf(stdout, + "psxrecomp: overlay autocompile command overridden by environment\n"); + } + } + if (const char *e = std::getenv("PSX_OVERLAY_AUTOCOMPILE_OFF")) { + if (e[0] && e[0] != '0') { + ac_cmd = nullptr; + std::fprintf(stdout, + "psxrecomp: overlay autocompile disabled by environment\n"); + } + } + if (ac_cmd) { + autocompile_set_cache_paths(cache_dir.c_str(), + captures_path.string().c_str()); + std::string ac_cwd = deferred_overlay_project_root.string(); + if (const char *e = std::getenv("PSX_OVERLAY_AUTOCOMPILE_CWD")) { + if (e[0]) ac_cwd = e; + } + autocompile_configure(ac_cmd->c_str(), ac_cwd.c_str()); + overlay_autocapture_set_enabled(1); + std::fprintf(stdout, + "psxrecomp: overlay autocompile enabled (%s); cache=%s; captures=%s\n", + overlay_backend_name(eff), cache_dir.c_str(), + captures_path.string().c_str()); + } + code_provider_init(cfg_backend, gcc_avail); + }; + + if (deferred_overlay_cache) { + overlay_init_thread = std::thread([&]() { + try { + run_deferred_overlay_init(); + } catch (...) { + overlay_init_exc = std::current_exception(); + } + }); + } + #if defined(RECOMP_LAUNCHER) /* Integrated recomp-ui launcher: shown in its own GL window before the emulator * boots. Seeded with the effective settings (game.toml ∪ settings.toml); * on LAUNCH the user's choices are persisted to settings.toml and applied. - * The launcher window/context is fully torn down before the emulator's own - * window is created, so the emulator boot path below is untouched. + * The launcher window/GL context is destroyed afterward, but SDL subsystems + * stay initialized so the game window can open without a second SDL_Init. * * Skip the GUI (boot straight in) when ANY of: PSX_NO_LAUNCHER=1 env, * --no-launcher, or the persisted [launcher] skip_launcher setting — unless @@ -6876,7 +7402,10 @@ std::string player_device[PSX_MAX_PLAYERS]; force_launcher || (!std::getenv("PSX_NO_LAUNCHER") && !force_no_launcher && !skip_launcher_setting); if (want_launcher) { + launcher_boot_timing_mark("host:before_sdl_init"); if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) == 0) { + launcher_boot_timing_mark("host:after_sdl_init"); + recomp_launcher_set_preserve_sdl(1); int lr = 2; /* 0 = launch, 1 = quit, 2 = unavailable */ PSXRecompV4::UserSettings seed; seed.renderer = g_video_renderer; seed.has_renderer = true; @@ -7124,6 +7653,7 @@ std::string player_device[PSX_MAX_PLAYERS]; } gi.needs_setup = (!bios_ok || !disc_ok) ? 1 : 0; } + launcher_boot_timing_mark("host:setup_checks_done"); #if defined(PSX_HAS_RECOMP_NET) && defined(PSX_HAS_LOBBY_CLIENT) g_lnch_netplay_game_name = game_name.empty() ? "PSX" : game_name; g_lnch_game_players = game_players; @@ -7137,9 +7667,11 @@ std::string player_device[PSX_MAX_PLAYERS]; #endif char rui_out_disc[1024] = {0}; + launcher_boot_timing_mark("host:before_run_window"); int rui_rc = recomp_launcher_run_window( rui_title.c_str(), &ls, &gi, assets_dir_str.c_str(), rui_initial_disc.c_str(), rui_out_disc, sizeof(rui_out_disc)); + launcher_boot_timing_mark("host:after_run_window"); lr = rui_rc; @@ -7252,6 +7784,9 @@ std::string player_device[PSX_MAX_PLAYERS]; if (lr == 1) { std::fprintf(stdout, "psxrecomp: launcher closed; exiting.\n"); + if (overlay_init_thread.joinable()) + overlay_init_thread.join(); + SDL_Quit(); return 0; } if (lr == 0) { @@ -7332,6 +7867,20 @@ std::string player_device[PSX_MAX_PLAYERS]; } #endif + if (overlay_init_thread.joinable()) { + overlay_init_thread.join(); + if (overlay_init_exc) { + try { + std::rethrow_exception(overlay_init_exc); + } catch (const std::exception& ex) { + std::fprintf(stderr, "psxrecomp: overlay cache init failed: %s\n", + ex.what()); + return 1; + } + } + } + + /* Re-apply the resolved language to the translation layer. text_xlate_init * (at config load) only saw the game.toml default; this folds in the * settings.toml override and the launcher's choice. No-op when unchanged. */ @@ -7845,10 +8394,14 @@ std::string player_device[PSX_MAX_PLAYERS]; nrc, net_cfg.local_slot, net_cfg.bind_hostport, net_cfg.peer_hostport); return 1; } - std::printf("psxrecomp: netplay LAN slot=%d input_player=%d delay=%d " - "bind=%s peer=%s session=%u\n", + std::printf("psxrecomp: netplay transport=%s slot=%d input_player=%d delay=%d " + "force_turn=%d bind=%s peer=%s session=%u\n", + psx_netplay_transport_name(), net_cfg.local_slot, net_cfg.input_player, net_cfg.input_delay, - net_cfg.bind_hostport, net_cfg.peer_hostport, + net_cfg.force_turn ? 1 : 0, + net_cfg.bind_hostport, + (std::strcmp(psx_netplay_transport_name(), "ice") == 0) + ? "(ice)" : net_cfg.peer_hostport, (unsigned)net_cfg.session_id); } @@ -8219,6 +8772,7 @@ std::string player_device[PSX_MAX_PLAYERS]; gi.memcard_inspect = ae_memcard_inspect; char rui_out_disc[1024] = {0}; + recomp_launcher_set_preserve_sdl(1); const int rui_rc = recomp_launcher_run_window( rui_title.c_str(), &ls, &gi, assets_dir_str.c_str(), rui_initial_disc.c_str(), rui_out_disc, sizeof(rui_out_disc)); diff --git a/runtime/src/memory.c b/runtime/src/memory.c index caa2ae137..a1ce18405 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -557,6 +557,9 @@ void dirty_ram_set_bitmap_words(const uint32_t* words, uint32_t count) { if (count > DIRTY_RAM_BITMAP_WORDS) count = DIRTY_RAM_BITMAP_WORDS; for (uint32_t i = 0; i < count; i++) dirty_ram_bitmap[i] = words[i]; + /* Bitmap replace bypasses clean→dirty transitions; bump so interpreter + * site caches keyed on g_dirty_ram_code_gen cannot survive a restore. */ + g_dirty_ram_code_gen++; } /* ---- Inc3: watched overlay pages + per-page generation counters --------- @@ -610,6 +613,17 @@ uint32_t overlay_watch_pagegen_sum(uint32_t phys, uint32_t len) { return sum; } +/* Savestate restores RAM via memcpy and never hits the store chokepoint that + * bumps overlay_page_gen. Without this, ENTRY_VALID overlays keep the gen-gated + * fast path and run native code against restored bytes they were not validated + * for — hang / freeze after the restored frame presents. */ +void overlay_watch_invalidate_after_ram_restore(void) { + for (uint32_t pg = 0; pg < DIRTY_RAM_PAGE_COUNT; pg++) + overlay_page_gen[pg]++; + extern void overlay_loader_note_code_write(void); + overlay_loader_note_code_write(); +} + static inline void overlay_watch_note_write(uint32_t phys, uint32_t size) { uint32_t pg = phys >> DIRTY_RAM_PAGE_SHIFT; if (pg >= DIRTY_RAM_PAGE_COUNT) return; diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c index cd17a6ee7..8ad52db1d 100644 --- a/runtime/src/overlay_loader.c +++ b/runtime/src/overlay_loader.c @@ -2034,6 +2034,28 @@ void overlay_loader_get_irq_suppress(int *mode, uint32_t *rl, uint64_t *supp) { if (supp) *supp = s_irq_suppressed; } +/* Overlay CI-wrapper attribution (post-load freeze): early returns never enter + * psx_check_interrupts, so s_irq_path_entry stays flat while cycles still + * advance inside native overlay / call-unit regions. */ +static uint64_t s_ci_skip_unit; +static uint64_t s_ci_skip_supp; +static uint64_t s_ci_skip_none; +static uint64_t s_ci_skip_sr; +static uint64_t s_ci_skip_deliv; +static uint64_t s_ci_enter; + +void overlay_loader_get_ci_skip_diag(uint64_t *unit, uint64_t *supp, + uint64_t *none, uint64_t *sr, + uint64_t *deliv, uint64_t *enter) { + if (unit) *unit = s_ci_skip_unit; + if (supp) *supp = s_ci_skip_supp; + if (none) *none = s_ci_skip_none; + if (sr) *sr = s_ci_skip_sr; + if (deliv) *deliv = s_ci_skip_deliv; + if (enter) *enter = s_ci_enter; +} +int overlay_loader_call_unit_depth(void) { return g_call_unit_depth; } + static int overlay_irq_suppressed_now(void) { /* Differential replay (and its authoritative interpreter pass) is atomic. * Never let a previously armed rate-limit punch a real IRQ into a shadow. */ @@ -2064,15 +2086,19 @@ static int overlay_irq_suppressed_now(void) { static void overlay_ci_wrapper(CPUState *cpu) { /* Defer while inside a nested call unit — a callee must not interrupt * mid-call (static-call atomicity). See g_call_unit_depth. */ - if (g_call_unit_depth > 0) return; - if (overlay_irq_suppressed_now()) return; + if (g_call_unit_depth > 0) { s_ci_skip_unit++; return; } + if (overlay_irq_suppressed_now()) { s_ci_skip_supp++; return; } /* psx_advance_cycles() has already raised every device edge due at this * block. Avoid entering the full scheduler/diagnostic path when COP0 could * not take the IRQ anyway. FMV polling loops can execute this edge millions * of times while an INTC bit is pending but IEc is deliberately clear. */ - if ((i_stat & i_mask) == 0) return; - if ((cpu->cop0[12] & ((1u << 10) | 1u)) != ((1u << 10) | 1u)) return; - if (!psx_interrupt_delivery_needed(cpu)) return; + if ((i_stat & i_mask) == 0) { s_ci_skip_none++; return; } + if ((cpu->cop0[12] & ((1u << 10) | 1u)) != ((1u << 10) | 1u)) { + s_ci_skip_sr++; + return; + } + if (!psx_interrupt_delivery_needed(cpu)) { s_ci_skip_deliv++; return; } + s_ci_enter++; if (s_irq_defer_cdrom && (i_stat & (1u << IRQ_CDROM))) { uint32_t saved_cd = i_stat & (1u << IRQ_CDROM); i_stat &= ~(1u << IRQ_CDROM); @@ -2100,11 +2126,15 @@ static void overlay_ci_at_wrapper(CPUState *cpu, uint32_t resume_pc) { /* Defer while inside a nested call unit (see g_call_unit_depth): suspending * here would save resume_pc at the callee's block leader while the enclosing * dirty caller expects an atomic unit — the resume-desync bug. */ - if (g_call_unit_depth > 0) return; - if (overlay_irq_suppressed_now()) return; - if ((i_stat & i_mask) == 0) return; - if ((cpu->cop0[12] & ((1u << 10) | 1u)) != ((1u << 10) | 1u)) return; - if (!psx_interrupt_delivery_needed(cpu)) return; + if (g_call_unit_depth > 0) { s_ci_skip_unit++; return; } + if (overlay_irq_suppressed_now()) { s_ci_skip_supp++; return; } + if ((i_stat & i_mask) == 0) { s_ci_skip_none++; return; } + if ((cpu->cop0[12] & ((1u << 10) | 1u)) != ((1u << 10) | 1u)) { + s_ci_skip_sr++; + return; + } + if (!psx_interrupt_delivery_needed(cpu)) { s_ci_skip_deliv++; return; } + s_ci_enter++; extern int g_idle_note_suppress; int suppress_idle_note = overlay_idle_note_is_internal_or_return(cpu, resume_pc); if (suppress_idle_note) g_idle_note_suppress++; diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index 72cbe9b49..b9e65afa6 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -3,6 +3,7 @@ #include "psx_cycles.h" #include "cpu_state.h" #include +#include #if defined(_MSC_VER) #include /* MSVC intrinsics: _BitScanReverse (no __builtin_clz) */ #endif @@ -107,6 +108,11 @@ static uint64_t s_devices_synced_cycle = 0; /* devices are advanced up to here /* Exported for the inlined psx_advance_cycles / psx_cyc_charge hot path. */ uint64_t psx_next_service_cycle = 0; /* absolute; 0 = dirty, recompute */ int psx_in_device_service = 0; /* re-entrancy guard */ +int g_plp_cycle_diag = 0; +uint64_t g_plp_adv_calls = 0; +uint32_t g_plp_adv_max_chunk = 0; +uint64_t g_plp_adv_sum = 0; +uint64_t g_plp_svc_calls = 0; #define s_next_service_cycle psx_next_service_cycle #define s_in_device_service psx_in_device_service static uint64_t s_next_watchdog = 0; @@ -158,6 +164,7 @@ static void psx_devices_recompute_deadline(void) { void psx_devices_service_to_now(void) { if (s_in_device_service) return; /* device code charged cycles: absorb */ + if (g_plp_cycle_diag) g_plp_svc_calls++; g_psx_cycle_fast_limit = 0; s_in_device_service = 1; uint64_t target = psx_cycle_count; @@ -518,13 +525,35 @@ void psx_idle_note_check(CPUState *cpu, uint32_t check_pc) { * the deadline-model bookkeeping (synced position + next deadline) is stale and * would try to replay a bogus gap. Re-anchor devices at the restored cycle and * force a fresh deadline on the next charge. */ -void psx_cycles_resync_after_restore(void) { +void psx_cycles_resync_after_restore(CPUState *cpu) { g_psx_cyc_batch = 0; g_psx_cyc_batch_limit = 0; g_psx_cyc_bb_defer = 0; s_devices_synced_cycle = psx_cycle_count; psx_next_service_cycle = 0; /* recompute on next charge */ psx_in_device_service = 0; + /* Idle-skip detector latches absolute cycle/store counters from the + * pre-load timeline; drop them so a rewound clock cannot false-train. */ + s_idle_pc = 0; + s_idle_streak = 0; + s_idle_have_snap = 0; + s_idle_progress_reg = -2; + s_idle_last_cycle = psx_cycle_count; + /* GTE/muldiv completion deadlines and load-absorb give-back are host-only + * absolute cycle stamps (not in BS_SEC_CPU). After a warm load they still + * hold the pre-load live timeline; the next psx_gte_stall / muldiv_stall + * 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; + } } void psx_cycles_reset_for_boot(void) { diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index 896bbd507..5f3e5bd3b 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -51,6 +51,33 @@ const PsxLobbyMatchCaps *psx_lobby_match_caps(void) int psx_lobby_set_match_caps(const PsxLobbyMatchCaps *c) { (void)c; return -1; } int psx_lobby_member_count(void) { return 0; } int psx_lobby_member_get(int index, PsxLobbyMember *out) { (void)index; (void)out; return 0; } +int psx_lobby_member_latency_ms(int slot) { (void)slot; return -1; } +int psx_lobby_member_is_host(const PsxLobbyMember *member) +{ + (void)member; + return 0; +} +int psx_lobby_send_signal(int type, int flag, const char *text) +{ + (void)type; + (void)flag; + (void)text; + return -1; +} +int psx_lobby_poll_signal(int *type, int *flag, char *text, size_t text_cap) +{ + (void)type; + (void)flag; + (void)text; + (void)text_cap; + return 0; +} +int psx_lobby_request_turn_credentials(void) { return -1; } +const PsxLobbyTurnCredentials *psx_lobby_turn_credentials(void) +{ + static PsxLobbyTurnCredentials z; + return &z; +} int psx_lobby_local_ready(void) { return 0; } int psx_lobby_all_ready(void) { return 0; } int psx_lobby_set_ready(int ready) { (void)ready; return -1; } @@ -118,6 +145,22 @@ typedef struct { PsxLobbyMatchCaps match_caps; char pending_tx[8][2048]; int pending_n; + /* Inbound ICE signals (WS op:signal). */ + struct { + int type; + int flag; + char text[2048]; + } sig_q[32]; + int sig_head; + int sig_tail; + int sig_count; + /* Coturn mint from WS get_turn_credentials. */ + PsxLobbyTurnCredentials turn; + time_t turn_received_at; + int turn_request_pending; + /* Waiting-room latency (ms) keyed by pad slot; -1 = unknown. */ + int member_rtt_ms[PSX_LOBBY_MAX_MEMBERS]; + uint64_t rtt_next_ping_ms; } LobbyClient; static LobbyClient g_lc = { @@ -125,6 +168,51 @@ static LobbyClient g_lc = { .filter_game_version = PSX_GAME_VERSION, }; +enum { + PSX_LOBBY_SIG_RTT_PING = 100, + PSX_LOBBY_SIG_RTT_PONG = 101, + PSX_LOBBY_SIG_RTT_REPORT = 102 +}; + +static uint64_t lobby_mono_ms(void) +{ +#if defined(CLOCK_MONOTONIC) + struct timespec ts; + if (clock_gettime(CLOCK_MONOTONIC, &ts) == 0) + return (uint64_t)ts.tv_sec * 1000ull + + (uint64_t)ts.tv_nsec / 1000000ull; +#endif + return (uint64_t)time(NULL) * 1000ull; +} + +/* Defined later; used by waiting-room RTT signal handling. */ +int psx_lobby_send_signal(int type, int flag, const char *text); + +static void member_rtt_clear(void) +{ + int i; + for (i = 0; i < PSX_LOBBY_MAX_MEMBERS; ++i) + g_lc.member_rtt_ms[i] = -1; + g_lc.rtt_next_ping_ms = 0; +} + +static int member_slot_for_player(const char *player_id) +{ + int i; + if (!player_id || !player_id[0]) + return -1; + for (i = 0; i < g_lc.member_count; ++i) { + if (strcmp(g_lc.members[i].player_id, player_id) == 0) + return g_lc.members[i].slot; + } + return -1; +} + +static int local_member_slot(void) +{ + return member_slot_for_player(g_lc.player_id); +} + /* Default max_slots for create (clamped 2..8). */ static int g_lobby_max_slots = 2; @@ -156,6 +244,24 @@ static int list_filter_version_strict(void) } static void queue_send(const char *json); +static void clear_turn_credentials(void); +static int queue_turn_credentials_request(void); + +static void clear_turn_credentials(void) +{ + memset(&g_lc.turn, 0, sizeof(g_lc.turn)); + g_lc.turn_received_at = 0; + g_lc.turn_request_pending = 0; +} + +static int queue_turn_credentials_request(void) +{ + if (!psx_lobby_connected()) + return -1; + queue_send("{\"op\":\"get_turn_credentials\"}"); + g_lc.turn_request_pending = 1; + return 0; +} static void queue_list_request(void) { @@ -265,6 +371,17 @@ static const char *json_get_str(const char *json, const char *key, char *out, si while (*p && *p != '"' && o + 1 < cap) { if (*p == '\\' && p[1]) { ++p; + switch (*p) { + case 'n': out[o++] = '\n'; break; + case 'r': out[o++] = '\r'; break; + case 't': out[o++] = '\t'; break; + case '"': out[o++] = '"'; break; + case '\\': out[o++] = '\\'; break; + case '/': out[o++] = '/'; break; + default: out[o++] = *p; break; + } + ++p; + continue; } out[o++] = *p++; } @@ -272,6 +389,55 @@ static const char *json_get_str(const char *json, const char *key, char *out, si return out; } +static size_t json_escape(const char *in, char *out, size_t cap) +{ + size_t o = 0; + if (!in || !out || cap == 0) return 0; + while (*in && o + 2 < cap) { + unsigned char c = (unsigned char)*in++; + if (c == '"' || c == '\\') { + if (o + 3 >= cap) break; + out[o++] = '\\'; + out[o++] = (char)c; + } else if (c == '\n') { + if (o + 3 >= cap) break; + out[o++] = '\\'; + out[o++] = 'n'; + } else if (c == '\r') { + if (o + 3 >= cap) break; + out[o++] = '\\'; + out[o++] = 'r'; + } else if (c == '\t') { + if (o + 3 >= cap) break; + out[o++] = '\\'; + out[o++] = 't'; + } else if (c < 0x20) { + continue; + } else { + out[o++] = (char)c; + } + } + out[o] = '\0'; + return o; +} + +static void enqueue_signal(int type, int flag, const char *text) +{ + int i; + if (g_lc.sig_count >= (int)(sizeof(g_lc.sig_q) / sizeof(g_lc.sig_q[0]))) { + g_lc.sig_head = (g_lc.sig_head + 1) % (int)(sizeof(g_lc.sig_q) / sizeof(g_lc.sig_q[0])); + g_lc.sig_count--; + } + i = g_lc.sig_tail; + g_lc.sig_q[i].type = type; + g_lc.sig_q[i].flag = flag; + g_lc.sig_q[i].text[0] = '\0'; + if (text) + strncpy(g_lc.sig_q[i].text, text, sizeof(g_lc.sig_q[i].text) - 1); + g_lc.sig_tail = (g_lc.sig_tail + 1) % (int)(sizeof(g_lc.sig_q) / sizeof(g_lc.sig_q[0])); + g_lc.sig_count++; +} + static int json_get_int(const char *json, const char *key, int def) { char pat[80]; @@ -634,6 +800,50 @@ static void handle_server_json(const char *json) queue_send(msg); } queue_list_request(); + /* Prefetch Coturn creds for ICE (no-op reply if server lacks COTURN_*). */ + (void)queue_turn_credentials_request(); + return; + } + if (strcmp(op, "turn_credentials") == 0) { + int ok = json_get_bool(json, "ok", 0); + g_lc.turn_request_pending = 0; + memset(&g_lc.turn, 0, sizeof(g_lc.turn)); + g_lc.turn_received_at = 0; + if (!ok) { + char err[64]; + json_get_str(json, "error", err, sizeof(err)); + fprintf(stderr, + "psx_lobby: turn_credentials failed (%s) — ICE will be " + "STUN-only unless PSX_NET_TURN_* is set\n", + err[0] ? err : "unknown"); + return; + } + json_get_str(json, "stun_host", g_lc.turn.stun_host, + sizeof(g_lc.turn.stun_host)); + json_get_str(json, "turn_host", g_lc.turn.turn_host, + sizeof(g_lc.turn.turn_host)); + json_get_str(json, "username", g_lc.turn.username, + sizeof(g_lc.turn.username)); + json_get_str(json, "password", g_lc.turn.password, + sizeof(g_lc.turn.password)); + g_lc.turn.stun_port = json_get_int(json, "stun_port", 3478); + g_lc.turn.turn_port = json_get_int(json, "turn_port", 3478); + g_lc.turn.ttl_secs = (uint32_t)json_get_int(json, "ttl_secs", 86400); + if (g_lc.turn.turn_host[0] && g_lc.turn.username[0] && + g_lc.turn.password[0]) { + g_lc.turn.valid = 1; + g_lc.turn_received_at = time(NULL); + fprintf(stderr, + "psx_lobby: turn_credentials ok stun=%s:%d turn=%s:%d " + "user=%s ttl=%us\n", + g_lc.turn.stun_host[0] ? g_lc.turn.stun_host : "(none)", + g_lc.turn.stun_port, + g_lc.turn.turn_host, g_lc.turn.turn_port, + g_lc.turn.username, (unsigned)g_lc.turn.ttl_secs); + } else { + fprintf(stderr, + "psx_lobby: turn_credentials ok but incomplete fields\n"); + } return; } if (strcmp(op, "lobby_list") == 0) { @@ -727,6 +937,7 @@ static void handle_server_json(const char *json) g_lc.join.ok = 1; g_lc.launch_pending = 0; g_lc.all_ready = 0; + member_rtt_clear(); json_get_str(json, "lobby_id", g_lc.join.lobby_id, sizeof(g_lc.join.lobby_id)); g_lc.join.session_id = (uint32_t)json_get_int(json, "session_id", 1); g_lc.join.local_slot = json_get_int(json, "local_slot", 0); @@ -760,6 +971,7 @@ static void handle_server_json(const char *json) g_lc.join.ok = 1; g_lc.launch_pending = 0; g_lc.all_ready = 0; + member_rtt_clear(); json_get_str(json, "lobby_id", g_lc.join.lobby_id, sizeof(g_lc.join.lobby_id)); g_lc.join.session_id = (uint32_t)json_get_int(json, "session_id", 1); g_lc.join.local_slot = json_get_int(json, "local_slot", 1); @@ -848,6 +1060,50 @@ static void handle_server_json(const char *json) g_lc.launch_pending = 1; return; } + if (strcmp(op, "signal") == 0) { + char text_buf[2048]; + char from[PSX_LOBBY_ID_LEN]; + int type = json_get_int(json, "type", 0); + int flag = json_get_int(json, "flag", 0); + text_buf[0] = '\0'; + from[0] = '\0'; + json_get_str(json, "text", text_buf, sizeof(text_buf)); + json_get_str(json, "from_player_id", from, sizeof(from)); + if (type == PSX_LOBBY_SIG_RTT_PING) { + if (g_lc.is_host) + (void)psx_lobby_send_signal(PSX_LOBBY_SIG_RTT_PONG, 0, text_buf); + return; + } + if (type == PSX_LOBBY_SIG_RTT_PONG) { + unsigned long long sent = 0; + uint64_t now = lobby_mono_ms(); + int slot; + if (sscanf(text_buf, "%llu", &sent) == 1 && (uint64_t)sent <= now) { + int ms = (int)(now - (uint64_t)sent); + if (ms < 0) ms = 0; + if (ms > 60000) ms = 60000; + slot = local_member_slot(); + if (slot >= 0 && slot < PSX_LOBBY_MAX_MEMBERS) + g_lc.member_rtt_ms[slot] = ms; + { + char report[32]; + snprintf(report, sizeof(report), "%d", ms); + (void)psx_lobby_send_signal(PSX_LOBBY_SIG_RTT_REPORT, 0, report); + } + } + return; + } + if (type == PSX_LOBBY_SIG_RTT_REPORT) { + int slot = member_slot_for_player(from); + int ms = (int)strtol(text_buf, NULL, 10); + if (slot >= 0 && slot < PSX_LOBBY_MAX_MEMBERS && ms >= 0 && ms <= 60000) + g_lc.member_rtt_ms[slot] = ms; + return; + } + enqueue_signal(type, flag, text_buf); + (void)flag; + return; + } if (strcmp(op, "error") == 0) { char code[64]; json_get_str(json, "code", code, sizeof(code)); @@ -879,6 +1135,7 @@ static void handle_server_json(const char *json) g_lc.launch_pending = 0; memset(&g_lc.join, 0, sizeof(g_lc.join)); match_caps_clear(&g_lc.match_caps); + member_rtt_clear(); return; } } @@ -996,6 +1253,7 @@ void psx_lobby_disconnect(void) memset(&g_lc, 0, sizeof(g_lc)); g_lc.fd = -1; strncpy(g_lc.display_name, dname, sizeof(g_lc.display_name) - 1); + member_rtt_clear(); } } @@ -1080,43 +1338,44 @@ void psx_lobby_pump(void) } flush_pending(); drain_ws_pending(); + /* Non-blocking recv into ws_pending + frame parse. Avoid MSG_PEEK / + * MSG_WAITALL / temporary blocking — those break on Windows MinGW when + * the socket stays O_NONBLOCK (list/create never see welcome/created). */ for (;;) { - int closed = 0; - int fl; -#if !defined(_WIN32) - fl = fcntl(g_lc.fd, F_GETFL, 0); - /* Non-blocking peek: if no data, EAGAIN from first recv inside read */ -#endif - { - uint8_t peek[1]; - n = recv(g_lc.fd, (char *)peek, 1, MSG_PEEK); - if (n < 0) { - if (socket_would_block()) { - break; - } - psx_lobby_disconnect(); - return; - } - if (n == 0) { - psx_lobby_disconnect(); - return; - } + size_t available = sizeof(g_lc.ws_pending) - g_lc.ws_pending_len; + if (available == 0) { + psx_lobby_disconnect(); + return; } -#if !defined(_WIN32) - fcntl(g_lc.fd, F_SETFL, fl & ~O_NONBLOCK); -#endif - n = rnet_ws_read_text(g_lc.fd, buf, sizeof(buf), &closed); -#if !defined(_WIN32) - fcntl(g_lc.fd, F_SETFL, fl | O_NONBLOCK); -#endif - if (closed || n < 0) { + n = recv(g_lc.fd, + (char *)g_lc.ws_pending + g_lc.ws_pending_len, + (int)available, 0); + if (n < 0) { + if (socket_would_block()) { + break; + } psx_lobby_disconnect(); return; } if (n == 0) { + psx_lobby_disconnect(); + return; + } + g_lc.ws_pending_len += (size_t)n; + drain_ws_pending(); + if (!psx_lobby_connected()) { break; } - handle_server_json(buf); + } + /* Guests: probe host RTT about once per second while seated. */ + if (g_lc.in_lobby && !g_lc.is_host && !g_lc.launch_pending) { + uint64_t now = lobby_mono_ms(); + if (now >= g_lc.rtt_next_ping_ms) { + char ts[32]; + snprintf(ts, sizeof(ts), "%llu", (unsigned long long)now); + (void)psx_lobby_send_signal(PSX_LOBBY_SIG_RTT_PING, 0, ts); + g_lc.rtt_next_ping_ms = now + 1000ull; + } } } @@ -1237,6 +1496,7 @@ int psx_lobby_leave(void) g_lc.all_ready = 0; g_lc.launch_pending = 0; match_caps_clear(&g_lc.match_caps); + member_rtt_clear(); return 0; } @@ -1331,6 +1591,30 @@ int psx_lobby_member_get(int index, PsxLobbyMember *out) return 1; } +int psx_lobby_member_latency_ms(int slot) +{ + if (slot < 0 || slot >= PSX_LOBBY_MAX_MEMBERS) + return -1; + if (g_lc.host_player_id[0]) { + int i; + for (i = 0; i < g_lc.member_count; ++i) { + if (g_lc.members[i].slot == slot && + strcmp(g_lc.members[i].player_id, g_lc.host_player_id) == 0) + return -1; /* host row */ + } + } + return g_lc.member_rtt_ms[slot]; +} + +int psx_lobby_member_is_host(const PsxLobbyMember *member) +{ + const char *host_id; + if (!member || !member->player_id[0]) + return 0; + host_id = psx_lobby_host_player_id(); + return host_id && host_id[0] && strcmp(member->player_id, host_id) == 0; +} + int psx_lobby_local_ready(void) { return g_lc.local_ready; @@ -1383,4 +1667,72 @@ void psx_lobby_clear_launch_pending(void) g_lc.launch_pending = 0; } +int psx_lobby_send_signal(int type, int flag, const char *text) +{ + char esc[4096]; + char msg[4608]; + const char *lid; + if (!psx_lobby_connected() || !g_lc.in_lobby) { + return -1; + } + lid = g_lc.join.lobby_id[0] ? g_lc.join.lobby_id : ""; + json_escape(text ? text : "", esc, sizeof(esc)); + snprintf(msg, sizeof(msg), + "{\"op\":\"signal\",\"lobby_id\":\"%s\",\"to_player_id\":\"\"," + "\"type\":%d,\"flag\":%d,\"text\":\"%s\"}", + lid, type, flag, esc); + /* Write immediately — ICE candidates arrive in bursts larger than pending_tx. */ + if (g_lc.handshake_done && g_lc.fd >= 0) { + if (rnet_ws_write_text(g_lc.fd, msg, 1) < 0) + return -1; + return 0; + } + queue_send(msg); + return 0; +} + +int psx_lobby_poll_signal(int *type, int *flag, char *text, size_t text_cap) +{ + int i; + if (g_lc.sig_count <= 0) { + return 0; + } + i = g_lc.sig_head; + if (type) *type = g_lc.sig_q[i].type; + if (flag) *flag = g_lc.sig_q[i].flag; + if (text && text_cap) { + strncpy(text, g_lc.sig_q[i].text, text_cap - 1); + text[text_cap - 1] = '\0'; + } + g_lc.sig_head = (g_lc.sig_head + 1) % (int)(sizeof(g_lc.sig_q) / sizeof(g_lc.sig_q[0])); + g_lc.sig_count--; + return 1; +} + +int psx_lobby_request_turn_credentials(void) +{ + if (!psx_lobby_connected()) + return -1; + if (g_lc.turn.valid && g_lc.turn_received_at > 0 && g_lc.turn.ttl_secs > 0) { + time_t now = time(NULL); + if (now >= g_lc.turn_received_at && + (uint32_t)(now - g_lc.turn_received_at) + 60u < g_lc.turn.ttl_secs) { + return 0; /* still fresh (60s skew margin) */ + } + } + return queue_turn_credentials_request(); +} + +const PsxLobbyTurnCredentials *psx_lobby_turn_credentials(void) +{ + if (g_lc.turn.valid && g_lc.turn_received_at > 0 && g_lc.turn.ttl_secs > 0) { + time_t now = time(NULL); + if (now < g_lc.turn_received_at || + (uint32_t)(now - g_lc.turn_received_at) >= g_lc.turn.ttl_secs) { + clear_turn_credentials(); + } + } + return &g_lc.turn; +} + #endif /* PSX_HAS_LOBBY_CLIENT */ diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index e7004f4cd..8476c48af 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -11,14 +11,25 @@ #include #include #include +#include + +#if defined(_WIN32) +#include +#include +#else +#include +#include +#endif #if defined(__linux__) #include -#include #endif #if defined(PSX_HAS_RECOMP_NET) #include "recomp_net/recomp_net.h" +#if defined(PSX_HAS_LOBBY_CLIENT) +#include "psx_lobby_client.h" +#endif #endif #ifndef PSX_MAX_PLAYERS @@ -55,6 +66,7 @@ void psx_netplay_config_defaults(PsxNetplayConfig *cfg) cfg->input_delay = 2; cfg->force_input_relay = 0; cfg->force_turn = 0; + cfg->transport = 0; cfg->session_id = 1; strncpy(cfg->bind_hostport, "0.0.0.0:7777", sizeof(cfg->bind_hostport) - 1); cfg->peer_hostport[0] = '\0'; @@ -92,6 +104,16 @@ void psx_netplay_apply_env(PsxNetplayConfig *cfg) strncpy(cfg->peer_hostport, v, sizeof(cfg->peer_hostport) - 1); cfg->peer_hostport[sizeof(cfg->peer_hostport) - 1] = '\0'; } + v = getenv("PSX_NET_TRANSPORT"); + if (v && v[0]) { + if (strcmp(v, "ice") == 0 || strcmp(v, "ICE") == 0) + cfg->transport = 1; + else if (strcmp(v, "lan") == 0 || strcmp(v, "LAN") == 0) + cfg->transport = 2; + } + v = getenv("PSX_NET_FORCE_TURN"); + if (v && v[0] && v[0] != '0') + cfg->force_turn = 1; } void psx_netplay_normalize_pad(PsxNetPad *pad) @@ -143,6 +165,9 @@ void psx_netplay_release_pads(void) int psx_netplay_active(void) { return 0; } int psx_netplay_is_running(void) { return 0; } +const char *psx_netplay_transport_name(void) { return "none"; } +int psx_netplay_ice_failed(void) { return 0; } +void psx_netplay_diag_tick(void) {} int psx_netplay_local_slot(void) { return -1; } int psx_netplay_input_player(void) { return 0; } uint32_t psx_netplay_sim_tick(void) { return 0; } @@ -227,10 +252,61 @@ typedef struct { int load_applied_local; int load_ready_replied; /* READY exchanged; synced; stay LOAD_READY until admit */ int load_sync_done; /* hard_resync+prime once at mutual ready */ + /* Transport / ICE / diag (MotK online path). */ + int use_ice; + int ice_has_turn; + int force_input_relay; + int is_host; + int input_delay; + uint32_t session_id; + uint32_t frames_finished; + uint32_t diag_session; + unsigned ice_stun_port; + unsigned ice_turn_port; + char ice_stun_host[128]; + char ice_turn_host[128]; + char ice_turn_user[192]; + char ice_turn_pass[128]; + char ice_bind_addr[64]; + char bind_hostport[64]; + char peer_hostport[64]; + char match_mode[32]; + char lobby_server[256]; + char lobby_id[64]; } NetplayState; static NetplayState g_np; +static FILE *g_diag_file; +static uint32_t g_diag_file_session; +static int g_diag_summary_written; +static uint32_t g_diag_last_write_ms; +static int g_diag_mkdir_done; + +static void np_sleep_ms(unsigned ms) +{ +#if defined(_WIN32) + Sleep(ms); +#else + usleep(ms * 1000u); +#endif +} + +static uint32_t np_mono_ms(void) +{ +#if defined(CLOCK_MONOTONIC) + struct timespec ts; + if (clock_gettime(CLOCK_MONOTONIC, &ts) == 0) + return (uint32_t)((uint64_t)ts.tv_sec * 1000ull + + (uint64_t)ts.tv_nsec / 1000000ull); +#endif +#if defined(_WIN32) + return (uint32_t)GetTickCount64(); +#else + return (uint32_t)((uint64_t)time(NULL) * 1000ull); +#endif +} + static void np_enter_load_ready(int slot); static void np_commit_load_sync(void); static void np_begin_load_apply(int slot); @@ -410,7 +486,8 @@ static void np_apply_ready_state(void) return; } - /* LOAD transfer (hash miss): guest writes sandbox, both enter apply barrier. */ + /* LOAD transfer (hash miss): guest writes sandbox; both stage apply here so + * the host cannot restore (and suppress INPUT) before the guest has bytes. */ if (g_np.local_slot != 0) { if (!savestate_write_slot((int)slot, data, size)) { rnet_session_state_finish(g_np.session, 0); @@ -427,8 +504,8 @@ static void np_apply_ready_state(void) return; } } - (void)savestate_request_load_protocol((int)slot); } + (void)savestate_request_load_protocol((int)slot); rnet_session_state_finish(g_np.session, 0); np_begin_load_apply((int)slot); printf("psxrecomp: netplay load slot=%u — applying after transfer…\n", (unsigned)slot); @@ -622,7 +699,10 @@ static void np_host_drive_xfer(void) g_np.xfer = NP_XFER_NONE; return; } - (void)savestate_request_load_protocol(g_np.xfer_slot); + /* Do not stage savestate_request_load here — host would apply during + * SEND, enter LOAD_READY, and suppress INPUT before the guest can + * admit frames for its own savestate_poll (deadlock). Both peers + * stage in np_apply_ready_state when the transfer completes. */ g_np.load_applied_local = 0; g_np.load_sync_done = 0; if (rnet_session_state_begin(g_np.session, RNET_STATE_OP_LOAD, (rnet_u8)g_np.xfer_slot, buf, @@ -684,7 +764,7 @@ static void np_prime_after_hard_resync(void) } /* Stage restore. Keep INPUT flowing so try_admit can still run guest cycles - * for savestate_poll — suppress starts at enter_load_ready / hard_resync. */ + * for savestate_poll — suppress only at mutual ready (np_commit_load_sync). */ static void np_begin_load_apply(int slot) { g_np.xfer = NP_XFER_LOAD_APPLYING; @@ -698,6 +778,8 @@ static void np_commit_load_sync(void) { if (g_np.load_sync_done || !g_np.session) return; + /* Suppress empty tips only for the hard_resync→prime window. */ + rnet_session_set_input_send_suppress(g_np.session, 1); rnet_session_hard_resync(g_np.session); np_prime_after_hard_resync(); /* clears suppress + emits fresh tip */ g_np.load_sync_done = 1; @@ -709,7 +791,9 @@ static void np_commit_load_sync(void) static void np_enter_load_ready(int slot) { /* Do not hard_resync/prime here — the later-applying peer would clear the - * earlier peer's tip and stall resume. Sync runs at mutual ready. */ + * earlier peer's tip and stall resume. Sync runs at mutual ready. + * Do not suppress INPUT here either: the first peer to finish apply must + * keep sending pads so the other can still admit frames for savestate_poll. */ g_np.load_applied_local = 1; g_np.load_ready_replied = 0; g_np.load_sync_done = 0; @@ -718,9 +802,6 @@ static void np_enter_load_ready(int slot) g_np.staged_valid = 0; g_np.xfer = NP_XFER_LOAD_READY; g_np.xfer_slot = slot; - /* Stop pre-resync tips until hard_resync + prime (avoids tick%128 clobber). */ - if (g_np.session) - rnet_session_set_input_send_suppress(g_np.session, 1); } /* After both peers stage a load: run until restore completes, then rendezvous. @@ -868,6 +949,23 @@ int psx_netplay_is_running(void) return psx_netplay_active() && rnet_session_is_running(g_np.session); } +const char *psx_netplay_transport_name(void) +{ + if (!psx_netplay_active()) return "none"; + return g_np.use_ice ? "ice" : "lan"; +} + +int psx_netplay_ice_failed(void) +{ +#if defined(RNET_ENABLE_ICE) + if (!psx_netplay_active() || !g_np.use_ice) + return 0; + return rnet_session_ice_state(g_np.session) == RNET_ICE_STATE_FAILED; +#else + return 0; +#endif +} + int psx_netplay_local_slot(void) { return psx_netplay_active() ? g_np.local_slot : -1; @@ -985,6 +1083,78 @@ static void pin_localhost_peer_cpus(int local_slot) } #endif +#if defined(PSX_HAS_LOBBY_CLIENT) && defined(RNET_ENABLE_ICE) +static void host_on_signal(const RNetSignal *msg, void *ctx) +{ + (void)ctx; + if (!msg) return; + (void)psx_lobby_send_signal((int)msg->type, (int)msg->flag, msg->text); +} + +static void drain_lobby_signals(void) +{ + int type = 0, flag = 0; + char text[2048]; + if (!g_np.session) return; + while (psx_lobby_poll_signal(&type, &flag, text, sizeof(text))) { + RNetSignal sig; + memset(&sig, 0, sizeof(sig)); + /* Peers emit LOCAL_*; push_signal expects REMOTE_* for SDP/candidates. */ + if (type == (int)RNET_SIGNAL_LOCAL_SDP) + type = (int)RNET_SIGNAL_REMOTE_SDP; + else if (type == (int)RNET_SIGNAL_LOCAL_CANDIDATE) + type = (int)RNET_SIGNAL_REMOTE_CANDIDATE; + sig.type = (RNetSignalType)type; + sig.flag = (rnet_u8)(flag & 0xFF); + strncpy(sig.text, text, sizeof(sig.text) - 1); + rnet_session_push_signal(g_np.session, &sig); + } +} +#else +static void drain_lobby_signals(void) {} +#endif + +static int resolve_use_ice(const PsxNetplayConfig *cfg) +{ + int in_motk_room = 0; + + if (cfg->transport == 2) return 0; /* force LAN */ +#if defined(PSX_HAS_LOBBY_CLIENT) + in_motk_room = psx_lobby_connected() && psx_lobby_in_lobby(); +#endif + +#if defined(RNET_ENABLE_ICE) && defined(PSX_HAS_LOBBY_CLIENT) + if (cfg->transport == 1) { + if (!in_motk_room) { + fprintf(stderr, + "psx_netplay: ICE requested but MotK lobby not connected\n"); + return -1; + } + return 1; + } + /* Auto: hosted MotK room always uses ICE. Do not demote to LAN when the + * lobby rewrites 0.0.0.0 binds to a private TCP peer IP (often wrong). + * Direct IP / LAN file lobby (no MotK seat) stays on LAN UDP. */ + if (in_motk_room) + return 1; + return 0; +#else + { + int online_requested = cfg->transport == 1 || + (cfg->transport == 0 && in_motk_room); + if (online_requested) { + fprintf(stderr, + "psx_netplay: hosted lobby requires ICE, but ICE is not " + "available in this build (configure with PSX_NET_ICE=ON / " + "RNET_ENABLE_ICE=ON)\n"); + return -1; + } + } + return 0; +#endif +} + + int psx_netplay_start(const PsxNetplayConfig *cfg) { RNetConfig rcfg; @@ -992,6 +1162,7 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) int in_player; int slots; int local; + int use_ice; if (!cfg || !cfg->enabled) return -1; if (g_np.session) psx_netplay_shutdown(); @@ -1017,17 +1188,172 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) in_player = cfg->input_player; if (in_player < 0 || in_player >= PSX_MAX_PLAYERS) in_player = 0; + use_ice = resolve_use_ice(cfg); + if (use_ice < 0) + return -4; + memset(&host, 0, sizeof(host)); host.sample_local = host_sample_local; host.publish = host_publish; host.ctx = &g_np; +#if defined(PSX_HAS_LOBBY_CLIENT) && defined(RNET_ENABLE_ICE) + if (use_ice) + host.on_signal = host_on_signal; +#endif g_np.session = rnet_session_create(&rcfg, &host); if (!g_np.session) return -2; - /* Host-as-relay: slot 0 with 3+ seats and no dial peer (guests dial host). - * MotK is 2P (PSX_MAX_PLAYERS=2); skip the hub API so we still build - * against older recomp-net trees that only expose start_lan. */ - { + + if (use_ice) { +#if defined(RNET_ENABLE_ICE) + RNetIceConfig ice; + RNetIpv4Address addrs[8]; + int naddr; + const char *env_turn_host = getenv("PSX_NET_TURN_HOST"); + const char *env_turn_user = getenv("PSX_NET_TURN_USER"); + const char *env_turn_pass = getenv("PSX_NET_TURN_PASS"); + const char *env_stun = getenv("PSX_NET_STUN_HOST"); + + g_np.ice_has_turn = 0; + g_np.ice_stun_host[0] = '\0'; + g_np.ice_turn_host[0] = '\0'; + g_np.ice_turn_user[0] = '\0'; + g_np.ice_turn_pass[0] = '\0'; + g_np.ice_bind_addr[0] = '\0'; + + rnet_ice_config_init_defaults(&ice); + ice.controlling = (rcfg.local_slot == 0) ? 1u : 0u; + + naddr = rnet_ipv4_enumerate(addrs, sizeof(addrs) / sizeof(addrs[0])); + if (naddr > 0 && addrs[0].address[0]) { + snprintf(g_np.ice_bind_addr, sizeof(g_np.ice_bind_addr), "%s", + addrs[0].address); + ice.bind_address = g_np.ice_bind_addr; + } + +#if defined(PSX_HAS_LOBBY_CLIENT) + if (psx_lobby_connected()) { + int i; + (void)psx_lobby_request_turn_credentials(); + for (i = 0; i < 50; ++i) { + const PsxLobbyTurnCredentials *tc = psx_lobby_turn_credentials(); + if (tc && tc->valid) + break; + psx_lobby_pump(); + np_sleep_ms(10); + } + } + { + const PsxLobbyTurnCredentials *tc = psx_lobby_turn_credentials(); + if (tc && tc->valid) { + if (tc->stun_host[0]) { + snprintf(g_np.ice_stun_host, sizeof(g_np.ice_stun_host), + "%s", tc->stun_host); + ice.stun_host = g_np.ice_stun_host; + ice.stun_port = (rnet_u16)(tc->stun_port > 0 ? tc->stun_port + : 3478); + } + snprintf(g_np.ice_turn_host, sizeof(g_np.ice_turn_host), "%s", + tc->turn_host); + snprintf(g_np.ice_turn_user, sizeof(g_np.ice_turn_user), "%s", + tc->username); + snprintf(g_np.ice_turn_pass, sizeof(g_np.ice_turn_pass), "%s", + tc->password); + ice.turn_host = g_np.ice_turn_host; + ice.turn_user = g_np.ice_turn_user; + ice.turn_pass = g_np.ice_turn_pass; + ice.turn_port = (rnet_u16)(tc->turn_port > 0 ? tc->turn_port + : 3478); + g_np.ice_has_turn = 1; + } + } +#endif + if (env_stun && env_stun[0]) { + snprintf(g_np.ice_stun_host, sizeof(g_np.ice_stun_host), "%s", + env_stun); + ice.stun_host = g_np.ice_stun_host; + ice.stun_port = (rnet_u16)env_u("PSX_NET_STUN_PORT", ice.stun_port + ? ice.stun_port + : 3478); + } + if (env_turn_host && env_turn_host[0] && env_turn_user && + env_turn_user[0] && env_turn_pass && env_turn_pass[0]) { + snprintf(g_np.ice_turn_host, sizeof(g_np.ice_turn_host), "%s", + env_turn_host); + snprintf(g_np.ice_turn_user, sizeof(g_np.ice_turn_user), "%s", + env_turn_user); + snprintf(g_np.ice_turn_pass, sizeof(g_np.ice_turn_pass), "%s", + env_turn_pass); + ice.turn_host = g_np.ice_turn_host; + ice.turn_user = g_np.ice_turn_user; + ice.turn_pass = g_np.ice_turn_pass; + ice.turn_port = (rnet_u16)env_u("PSX_NET_TURN_PORT", 3478); + g_np.ice_has_turn = 1; + } + + if (!g_np.ice_stun_host[0] && ice.stun_host && ice.stun_host[0]) { + snprintf(g_np.ice_stun_host, sizeof(g_np.ice_stun_host), "%s", + ice.stun_host); + } + g_np.ice_stun_port = ice.stun_port ? (unsigned)ice.stun_port : 19302u; + g_np.ice_turn_port = ice.turn_port ? (unsigned)ice.turn_port : 0u; + + if (g_np.ice_has_turn) { + fprintf(stderr, + "psx_netplay: ICE stun=%s:%u turn=%s:%u user=%s bind=%s\n", + ice.stun_host ? ice.stun_host : "(default)", + (unsigned)ice.stun_port, + ice.turn_host, (unsigned)ice.turn_port, ice.turn_user, + ice.bind_address ? ice.bind_address : "(any)"); + } else { + fprintf(stderr, + "psx_netplay: ICE STUN-only (no TURN) stun=%s:%u " + "bind=%s — remote NAT may hang; configure Coturn on the " + "lobby or PSX_NET_TURN_*\n", + ice.stun_host ? ice.stun_host : "(default)", + (unsigned)ice.stun_port, + ice.bind_address ? ice.bind_address : "(any)"); + } + + { + int force_turn = cfg->force_turn ? 1 : 0; + const char *ft = getenv("PSX_NET_FORCE_TURN"); + if (ft && ft[0] && ft[0] != '0') + force_turn = 1; + if (force_turn && !g_np.ice_has_turn) { + fprintf(stderr, + "psx_netplay: FORCE_TURN requires Coturn credentials " + "(lobby get_turn_credentials or PSX_NET_TURN_*)\n"); + rnet_session_destroy(g_np.session); + g_np.session = NULL; + return -4; + } + if (force_turn) { + ice.force_relay = 1; + fprintf(stderr, + "psx_netplay: FORCE_TURN — ICE will use relay-only " + "candidates (host match_caps / all peers)\n"); + } + } + + if (rnet_session_start_ice(g_np.session, &ice) != 0) { + fprintf(stderr, + "psx_netplay: start_ice failed; refusing unsafe LAN " + "fallback for an online lobby\n"); + rnet_session_destroy(g_np.session); + g_np.session = NULL; + return -4; + } +#else + fprintf(stderr, "psx_netplay: ICE requested but not built\n"); + rnet_session_destroy(g_np.session); + g_np.session = NULL; + return -4; +#endif + } + + if (!use_ice) { + /* Host-as-relay: slot 0 with 3+ seats and no dial peer. */ #if PSX_MAX_PLAYERS >= 3 const int peer_empty = !cfg->peer_hostport || !cfg->peer_hostport[0]; @@ -1045,9 +1371,10 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) g_np.session = NULL; return -3; } - np_diag_capture(cfg, slots); } + np_diag_capture(cfg, slots); g_np.active = 1; + g_np.use_ice = use_ice ? 1 : 0; g_np.slot_count = (int)rcfg.slot_count; g_np_slot_count = g_np.slot_count; g_np.local_slot = (int)rcfg.local_slot; @@ -1067,13 +1394,53 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) g_np.local_save_staged = 0; g_np.load_applied_local = 0; g_np.guest_sandbox = 0; + g_np.force_input_relay = cfg->force_input_relay ? 1 : 0; + g_np.input_delay = (int)rcfg.input_delay; + g_np.session_id = rcfg.session_id; + g_np.is_host = (g_np.local_slot == 0) ? 1 : 0; + g_np.frames_finished = 0; + g_np.diag_session++; + g_diag_summary_written = 0; + if (g_diag_file) { + fclose(g_diag_file); + g_diag_file = NULL; + } + g_diag_file_session = 0; + g_diag_last_write_ms = 0; + snprintf(g_np.bind_hostport, sizeof(g_np.bind_hostport), "%s", + cfg->bind_hostport); + snprintf(g_np.peer_hostport, sizeof(g_np.peer_hostport), "%s", + cfg->peer_hostport); + g_np.lobby_server[0] = '\0'; + g_np.lobby_id[0] = '\0'; +#if defined(PSX_HAS_LOBBY_CLIENT) + if (use_ice && psx_lobby_connected() && psx_lobby_in_lobby()) { + const PsxLobbyJoinInfo *ji = psx_lobby_join_info(); + snprintf(g_np.match_mode, sizeof(g_np.match_mode), "hosted_lobby"); + snprintf(g_np.lobby_server, sizeof(g_np.lobby_server), "%s", + psx_lobby_default_url()); + if (ji && ji->lobby_id[0]) + snprintf(g_np.lobby_id, sizeof(g_np.lobby_id), "%s", ji->lobby_id); + g_np.is_host = psx_lobby_is_host() ? 1 : 0; + } else +#endif + { + snprintf(g_np.match_mode, sizeof(g_np.match_mode), "direct_ip"); + } #if defined(__linux__) - if (peer_is_loopback(cfg->peer_hostport)) + if (!use_ice && peer_is_loopback(cfg->peer_hostport)) pin_localhost_peer_cpus(g_np.local_slot); #endif psx_netplay_release_pads(); + fprintf(stderr, + "psx_netplay: started transport=%s slot=%d input_player=%d session=%u " + "delay=%u force_input_relay=%d force_turn=%d bind=%s peer=%s\n", + use_ice ? "ice" : "lan", g_np.local_slot, g_np.input_player, + (unsigned)rcfg.session_id, (unsigned)rcfg.input_delay, + g_np.force_input_relay, cfg->force_turn ? 1 : 0, cfg->bind_hostport, + use_ice ? "(ice)" : cfg->peer_hostport); return 0; } @@ -1139,6 +1506,13 @@ static int np_starv_runway_ok(void) void psx_netplay_shutdown(void) { + if (g_diag_file) { + fclose(g_diag_file); + g_diag_file = NULL; + } + g_diag_file_session = 0; + g_diag_summary_written = 0; + g_diag_last_write_ms = 0; if (g_np.session) { (void)rnet_session_send_bye(g_np.session); rnet_session_destroy(g_np.session); @@ -1210,8 +1584,253 @@ int psx_netplay_in_load_barrier(void) return (g_np.xfer == NP_XFER_LOAD_APPLYING || g_np.xfer == NP_XFER_LOAD_READY) ? 1 : 0; } + +static int np_diag_enabled(void) +{ + static int cached = -1; + if (cached < 0) { + const char *v = getenv("PSX_NET_DIAG"); + cached = (v && v[0] && v[0] != '0') ? 1 : 0; + } + return cached; +} + +static unsigned np_diag_interval_ms(void) +{ + static unsigned cached = 0; + unsigned hz; + if (cached) + return cached; + hz = env_u("PSX_NET_DIAG_HZ", 2); + if (hz < 1) hz = 1; + if (hz > 30) hz = 30; + cached = 1000u / hz; + if (cached < 1) cached = 1; + return cached; +} + +static void np_diag_escape(char *out, size_t out_len, const char *in) +{ + size_t oi = 0; + if (!out || out_len == 0) + return; + out[0] = '\0'; + if (!in) + return; + for (; *in && oi + 2 < out_len; ++in) { + char c = *in; + if (c == '"' || c == '\\') { + if (oi + 3 >= out_len) + break; + out[oi++] = '\\'; + out[oi++] = c; + } else if ((unsigned char)c < 0x20) { + /* skip */ + } else { + out[oi++] = c; + } + } + out[oi] = '\0'; +} + +static const char *np_diag_ice_path(const RNetSessionStats *st) +{ + if (!g_np.use_ice) + return "lan"; + if (!st) + return "pending"; + if (st->ice_state == RNET_ICE_STATE_FAILED) + return "failed"; + if (st->ice_path[0]) + return st->ice_path; + if (st->ice_state == RNET_ICE_STATE_COMPLETED || + st->ice_state == RNET_ICE_STATE_CONNECTED) + return "unknown"; + return "pending"; +} + +static const char *np_diag_ice_nat(const char *path) +{ + if (!g_np.use_ice) + return "lan"; + if (!path || !path[0] || strcmp(path, "pending") == 0) + return "pending"; + if (strcmp(path, "failed") == 0) + return "failed"; + if (strcmp(path, "relay") == 0) + return "turn"; + if (strcmp(path, "srflx") == 0 || strcmp(path, "prflx") == 0) + return "stun"; + if (strcmp(path, "host") == 0) + return "host"; + return "unknown"; +} + +static int np_diag_path_ready(const RNetSessionStats *st) +{ + if (!g_np.use_ice) + return 1; + if (!st) + return 0; + if (st->ice_state == RNET_ICE_STATE_FAILED) + return 1; + if (st->ice_path[0] && strcmp(st->ice_path, "pending") != 0 && + strcmp(st->ice_path, "unknown") != 0) + return 1; + if (st->ice_state == RNET_ICE_STATE_COMPLETED || + st->ice_state == RNET_ICE_STATE_CONNECTED) + return 1; + return 0; +} + +static void np_diag_write_summary(FILE *f, const RNetSessionStats *st, uint32_t now) +{ + char server_esc[280]; + char lobby_esc[80]; + char bind_esc[80]; + char peer_esc[80]; + char stun_esc[140]; + char turn_esc[140]; + char ice_local_esc[120]; + char ice_remote_esc[120]; + const char *path = np_diag_ice_path(st); + const char *nat = np_diag_ice_nat(path); + const char *ice_state = + st ? rnet_ice_state_name(st->ice_state) : "idle"; + + np_diag_escape(server_esc, sizeof(server_esc), g_np.lobby_server); + np_diag_escape(lobby_esc, sizeof(lobby_esc), g_np.lobby_id); + np_diag_escape(bind_esc, sizeof(bind_esc), g_np.bind_hostport); + np_diag_escape(peer_esc, sizeof(peer_esc), g_np.peer_hostport); + np_diag_escape(stun_esc, sizeof(stun_esc), g_np.ice_stun_host); + np_diag_escape(turn_esc, sizeof(turn_esc), g_np.ice_turn_host); + np_diag_escape(ice_local_esc, sizeof(ice_local_esc), + st ? st->ice_local : ""); + np_diag_escape(ice_remote_esc, sizeof(ice_remote_esc), + st ? st->ice_remote : ""); + + fprintf(f, + "{\"type\":\"summary\",\"t_ms\":%u,\"match\":\"%s\"," + "\"lobby_server\":\"%s\",\"lobby_id\":\"%s\",\"is_host\":%d," + "\"slot\":%d,\"session_id\":%u,\"input_delay\":%d," + "\"force_input_relay\":%d," + "\"transport\":\"%s\",\"bind\":\"%s\",\"peer\":\"%s\"," + "\"turn_configured\":%d,\"stun_host\":\"%s\",\"stun_port\":%u," + "\"turn_host\":\"%s\",\"turn_port\":%u,\"ice_state\":\"%s\"," + "\"ice_path\":\"%s\",\"ice_nat\":\"%s\"," + "\"ice_local\":\"%s\",\"ice_remote\":\"%s\"}\n", + (unsigned)now, g_np.match_mode[0] ? g_np.match_mode : "unknown", + server_esc, lobby_esc, g_np.is_host, g_np.local_slot, + (unsigned)g_np.session_id, g_np.input_delay, g_np.force_input_relay, + g_np.use_ice ? "ice" : "lan", bind_esc, peer_esc, + g_np.ice_has_turn ? 1 : 0, stun_esc, g_np.ice_stun_port, turn_esc, + g_np.ice_turn_port, ice_state ? ice_state : "idle", path, nat, + ice_local_esc, ice_remote_esc); +} + +void psx_netplay_diag_tick(void) +{ + RNetSessionStats st; + uint32_t now; + const char *transport; + const char *ice_state; + const char *path; + + if (!np_diag_enabled() || !psx_netplay_active() || !g_np.session) + return; + + rnet_session_get_stats(g_np.session, &st); + + if (!g_diag_summary_written && !np_diag_path_ready(&st)) + return; + + now = np_mono_ms(); + if (g_diag_last_write_ms && + (uint32_t)(now - g_diag_last_write_ms) < np_diag_interval_ms() && + g_diag_summary_written) + return; + g_diag_last_write_ms = now ? now : 1u; + + if (!g_diag_mkdir_done) { + g_diag_mkdir_done = 1; +#ifdef _WIN32 + _mkdir("saves"); + _mkdir("saves\\netplay"); +#else + mkdir("saves", 0755); + mkdir("saves/netplay", 0755); +#endif + } + + if (!g_diag_file || g_diag_file_session != g_np.diag_session) { + char pathbuf[64]; + if (g_diag_file) { + fclose(g_diag_file); + g_diag_file = NULL; + } + snprintf(pathbuf, sizeof(pathbuf), "saves/netplay/net_diag.jsonl"); + g_diag_file = fopen(pathbuf, "wb"); + if (!g_diag_file) + return; + setvbuf(g_diag_file, NULL, _IOLBF, 0); + g_diag_file_session = g_np.diag_session; + g_diag_summary_written = 0; + fprintf(stderr, "psx_netplay: diag writing %s " + "(PSX_NET_DIAG_HZ interval %ums)\n", + pathbuf, np_diag_interval_ms()); + } + + if (!g_diag_summary_written) { + np_diag_write_summary(g_diag_file, &st, now); + g_diag_summary_written = 1; + } + + { + char ice_local_esc[120]; + char ice_remote_esc[120]; + const char *stall = rnet_admit_stall_name(st.last_stall); + int using_turn_path = (strcmp(np_diag_ice_path(&st), "relay") == 0) ? 1 : 0; + + transport = psx_netplay_transport_name(); + ice_state = rnet_ice_state_name(st.ice_state); + path = np_diag_ice_path(&st); + np_diag_escape(ice_local_esc, sizeof(ice_local_esc), st.ice_local); + np_diag_escape(ice_remote_esc, sizeof(ice_remote_esc), st.ice_remote); + + fprintf(g_diag_file, + "{\"t_ms\":%u,\"slot\":%d,\"transport\":\"%s\",\"ice_state\":\"%s\"," + "\"ice_path\":\"%s\",\"ice_nat\":\"%s\",\"turn\":%d," + "\"ice_local\":\"%s\",\"ice_remote\":\"%s\"," + "\"running\":%d,\"sim_tick\":%u,\"frames_finished\":%u," + "\"delay\":%u,\"stall\":\"%s\"," + "\"stall_ms\":%u,\"stall_max_ms\":%u,\"stall_streaks\":%u," + "\"consec_stalls\":%u,\"admit_ok\":%u,\"remote_lead\":%d," + "\"remote_wire\":%u,\"peer_rx_age_ms\":%llu,\"peer_gone\":%d," + "\"desync\":%d,\"desync_tick\":%u,\"state_busy\":%d,\"state_op\":%u," + "\"pkts_rx\":%u,\"input_sends\":%u}\n", + (unsigned)now, g_np.local_slot, transport ? transport : "none", + ice_state ? ice_state : "idle", path, np_diag_ice_nat(path), + using_turn_path, ice_local_esc, ice_remote_esc, st.is_running, + (unsigned)st.sim_tick, (unsigned)g_np.frames_finished, + (unsigned)st.delay, stall ? stall : "unknown", + (unsigned)st.last_admit_wait_ms, (unsigned)st.max_admit_wait_ms, + (unsigned)st.stall_streaks, (unsigned)st.consecutive_stalls, + (unsigned)st.admit_ok_count, st.remote_lead, + (unsigned)st.highest_remote_wire, + (unsigned long long)st.last_peer_rx_age_ms, st.peer_gone, + st.input_desync, (unsigned)st.desync_tick, st.state_busy, + (unsigned)st.state_op, (unsigned)st.packets_rx, + (unsigned)st.input_bundle_sends); + } +} + static void np_pump_session(void) { +#if defined(PSX_HAS_LOBBY_CLIENT) + if (g_np.use_ice || psx_lobby_connected()) + psx_lobby_pump(); +#endif + drain_lobby_signals(); rnet_session_pump(g_np.session); np_guest_handle_probe(); np_apply_ready_state(); @@ -1226,6 +1845,7 @@ void psx_netplay_pump(void) if (!psx_netplay_active()) return; np_pump_session(); + psx_netplay_diag_tick(); } static int np_try_admit_gameplay(void) @@ -1252,6 +1872,7 @@ int psx_netplay_poll_admit(void) if (!rnet_session_is_running(g_np.session)) { psx_netplay_release_pads(); np_starv_reset(); + psx_netplay_diag_tick(); return 0; } @@ -1372,6 +1993,7 @@ void psx_netplay_finish_frame(void) rnet_session_advance(g_np.session); g_np.needs_advance = 0; g_np.latched_for_tick = 0; + g_np.frames_finished++; } int psx_netplay_remote_lead(void) diff --git a/runtime/src/savestate.c b/runtime/src/savestate.c index 58ea9436a..6ee07ab9d 100644 --- a/runtime/src/savestate.c +++ b/runtime/src/savestate.c @@ -7,6 +7,8 @@ #include "savestate.h" #include "boot_state.h" +#include "cdrom.h" +#include "interrupts.h" #include "psx_cycles.h" #include "psx_netplay.h" #include "psx_scheduler.h" @@ -15,10 +17,26 @@ #include #ifdef _WIN32 #include +#include #else #include +#include #endif +static double savestate_mono_ms(void) { +#ifdef _WIN32 + static LARGE_INTEGER freq; + LARGE_INTEGER c; + if (!freq.QuadPart) QueryPerformanceFrequency(&freq); + QueryPerformanceCounter(&c); + return (double)c.QuadPart * 1000.0 / (double)freq.QuadPart; +#else + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec * 1000.0 + (double)ts.tv_nsec / 1.0e6; +#endif +} + static char s_dir[512]; static uint32_t s_bios_checksum; static uint32_t s_entry_pc; @@ -32,7 +50,10 @@ static int s_load_cooldown_notice = 0; extern int psx_hle_scheduler_enabled(void); extern uint64_t s_frame_count; -#define SAVESTATE_LOAD_COOLDOWN_FRAMES 60u +/* Debounce only — long enough to ignore key-repeat / double F-key, short + * enough that a deliberate second load is not blocked for a full second. + * Wall time stretches if a restore hitch drops FPS (cooldown is in frames). */ +#define SAVESTATE_LOAD_COOLDOWN_FRAMES 12u static void ensure_dir(const char* dir) { if (!dir || !dir[0]) return; @@ -161,7 +182,12 @@ static int request_load_inner(int slot) { if (slot < 0 || slot >= SAVESTATE_SLOTS) return 0; if (s_frame_count < s_load_cooldown_until_frame) { if (!s_load_cooldown_notice) { - fprintf(stderr, "savestate: load ignored during restore cooldown\n"); + uint64_t left = s_load_cooldown_until_frame - s_frame_count; + fprintf(stderr, + "savestate: load ignored (%llu frame cooldown after restore; " + "%llu left)\n", + (unsigned long long)SAVESTATE_LOAD_COOLDOWN_FRAMES, + (unsigned long long)left); s_load_cooldown_notice = 1; } return 1; @@ -229,19 +255,37 @@ void savestate_poll(CPUState* cpu, uint32_t resume_pc) { int slot = s_load_pending; s_load_pending = -1; char path[600]; + const double t_load0 = savestate_mono_ms(); + double t_after_boot = t_load0; + double t_after_frontend = t_load0; if (!savestate_slot_path(slot, path, sizeof(path))) return; if (boot_state_load(path, s_bios_checksum, s_entry_pc, cpu)) { - psx_cycles_resync_after_restore(); + t_after_boot = savestate_mono_ms(); + psx_cycles_resync_after_restore(cpu); + /* Drop absolute-cycle IRQ cooldowns / VBlank phase from the + * pre-load host timeline (cycle rewind would otherwise blackout + * VBlank delivery for however long the user played past the save). */ + interrupts_resync_after_restore(); + /* Collapse restored / imminent CD second-response debt (ReadTOC, + * Init, seeks) so the picture does not freeze for ~1s after the + * restored frame presents. */ + cdrom_accelerate_after_savestate(); s_load_cooldown_until_frame = s_frame_count + SAVESTATE_LOAD_COOLDOWN_FRAMES; s_load_cooldown_notice = 0; - fprintf(stderr, "savestate: LOADED slot %d -> resuming pc=0x%08X\n", - slot, (unsigned)cpu->pc); /* Netplay post-load barrier observes this before the longjmp. */ s_load_completed = 1; /* Restage FBO/present latch so the restored frame is visible * immediately (avoids disabled-display blank latch + stale smooth). */ psx_frontend_on_savestate_loaded(); + t_after_frontend = savestate_mono_ms(); + fprintf(stderr, + "savestate: LOADED slot %d -> resuming pc=0x%08X " + "(boot=%.1f frontend=%.1f poll_total=%.1f ms)\n", + slot, (unsigned)cpu->pc, + t_after_boot - t_load0, + t_after_frontend - t_after_boot, + t_after_frontend - t_load0); /* Unwind to the scheduler and re-dispatch the restored PC. Never * returns; abandons the suspended CPS frames on the current stack. */ psx_scheduler_resume_at(cpu->pc); From 0944dfc056dcf5f4aeb5262420560336b293fb31 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Mon, 27 Jul 2026 11:04:03 -0400 Subject: [PATCH 31/38] Update recomp-net --- lib/recomp-net | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/recomp-net b/lib/recomp-net index 6c8eee98b..b389f167a 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit 6c8eee98b8b87abe5dca0b6b5588861d4721ca85 +Subproject commit b389f167ad7201db051ceb1237341496183ed002 From 227d39582311ab241e9b2a8a42e2116b4842790d Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Tue, 28 Jul 2026 00:16:30 -0400 Subject: [PATCH 32/38] ICE optimizations, connectivity, latency polling, save/loadstate performance --- docs/SAVESTATE_LOAD_PERFORMANCE.md | 205 +++++++ docs/internal/README.md | 4 + lib/recomp-net | 2 +- runtime/include/boot_state.h | 6 + runtime/include/psx_lobby_client.h | 20 +- runtime/include/psx_netplay.h | 6 +- runtime/include/savestate.h | 4 + runtime/src/boot_state.c | 71 ++- runtime/src/main.cpp | 93 +++- runtime/src/memcard.c | 29 +- runtime/src/psx_cycles.c | 21 + runtime/src/psx_lobby_client.c | 838 +++++++++++++++++++++++++++-- runtime/src/psx_netplay.c | 298 ++++++++-- runtime/src/savestate.c | 145 +++-- 14 files changed, 1572 insertions(+), 170 deletions(-) create mode 100644 docs/SAVESTATE_LOAD_PERFORMANCE.md diff --git a/docs/SAVESTATE_LOAD_PERFORMANCE.md b/docs/SAVESTATE_LOAD_PERFORMANCE.md new file mode 100644 index 000000000..50c58defb --- /dev/null +++ b/docs/SAVESTATE_LOAD_PERFORMANCE.md @@ -0,0 +1,205 @@ +# Savestate load performance — solo and netplay + +Status: **shipped** (MotK / Bomberman runtime + vendored `recomp-net`). +Scope: host-side restore hitch after F-key / protocol load, and lockstep +correctness when both peers restore together. + +This is not the CD “load window” turbo work in [`LOAD_TIME_ZERO.md`](LOAD_TIME_ZERO.md). +That doc is about *in-game* disc loads. This one is about **user savestate +restore** (`.pst` / `boot_state_*`) feeling instant and staying in sync under +delay-netplay. + +--- + +## Symptoms that drove the work + +| Mode | Symptom | Host FPS | Root class | +|------|---------|----------|------------| +| Solo | Picture frozen ~1–several seconds after load; worse on 2nd/3rd load of same slot | ~60 | Giant `psx_advance_cycles` / IRQ blackout / present latch | +| Netplay | Host `applied, waiting for guest…`; guest stuck at `applying after transfer…` then disconnect | N/A | INPUT / admit deadlock across apply ↔ ready | +| Netplay spam | Hang when mashing load while holding directions | N/A | Same tip-starvation class; tip runway emptied by `hard_resync` | + +--- + +## Solo restore path + +Call chain (HLE scheduler required): + +``` +F-key / debug → savestate_request_load + → savestate_poll (block leader) + → boot_state_load + → psx_cycles_resync_after_restore + → interrupts_resync_after_restore + → cdrom_accelerate_after_savestate + → psx_frontend_on_savestate_loaded + → psx_scheduler_resume_at(pc) /* longjmp; does not return */ +``` + +### 1. Anchor host-only cycle deadlines (`psx_cycles_resync_after_restore`) + +**Bug:** `gte_ts_done` / `muldiv_ts_done` and load-absorb fields live on +`CPUState` but are **not** in the savestate wire format. After a warm load, +`psx_cycle_count` rewinds to the snapshot while those deadlines still sit on +the pre-load live timeline. The next GTE / muldiv stall then advances +`(live_ts − restored_cycle)` in one shot — tens of millions of cycles, many +nested presents, `chk(e=0)`, sticky VBlank, no draw. + +**Fix:** After restore, set `gte_ts_done` / `muldiv_ts_done` to the restored +`psx_cycle_count`, clear absorb/fudge state, and re-anchor device sync / +idle-skip latches. + +- `runtime/src/psx_cycles.c` — `psx_cycles_resync_after_restore` +- Called from `savestate_poll` immediately after a successful `boot_state_load` + +### 2. Clear absolute IRQ cooldowns (`interrupts_resync_after_restore`) + +**Bug:** `post_exception_cooldown_until` is an absolute guest-cycle stamp. +Leaving it in the future after a clock rewind blocks every IRQ (including +VBlank) until the restored clock “catches up” — freeze for however long the +user played past the save, while host FPS stays ~60. + +**Fix:** Zero the cooldown and related exception / VBlank phase bookkeeping. + +### 3. Cap CD second-response debt (`cdrom_accelerate_after_savestate`) + +Restored / imminent CD command delays (ReadTOC, Init, seeks) can freeze the +picture for ~1s after the restored frame presents. A short post-load boost +window clamps outstanding delays so the display recovers immediately. + +### 4. Frontend present / audio re-anchor (`psx_frontend_on_savestate_loaded`) + +After restore: + +- Force present (`s_force_present_after_load`) so a disabled-display blank + latch or smooth-60 duplicate does not hide the restored frame. +- Invalidate GL present-dirty early-out (`gl_renderer_invalidate_present`) — + critical on **2nd+ load of the same slot**, where the framebuffer can match + the last swap and skip `SwapWindow`. +- Reset frame pacer + FPS baseline (admit / hitch can leave deadlines in the past). +- Resync guest-cycle→audio sample budgeting. + +### 5. Smaller / faster `.pst` I/O (boot_state v4 zlib) + +Large sections may be zlib-compressed on save (`BS_SEC` pad bit0). Shrinks +disk and helps slow storage; older readers still accept uncompressed v3. + +### 6. No post-load request cooldown + +A former 12-frame (earlier 60-frame) “load ignored” debounce in +`request_load_inner` was removed. It was only key-repeat padding and could +break netplay: protocol path entered `LOAD_APPLYING` while the cooldown +silently refused to stage `s_load_pending`. + +Overlapping loads are gated elsewhere: + +- Solo: single `s_load_pending` slot (last request wins). +- Netplay: `np_xfer_busy()` until the barrier clears. + +### Diagnostics + +`PSX_POST_LOAD_PROBE=1` arms a short post-load window that logs advance size, +IRQ check outcomes, dirty/idle/horizon, and host ms (`main.cpp` + cycle +attribution). Use this when a freeze returns; do **not** pause the runtime to +measure — extend the probe / rings instead. + +--- + +## Netplay load path + +Host-only initiate (`psx_netplay_request_load`). Guest follows via STATE_* on +the same UDP/relay path as inputs. + +### High-level sequence + +``` +host: hash PROBE(op=LOAD, size, crc) +guest: REPLY match? + yes → both stage savestate_request_load_protocol + LOAD_APPLYING + no → host STATE_BEGIN/CHUNK → guest writes sandbox + → both stage load in np_apply_ready_state (transfer complete) +both: admit while savestate_pending (guest cycles → savestate_poll) +both: local LOADED → LOAD_READY +host: ready PROBE(op=LOAD, size=0, NP_LOAD_READY_CRC) +guest: ACK when applied +both: hard_resync + prime_delay_inputs (once) +both: stay in LOAD_READY until try_admit succeeds → resume lockstep +``` + +### Correctness / performance rules (do not regress) + +1. **Stage apply only when both peers have the bytes** + - Do **not** call `savestate_request_load_protocol` on the host at SEND + begin. Host would restore during transfer, enter ready early, and starve + the guest of tips needed for `savestate_poll`. + - Hash-miss: both stage in `np_apply_ready_state` after transfer. + - Hash-hit: both stage when the probe reply is handled. + +2. **Keep INPUT flowing until mutual ready** + - `LOAD_APPLYING` / enter `LOAD_READY`: do **not** set + `input_send_suppress`. + - Suppress only inside `np_commit_load_sync` for the + `hard_resync` → `prime_delay_inputs` window (prime clears suppress). + - App barrier (`psx_netplay_poll_admit`) freezes sim after apply / + during ready; that is separate from INPUT emission. + +3. **Ready probe must not stall INPUT (`recomp-net`)** + - `rnet_session_state_probe` with `LOAD` + `total_size == 0` sets + `state_stall_sim = 0` (same as SAVE coord). + - Previously `state_stall_sim = 1` blocked `send_input_bundle` on the host + as soon as the first peer finished apply — same deadlock as suppress, + worse under spam because `hard_resync` leaves only ~D tip frames of + runway. + - Hash probes (`total_size != 0`) still stall until finish / transfer. + +4. **`hard_resync` + prime once at mutual ready, not at apply** + - Clearing rings / `sim_tick → 0` at apply time lets the later peer wipe + the earlier peer’s tip and stall resume. + - After mutual ready: clear local **and** remote rings, prime neutral + delay prefix, wait for `try_admit` (fresh tip + INPUT_CONFIRM) before + dropping `LOAD_READY`. + +5. **Peer disconnect during barrier** + - `psx_netplay_peer_disconnected(0)` while `psx_netplay_in_load_barrier()` + so rx silence for a multi-second restore does not soft-exit to lobby. + BYE / `peer_gone` still honored. + +6. **Spam loads** + - Host ignores new requests while `np_xfer_busy()`. + - No savestate-layer frame cooldown (see solo §6). + +### Where the code lives + +| Piece | Location | +|-------|----------| +| App xfer / barrier | `runtime/src/psx_netplay.c` | +| Session stall / tip / hard_resync | `lib/recomp-net/src/session/rnet_session.c` | +| Protocol notes | `lib/recomp-net/docs/protocol.md` | +| Staging API | `savestate_request_load_protocol` (bypasses netplay user block) | + +--- + +## Expected log lines (healthy netplay load) + +``` +netplay load slot=N — hash probe (…) +netplay load slot=N — hashes match, applying… # or transferring / applying after transfer +savestate: LOADED slot N … +netplay load slot=N — applied, waiting for guest… # host +netplay guest load slot=N — applied, waiting for host… # or ready acked +netplay load slot=N — mutual ready, waiting lockstep… +netplay load slot=N — peer ready, resuming lockstep +``` + +Stuck on `waiting for guest` / guest never leaving `applying…` → tip +starvation (rules 1–3). Sticky `INPUT desync … stalled` → confirm/hash +disagreement after resume (inspect tip epoch / history collisions). + +--- + +## Related docs + +- [`LOAD_TIME_ZERO.md`](LOAD_TIME_ZERO.md) — in-game CD load wall-time (different problem). +- [`CYCLE_TIMING_ARCH.md`](CYCLE_TIMING_ARCH.md) — cycle / GTE stall model. +- `lib/recomp-net/docs/protocol.md` — STATE_PROBE / post-load ready rendezvous. +- `runtime/include/boot_state.h` — `.pst` section version / zlib flags. diff --git a/docs/internal/README.md b/docs/internal/README.md index 5ab8d0de7..91857f244 100644 --- a/docs/internal/README.md +++ b/docs/internal/README.md @@ -40,3 +40,7 @@ behind a specific subsystem. ## Overlay cache internals - [`SLJIT_PERSIST_CACHE.md`](SLJIT_PERSIST_CACHE.md) — persisted overlay-shard cache design. + +## Savestate / netplay +- [`../SAVESTATE_LOAD_PERFORMANCE.md`](../SAVESTATE_LOAD_PERFORMANCE.md) — + solo restore hitch fixes and netplay load barrier / INPUT rules. diff --git a/lib/recomp-net b/lib/recomp-net index 6c8eee98b..9bd27bd53 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit 6c8eee98b8b87abe5dca0b6b5588861d4721ca85 +Subproject commit 9bd27bd5372d77bd030bfab8b6e3e5135006ad5d diff --git a/runtime/include/boot_state.h b/runtime/include/boot_state.h index 78ec3a11b..fd288f463 100644 --- a/runtime/include/boot_state.h +++ b/runtime/include/boot_state.h @@ -1,6 +1,7 @@ #ifndef PSX_BOOT_STATE_H #define PSX_BOOT_STATE_H +#include #include #include "cpu_state.h" @@ -101,6 +102,11 @@ int boot_state_save(const CPUState* cpu, uint32_t bios_checksum, int boot_state_load(const char* path, uint32_t bios_checksum, uint32_t entry_pc, CPUState* cpu); +/* Same as boot_state_load, but from an already-buffered .pst image (netplay). */ +int boot_state_load_buffer(const uint8_t* file, size_t file_len, + uint32_t bios_checksum, uint32_t entry_pc, + CPUState* cpu); + /* Register a deferred capture: when boot_state_trigger_capture() fires (from * fntrace at game-start), serialize to path. One-shot. */ void boot_state_set_capture(const char* path, uint32_t bios_checksum, diff --git a/runtime/include/psx_lobby_client.h b/runtime/include/psx_lobby_client.h index 0d60e908f..b98a98e8f 100644 --- a/runtime/include/psx_lobby_client.h +++ b/runtime/include/psx_lobby_client.h @@ -14,6 +14,7 @@ extern "C" { #define PSX_LOBBY_ENDPOINT_LEN 64 #define PSX_LOBBY_MAX_LIST 32 #define PSX_LOBBY_MAX_MEMBERS 8 +#define PSX_LOBBY_MAX_LAN_EPS 4 #define PSX_LOBBY_LANG_LEN 16 #ifndef PSX_GAME_VERSION @@ -28,6 +29,13 @@ typedef struct PsxLobbyRow { int player_count; int max_slots; int has_password; + /* Host UDP endpoint from the server list (for one-shot latency probes). */ + char host_endpoint[PSX_LOBBY_ENDPOINT_LEN]; + /* Legacy hub lan_endpoints (compat). Prefer local UDP beacon by lobby_id. */ + char lan_endpoints[PSX_LOBBY_MAX_LAN_EPS][PSX_LOBBY_ENDPOINT_LEN]; + int lan_count; + /* Round-trip ms to a reachable candidate; -1 unknown / timed out. */ + int latency_ms; } PsxLobbyRow; typedef struct PsxLobbyMember { @@ -140,9 +148,10 @@ int psx_lobby_set_match_caps(const PsxLobbyMatchCaps *caps); int psx_lobby_member_count(void); int psx_lobby_member_get(int index, PsxLobbyMember *out); -/* Waiting-room RTT to the lobby host in ms for `slot`, or -1 if unknown. - * Host's own seat is always -1. Guests measure via signal ping; hosts learn - * guest RTT from peer reports. */ +/* Waiting-room peer RTT in ms for `slot`, or -1 if unknown. + * Host's own seat is always -1. Measured over UDP (rnet_rtt_probe) on the + * advertised game endpoints — not the lobby WebSocket. Guests also REPORT + * so the host UI updates when it cannot dial the guest yet. */ int psx_lobby_member_latency_ms(int slot); /* True when member.player_id matches psx_lobby_host_player_id(). @@ -157,6 +166,11 @@ int psx_lobby_member_is_host(const PsxLobbyMember *member); */ int psx_lobby_send_signal(int type, int flag, const char *text); int psx_lobby_poll_signal(int *type, int *flag, char *text, size_t text_cap); +/* Drop queued ICE SDP/candidates (soft-return / rematch hygiene). */ +void psx_lobby_clear_signals(void); +/* When 0, inbound ICE op:signal is discarded (lobby / post-match). Launch + * re-enables so early peer offers are kept until netplay drains them. */ +void psx_lobby_set_ice_signal_accept(int accept); /* * Coturn / ICE credentials minted by the WS lobby diff --git a/runtime/include/psx_netplay.h b/runtime/include/psx_netplay.h index dcdcf439f..7188a0b33 100644 --- a/runtime/include/psx_netplay.h +++ b/runtime/include/psx_netplay.h @@ -103,7 +103,7 @@ int psx_netplay_is_host(void); int psx_netplay_request_save(int slot); int psx_netplay_request_load(int slot); -/* 1 while post-load apply/ready barrier owns the clock (no FPS / no present). */ +/* 1 while load probe/transfer/apply/ready owns the clock (no FPS / no present). */ int psx_netplay_in_load_barrier(void); /* Stage local pad for the current sim tick. Ignored once that tick is latched. */ @@ -156,6 +156,10 @@ void psx_netplay_catchup_consume_frame(void); /* Park the admit barrier until a peer datagram may be ready (or timeout). */ void psx_netplay_wait_recv(int timeout_ms); +/* Diagnostics for a stuck admit barrier (stall name, sim tick, remote lead). */ +void psx_netplay_admit_wait_info(char *stall_out, size_t stall_cap, + uint32_t *sim_tick_out, int *lead_out); + /* Normalize sticks (deadzone → center) for stabler cross-device blobs. */ void psx_netplay_normalize_pad(PsxNetPad *pad); diff --git a/runtime/include/savestate.h b/runtime/include/savestate.h index ca1c9d23f..f55309bda 100644 --- a/runtime/include/savestate.h +++ b/runtime/include/savestate.h @@ -58,6 +58,10 @@ int savestate_request_load(int slot); int savestate_request_save_protocol(int slot); int savestate_request_load_protocol(int slot); +/* Netplay LOAD transfer: stage an in-memory .pst (no disk write). Copied + * internally; applied by savestate_poll like a normal slot load. */ +int savestate_request_load_blob_protocol(const void* data, size_t size); + /* 1 while a staged save/load has not yet been consumed by savestate_poll. */ int savestate_pending(void); diff --git a/runtime/src/boot_state.c b/runtime/src/boot_state.c index e96942e5f..4f2a93a88 100644 --- a/runtime/src/boot_state.c +++ b/runtime/src/boot_state.c @@ -417,12 +417,9 @@ static int apply_section(uint32_t tag, const uint8_t* p, uint32_t len, } } -int boot_state_load(const char* path, uint32_t bios_checksum, - uint32_t entry_pc, CPUState* cpu) { - FILE* f = fopen(path, "rb"); - long sz; - uint8_t* file = NULL; - size_t file_len = 0; +int boot_state_load_buffer(const uint8_t* file, size_t file_len, + uint32_t bios_checksum, uint32_t entry_pc, + CPUState* cpu) { const uint8_t* cur; const uint8_t* end; BootStateHeader h; @@ -435,31 +432,16 @@ int boot_state_load(const char* path, uint32_t bios_checksum, uint32_t seen = 0; int ok = 1; const double t0 = boot_state_mono_ms(); - double t_after_read = t0; double inflate_ms = 0.0; double apply_ram_ms = 0.0; double apply_vram_ms = 0.0; double apply_spuram_ms = 0.0; double apply_other_ms = 0.0; - if (!f) return 0; - if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return 0; } - sz = ftell(f); - if (sz < (long)BOOT_STATE_HEADER_WIRE_BYTES || sz > 64L * 1024L * 1024L) { - fclose(f); - return 0; - } - if (fseek(f, 0, SEEK_SET) != 0) { fclose(f); return 0; } - file_len = (size_t)sz; - file = (uint8_t*)malloc(file_len); - if (!file) { fclose(f); return 0; } - if (fread(file, 1, file_len, f) != file_len) { - free(file); - fclose(f); + if (!file || file_len < BOOT_STATE_HEADER_WIRE_BYTES || + file_len > 64u * 1024u * 1024u) { return 0; } - fclose(f); - t_after_read = boot_state_mono_ms(); /* Parse header from the in-memory image (one I/O, then CPU-side inflate). */ pst_r_init(&hr, file, BOOT_STATE_HEADER_WIRE_BYTES); @@ -473,7 +455,6 @@ int boot_state_load(const char* path, uint32_t bios_checksum, !pst_r_u32(&hr, &h.codegen_ver) || !pst_r_u32(&hr, &h.section_count) || !pst_r_u32(&hr, &h.reserved)) { - free(file); return 0; } @@ -485,7 +466,6 @@ int boot_state_load(const char* path, uint32_t bios_checksum, h.codegen_hash != (uint32_t)PSX_OVERLAY_CODEGEN_HASH || h.abi_tag != (int32_t)PSX_OVERLAY_ABI_TAG || h.codegen_ver != (uint32_t)PSX_OVERLAY_CODEGEN_VER) { - free(file); return 0; } @@ -561,7 +541,6 @@ int boot_state_load(const char* path, uint32_t bios_checksum, } free(inflated); } - free(file); if (!ok || (seen & required) != required) return 0; @@ -572,16 +551,52 @@ int boot_state_load(const char* path, uint32_t bios_checksum, { const double total_ms = boot_state_mono_ms() - t0; fprintf(stderr, - "savestate: load_timing read=%.1f inflate=%.1f " + "savestate: load_timing read=0.0 inflate=%.1f " "apply_ram=%.1f apply_vram=%.1f apply_spuram=%.1f " "apply_other=%.1f total=%.1f ms (file=%zu)\n", - t_after_read - t0, inflate_ms, + inflate_ms, apply_ram_ms, apply_vram_ms, apply_spuram_ms, apply_other_ms, total_ms, file_len); } return 1; } +int boot_state_load(const char* path, uint32_t bios_checksum, + uint32_t entry_pc, CPUState* cpu) { + FILE* f = fopen(path, "rb"); + long sz; + uint8_t* file = NULL; + size_t file_len = 0; + int ok; + const double t0 = boot_state_mono_ms(); + double t_after_read; + + if (!f) return 0; + if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return 0; } + sz = ftell(f); + if (sz < (long)BOOT_STATE_HEADER_WIRE_BYTES || sz > 64L * 1024L * 1024L) { + fclose(f); + return 0; + } + if (fseek(f, 0, SEEK_SET) != 0) { fclose(f); return 0; } + file_len = (size_t)sz; + file = (uint8_t*)malloc(file_len); + if (!file) { fclose(f); return 0; } + if (fread(file, 1, file_len, f) != file_len) { + free(file); + fclose(f); + return 0; + } + fclose(f); + t_after_read = boot_state_mono_ms(); + (void)t0; + (void)t_after_read; + + ok = boot_state_load_buffer(file, file_len, bios_checksum, entry_pc, cpu); + free(file); + return ok; +} + void boot_state_set_capture(const char* path, uint32_t bios_checksum, uint32_t entry_pc) { strncpy(s_capture_path, path, sizeof(s_capture_path) - 1); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 346010a18..fa5d526ac 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1627,6 +1627,11 @@ static void teardown_game_session_keep_lobby(void) { if (sdl_pixel_buf) { std::free(sdl_pixel_buf); sdl_pixel_buf = nullptr; } psx_lobby_set_ready(0); psx_lobby_clear_launch_pending(); +#if defined(PSX_HAS_LOBBY_CLIENT) + /* Drop prior-match ICE SDP/candidates; ignore new ones until next launch. */ + psx_lobby_clear_signals(); + psx_lobby_set_ice_signal_accept(0); +#endif psx_clear_return_to_lobby(); } @@ -3226,6 +3231,8 @@ static void netplay_barrier_admit(int override) { SDL_PumpEvents(); SDL_FlushEvent(SDL_QUIT); static int desync_logged = 0; + const Uint64 barrier_t0 = SDL_GetTicks64(); + Uint64 last_stall_log_ms = barrier_t0; const uint64_t admit_t0 = netplay_timing_on() ? SDL_GetPerformanceCounter() : 0; /* Guest quantum = time since previous admit returned (fiber ran). */ @@ -3235,6 +3242,7 @@ static void netplay_barrier_admit(int override) { } for (;;) { uint32_t dt = 0, lh = 0, rh = 0; + const Uint64 now_ms = SDL_GetTicks64(); /* Load apply/ready suppresses INPUT (and can sit silent for seconds on * a hash-match .pst). timeout=0 still honors BYE (peer_gone) but does * not treat rx silence as disconnect — that was kicking both peers to @@ -3244,6 +3252,44 @@ static void netplay_barrier_admit(int override) { netplay_soft_exit("netplay_peer_disconnect"); if (psx_return_to_lobby_requested()) return; } + /* Mutual INPUT/CONFIRM stall still refreshes last_peer_rx — detect + * "no sim progress" separately (common rematch + TURN loss mode). + * Load probe/xfer/apply/ready uses a longer budget (TURN + 1.4MB). */ + if (psx_netplay_in_load_barrier() && now_ms - barrier_t0 >= 90000u) { + char stall[96]; + uint32_t sim = 0; + int lead = 0; + psx_netplay_admit_wait_info(stall, sizeof(stall), &sim, &lead); + std::fprintf(stderr, + "psxrecomp: netplay load barrier timeout sim=%u " + "stall=%s lead=%d — returning to lobby\n", + (unsigned)sim, stall[0] ? stall : "?", lead); + netplay_soft_exit("netplay_load_stall"); + if (psx_return_to_lobby_requested()) return; + } else if (!psx_netplay_in_load_barrier() && + now_ms - barrier_t0 >= 20000u) { + char stall[64]; + uint32_t sim = 0; + int lead = 0; + psx_netplay_admit_wait_info(stall, sizeof(stall), &sim, &lead); + std::fprintf(stderr, + "psxrecomp: netplay admit stall timeout sim=%u " + "stall=%s lead=%d — returning to lobby\n", + (unsigned)sim, stall[0] ? stall : "?", lead); + netplay_soft_exit("netplay_admit_stall"); + if (psx_return_to_lobby_requested()) return; + } else if (now_ms - last_stall_log_ms >= 2000u) { + char stall[96]; + uint32_t sim = 0; + int lead = 0; + psx_netplay_admit_wait_info(stall, sizeof(stall), &sim, &lead); + std::fprintf(stderr, + "psxrecomp: netplay admit waiting sim=%u stall=%s " + "lead=%d (%llums)\n", + (unsigned)sim, stall[0] ? stall : "?", lead, + (unsigned long long)(now_ms - barrier_t0)); + last_stall_log_ms = now_ms; + } psx_lobby_pump(); if (psx_netplay_input_desync(&dt, &lh, &rh)) { if (!desync_logged) { @@ -4524,7 +4570,8 @@ namespace { RecompLauncherCNetplayLaunch g_lnch_pending_direct_launch{}; int g_lnch_lobby_input_delay = 2; int g_lnch_force_input_relay = 0; - int g_lnch_force_turn = 0; + /* Default on: CGNAT-safe relay-only ICE (BattleShip-style online path). */ + int g_lnch_force_turn = 1; int g_lnch_host_max_slots = 2; /* Delay-sync READY/START waits for every seat in slot_count. Use seated @@ -4577,6 +4624,8 @@ namespace { sockaddr_in g_lnch_lan_peers[kAeLanMaxSlots]{}; bool g_lnch_lan_peer_ok[kAeLanMaxSlots]{}; uint32_t g_lnch_lan_join_pulse_ms = 0; + /* LAN list latency from last Refresh probe; -1 unknown. */ + int g_lnch_lan_latency_ms = -1; std::filesystem::path ae_np_lan_file() { return std::filesystem::current_path() / "netplay_lan_lobby.txt"; @@ -5012,6 +5061,7 @@ namespace { if (out->max_slots > PSX_MAX_PLAYERS) out->max_slots = PSX_MAX_PLAYERS; if (out->max_slots > kAeLanMaxSlots) out->max_slots = kAeLanMaxSlots; out->has_password = state.password.empty() ? 0 : 1; + out->latency_ms = g_lnch_hosting_lan ? 0 : g_lnch_lan_latency_ms; return 1; } @@ -5038,35 +5088,36 @@ namespace { return ok; } - /* Probe whether a LAN host is still answering on endpoint. */ - static bool ae_np_lan_probe_host_ms(const std::string& endpoint, uint32_t timeout_ms) { + /* Probe LAN host; returns RTT ms, or -1 on timeout/failure. */ + static int ae_np_lan_probe_rtt_ms(const std::string& endpoint, uint32_t timeout_ms) { char host[64]; - if (!ae_np_lan_endpoint_host(endpoint, host, sizeof(host))) return false; + if (!ae_np_lan_endpoint_host(endpoint, host, sizeof(host))) return -1; #ifdef _WIN32 WSADATA wsa; WSAStartup(MAKEWORD(2, 2), &wsa); #endif AeLanSock s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); - if (s == kAeLanSockInvalid) return false; + if (s == kAeLanSockInvalid) return -1; if (!ae_np_lan_set_nonblock(s)) { ae_np_lan_sock_close(&s); - return false; + return -1; } sockaddr_in to{}; to.sin_family = AF_INET; to.sin_port = htons((uint16_t)ae_np_lan_endpoint_port(endpoint)); if (inet_pton(AF_INET, host, &to.sin_addr) != 1) { ae_np_lan_sock_close(&s); - return false; + return -1; } const char ping[] = "MOTK1 PING\n"; + const uint32_t t0 = SDL_GetTicks(); #ifdef _WIN32 sendto(s, ping, (int)sizeof(ping) - 1, 0, (const sockaddr*)&to, sizeof(to)); #else sendto(s, ping, sizeof(ping) - 1, 0, (const sockaddr*)&to, sizeof(to)); #endif - const uint32_t deadline = SDL_GetTicks() + timeout_ms; - bool alive = false; + const uint32_t deadline = t0 + timeout_ms; + int rtt = -1; while ((int32_t)(deadline - SDL_GetTicks()) > 0) { char buf[64]; sockaddr_in from{}; @@ -5082,14 +5133,18 @@ namespace { if (n > 0) { buf[n] = '\0'; if (std::strncmp(buf, "MOTK1 PONG", 10) == 0) { - alive = true; + rtt = (int)(SDL_GetTicks() - t0); break; } } SDL_Delay(5); } ae_np_lan_sock_close(&s); - return alive; + return rtt; + } + + static bool ae_np_lan_probe_host_ms(const std::string& endpoint, uint32_t timeout_ms) { + return ae_np_lan_probe_rtt_ms(endpoint, timeout_ms) >= 0; } static bool ae_np_lan_probe_host(const std::string& endpoint) { @@ -5291,12 +5346,19 @@ namespace { * reading session_id/started from the file. Started lobbies are already * hidden by ae_np_lan_list_visible(). */ static void ae_np_lan_rescan(void) { - if (g_lnch_hosting_lan) return; + if (g_lnch_hosting_lan) { + g_lnch_lan_latency_ms = 0; + return; + } if (g_lnch_joined_lan) return; AeLanLobbyState st; - if (!ae_np_read_lan_file_state(&st)) return; + if (!ae_np_read_lan_file_state(&st)) { + g_lnch_lan_latency_ms = -1; + return; + } if (st.started) return; - if (!ae_np_lan_probe_host(st.endpoint)) { + g_lnch_lan_latency_ms = ae_np_lan_probe_rtt_ms(st.endpoint, 200u); + if (g_lnch_lan_latency_ms < 0) { std::error_code ec; std::filesystem::remove(ae_np_lan_file(), ec); } @@ -5767,6 +5829,7 @@ namespace { out->player_count = row.player_count; out->max_slots = row.max_slots; out->has_password = row.has_password; + out->latency_ms = row.latency_ms; return 1; } @@ -6442,6 +6505,8 @@ namespace { g_lnch_pending_direct_launch = {}; psx_lobby_set_ready(0); psx_lobby_clear_launch_pending(); + psx_lobby_clear_signals(); + psx_lobby_set_ice_signal_accept(0); if (!(g_lnch_hosting_lan || g_lnch_joined_lan)) return; AeLanLobbyState st; if (ae_np_read_lan_state(&st)) { diff --git a/runtime/src/memcard.c b/runtime/src/memcard.c index c0f95005a..215f17fca 100644 --- a/runtime/src/memcard.c +++ b/runtime/src/memcard.c @@ -83,12 +83,37 @@ static void memcard_format(uint8_t *data) { memcpy(&data[63 * 128], &data[0], 128); } +/* mkdir -p: create each path component (guest sandbox is /netplay). */ static void memcard_ensure_dir(const char* dir) { + char tmp[512]; + size_t len; + size_t i; if (!dir || !dir[0]) return; + strncpy(tmp, dir, sizeof(tmp) - 1); + tmp[sizeof(tmp) - 1] = '\0'; + len = strlen(tmp); + while (len > 1 && (tmp[len - 1] == '/' || tmp[len - 1] == '\\')) { + tmp[--len] = '\0'; + } + for (i = 1; i < len; i++) { + if (tmp[i] == '/' || tmp[i] == '\\') { +#ifdef _WIN32 + if (i == 2 && tmp[1] == ':') + continue; +#endif + tmp[i] = '\0'; +#ifdef _WIN32 + (void)_mkdir(tmp); +#else + (void)mkdir(tmp, 0755); +#endif + tmp[i] = '/'; + } + } #ifdef _WIN32 - (void)_mkdir(dir); + (void)_mkdir(tmp); #else - (void)mkdir(dir, 0755); + (void)mkdir(tmp, 0755); #endif } diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index b9e65afa6..0ceeeab2a 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -13,6 +13,9 @@ #include "sio.h" #include "starvation_ring.h" #include "timers.h" +#if defined(PSX_HAS_RECOMP_NET) +#include "psx_netplay.h" +#endif #ifdef PSX_COSIM #include "cosim_state.h" #endif @@ -205,6 +208,11 @@ void psx_devices_service_to_now(void) { if (target >= s_next_watchdog) { s_next_watchdog = target + 65536ull; psx_cycles_watchdog_fire(); +#if defined(PSX_HAS_RECOMP_NET) + /* INPUT/CONFIRM retransmit during BIOS free-run (before vblank admit). */ + if (psx_netplay_active()) + psx_netplay_pump(); +#endif } if (target >= s_next_pc_sample) { s_next_pc_sample = target + 1048576ull; @@ -307,6 +315,19 @@ void psx_advance_cycles_slow(uint32_t cycles) { psx_cycles_pc_sample_fire(); } #endif +#if defined(PSX_HAS_RECOMP_NET) + /* Independent of starvation ring (off in PSX_NO_DEBUG_TOOLS release). + * Retransmit INPUT/CONFIRM during BIOS free-run before first vblank. */ + { + static uint32_t s_net_pump_throttle; + s_net_pump_throttle += cycles; + if (s_net_pump_throttle >= 65536u) { + s_net_pump_throttle = 0; + if (psx_netplay_active()) + psx_netplay_pump(); + } + } +#endif } uint64_t psx_get_cycle_count(void) { diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index 5f3e5bd3b..d512f922a 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -72,6 +72,8 @@ int psx_lobby_poll_signal(int *type, int *flag, char *text, size_t text_cap) (void)text_cap; return 0; } +void psx_lobby_clear_signals(void) {} +void psx_lobby_set_ice_signal_accept(int accept) { (void)accept; } int psx_lobby_request_turn_credentials(void) { return -1; } const PsxLobbyTurnCredentials *psx_lobby_turn_credentials(void) { @@ -89,6 +91,9 @@ void psx_lobby_clear_launch_pending(void) {} #include "rnet_ws.h" #include "rnet_sha1.h" +#include "recomp_net/address.h" +#include "recomp_net/lan_beacon.h" +#include "recomp_net/rtt_probe.h" #if defined(_WIN32) #include @@ -154,6 +159,8 @@ typedef struct { int sig_head; int sig_tail; int sig_count; + /* 0 while in lobby / after soft-return — drop stale ICE; launch sets 1. */ + int ice_signal_accept; /* Coturn mint from WS get_turn_credentials. */ PsxLobbyTurnCredentials turn; time_t turn_received_at; @@ -187,6 +194,545 @@ static uint64_t lobby_mono_ms(void) /* Defined later; used by waiting-room RTT signal handling. */ int psx_lobby_send_signal(int type, int flag, const char *text); +static int endpoint_port_is_zero(const char *ep); +static int using_server_input_relay(const PsxLobbyJoinInfo *j); +static void queue_send(const char *msg); +static void flush_pending(void); +static void lobby_rtt_close(void); + +static RNetRttProbe *g_rtt_probe; + +/* One-shot list latency: burst-ping LAN + public candidates after lobby_list. */ +#define PSX_LOBBY_MAX_PROBE_PEND (PSX_LOBBY_MAX_LIST * (PSX_LOBBY_MAX_LAN_EPS + 1)) +static RNetRttProbe *g_list_rtt_probe; +static int g_list_rtt_active; +static int g_list_rtt_on_next_list; /* set by request_list / Refresh */ +static uint64_t g_list_rtt_deadline_ms; +static unsigned long long g_list_rtt_sent_ts[PSX_LOBBY_MAX_PROBE_PEND]; +static int g_list_rtt_lobby_idx[PSX_LOBBY_MAX_PROBE_PEND]; +static int g_list_rtt_pend_active[PSX_LOBBY_MAX_PROBE_PEND]; +static int g_list_rtt_pend_count; + +/* Local UDP broadcast discovery — LAN endpoint never goes to the hub. */ +static RNetLanBeacon *g_lan_beacon_pub; +static RNetLanBeacon *g_lan_beacon_listen; + +/* Host STUN advertise → set_host_endpoint for list / pre-join RTT. */ +enum { + HOST_ADV_IDLE = 0, + HOST_ADV_WAIT_TURN, + HOST_ADV_DONE +}; +static int g_host_adv_state; +static uint64_t g_host_adv_deadline_ms; + +static void lobby_host_advertise_reset(void) +{ + g_host_adv_state = HOST_ADV_IDLE; + g_host_adv_deadline_ms = 0; +} + +static int host_endpoint_is_loopback(const char *ep) +{ + if (!ep || !ep[0]) + return 0; + if (strncmp(ep, "127.", 4) == 0) + return 1; + if (strncmp(ep, "::1:", 4) == 0 || strcmp(ep, "::1") == 0) + return 1; + if (strncmp(ep, "localhost:", 10) == 0 || strcmp(ep, "localhost") == 0) + return 1; + return 0; +} + +static int endpoint_host_port(const char *ep, char *host, size_t host_cap, int *port_out) +{ + const char *colon; + size_t n; + if (!ep || !ep[0] || !host || host_cap == 0 || !port_out) + return 0; + colon = strrchr(ep, ':'); + if (!colon || colon == ep || !colon[1]) + return 0; + n = (size_t)(colon - ep); + if (n + 1 > host_cap) + n = host_cap - 1; + memcpy(host, ep, n); + host[n] = '\0'; + *port_out = (int)strtoul(colon + 1, NULL, 10); + return *port_out > 0 && *port_out <= 65535; +} + +static int parse_ipv4_dotted(const char *host, unsigned *o) +{ + unsigned a, b, c, d; + char extra; + if (!host || !o) + return 0; + if (sscanf(host, "%u.%u.%u.%u%c", &a, &b, &c, &d, &extra) != 4) + return 0; + if (a > 255 || b > 255 || c > 255 || d > 255) + return 0; + o[0] = a; + o[1] = b; + o[2] = c; + o[3] = d; + return 1; +} + +static int ipv4_is_rfc1918(const unsigned o[4]) +{ + if (!o) + return 0; + if (o[0] == 10) + return 1; + if (o[0] == 172 && o[1] >= 16 && o[1] <= 31) + return 1; + if (o[0] == 192 && o[1] == 168) + return 1; + return 0; +} + +static int ipv4_is_link_local(const unsigned o[4]) +{ + return o && o[0] == 169 && o[1] == 254; +} + +/* Public WAN IPv4 suitable for lobby-list host_endpoint (not LAN/loopback). */ +static int endpoint_is_public_ipv4(const char *ep) +{ + char host[128]; + unsigned o[4]; + int port = 0; + if (!endpoint_host_port(ep, host, sizeof(host), &port)) + return 0; + if (!parse_ipv4_dotted(host, o)) + return 0; + if (o[0] == 0 || o[0] == 127 || o[0] >= 224) + return 0; + if (ipv4_is_rfc1918(o) || ipv4_is_link_local(o)) + return 0; + return 1; +} + +static void lobby_rtt_ensure(void); +static void lobby_list_rtt_start(int force_all); + +/* /24 heuristic — good enough for typical home/small office LANs. */ +static int ipv4_same_lan24(const unsigned a[4], const unsigned b[4]) +{ + return a && b && a[0] == b[0] && a[1] == b[1] && a[2] == b[2]; +} + +static int my_bind_port(void) +{ + char host[128]; + int port = 7777; + if (g_lc.my_bind[0] && endpoint_host_port(g_lc.my_bind, host, sizeof(host), &port)) + return port; + return 7777; +} + +/* Single LAN advertise candidate: the Host Lobby "Advertised IP" / my_bind NIC. + * Do not enumerate every local interface — only the menu selection. */ +static int collect_host_lan_endpoints(char out[][PSX_LOBBY_ENDPOINT_LEN], int max_out) +{ + char bind_host[128]; + unsigned bind_o[4]; + int port = my_bind_port(); + + if (!out || max_out <= 0) + return 0; + bind_host[0] = '\0'; + if (!g_lc.my_bind[0] || + !endpoint_host_port(g_lc.my_bind, bind_host, sizeof(bind_host), &port) || + !parse_ipv4_dotted(bind_host, bind_o) || !ipv4_is_rfc1918(bind_o) || + strcmp(bind_host, "0.0.0.0") == 0) + return 0; + snprintf(out[0], PSX_LOBBY_ENDPOINT_LEN, "%s:%d", bind_host, port); + return 1; +} + +/* Discover a public UDP mapping for the game port. Coturn on the same LAN can + * return RFC1918 — reject those and fall back to any:/port + public STUN. */ +static int lobby_host_stun_public(char *out, size_t out_len) +{ + RNetExternalIpv4Config stun; + char any_bind[64]; + char endpoint[RNET_ENDPOINT_TEXT_MAX]; + int port = my_bind_port(); + int attempt; + int last_rc = RNET_EXTERNAL_IPV4_ERR_ARGUMENT; + + if (!out || out_len == 0) + return -1; + out[0] = '\0'; + snprintf(any_bind, sizeof(any_bind), "0.0.0.0:%d", port); + + for (attempt = 0; attempt < 4; ++attempt) { + const char *bind_hp; + endpoint[0] = '\0'; + rnet_external_ipv4_config_init(&stun); + if (attempt < 2 && g_lc.turn.valid && g_lc.turn.stun_host[0]) { + stun.stun_host = g_lc.turn.stun_host; + stun.stun_port = (unsigned short)g_lc.turn.stun_port; + } + /* attempt 0: coturn + my_bind, 1: coturn + any, 2: default + my_bind, + * 3: default + any */ + bind_hp = (attempt & 1) ? any_bind + : (g_lc.my_bind[0] ? g_lc.my_bind : any_bind); + last_rc = rnet_external_udp_endpoint_discover(&stun, bind_hp, endpoint, + sizeof(endpoint)); + if (last_rc != RNET_EXTERNAL_IPV4_OK || !endpoint[0]) + continue; + if (!endpoint_is_public_ipv4(endpoint)) { + fprintf(stderr, + "psx_lobby: STUN mapped private %s (bind=%s stun=%s) — " + "retrying\n", + endpoint, bind_hp, + stun.stun_host ? stun.stun_host : "(default)"); + continue; + } + strncpy(out, endpoint, out_len - 1); + out[out_len - 1] = '\0'; + return 0; + } + return last_rc != 0 ? last_rc : -1; +} + +static void lobby_lan_beacon_close_all(void) +{ + rnet_lan_beacon_close(&g_lan_beacon_pub); + rnet_lan_beacon_close(&g_lan_beacon_listen); +} + +static void lobby_lan_beacon_publish_update(void) +{ + char lan[PSX_LOBBY_MAX_LAN_EPS][PSX_LOBBY_ENDPOINT_LEN]; + int lan_n; + if (!g_lc.is_host || !g_lc.in_lobby || g_lc.launch_pending) + return; + if (!g_lc.join.lobby_id[0]) + return; + lan_n = collect_host_lan_endpoints(lan, PSX_LOBBY_MAX_LAN_EPS); + if (lan_n <= 0) { + rnet_lan_beacon_close(&g_lan_beacon_pub); + return; + } + if (!g_lan_beacon_pub && + rnet_lan_beacon_publish_open(&g_lan_beacon_pub, 0) != 0) + return; + if (rnet_lan_beacon_publish_set(g_lan_beacon_pub, g_lc.join.lobby_id, lan[0], + g_lc.filter_game_name) != 0) { + rnet_lan_beacon_close(&g_lan_beacon_pub); + return; + } + fprintf(stderr, "psx_lobby: LAN beacon publish %s → %s\n", + g_lc.join.lobby_id, lan[0]); +} + +static void lobby_lan_beacon_tick(void) +{ + if (g_lc.is_host && g_lc.in_lobby && !g_lc.launch_pending) { + if (!g_lan_beacon_pub) + lobby_lan_beacon_publish_update(); + if (g_lan_beacon_pub) + (void)rnet_lan_beacon_publish_tick(g_lan_beacon_pub); + } else if (g_lan_beacon_pub) { + rnet_lan_beacon_close(&g_lan_beacon_pub); + } + + /* Guests (and hosts browsing after leave) listen for same-LAN announces. */ + if (!g_lc.is_host || !g_lc.in_lobby) { + int updated = 0; + if (!g_lan_beacon_listen) + (void)rnet_lan_beacon_listen_open(&g_lan_beacon_listen, 0); + if (g_lan_beacon_listen) + updated = rnet_lan_beacon_listen_pump(g_lan_beacon_listen); + /* New beacon → re-probe list rows still missing latency. */ + if (updated > 0 && g_lc.list_count > 0 && !g_list_rtt_active) { + int i; + int need = 0; + for (i = 0; i < g_lc.list_count; ++i) { + if (g_lc.list[i].latency_ms < 0) { + need = 1; + break; + } + } + if (need) + lobby_list_rtt_start(0); + } + } +} + +static void lobby_host_advertise_tick(void) +{ + char endpoint[RNET_ENDPOINT_TEXT_MAX]; + char msg[384]; + int rc; + + if (g_host_adv_state != HOST_ADV_WAIT_TURN) + return; + if (!g_lc.is_host || !g_lc.in_lobby || g_lc.launch_pending) { + lobby_host_advertise_reset(); + return; + } + if (using_server_input_relay(&g_lc.join)) { + g_host_adv_state = HOST_ADV_DONE; + return; + } + /* Prefer Coturn STUN from turn_credentials; don't block forever. */ + if (!g_lc.turn.valid && lobby_mono_ms() < g_host_adv_deadline_ms) + return; + if (!g_lc.my_bind[0]) { + g_host_adv_state = HOST_ADV_DONE; + return; + } + + /* Free the game UDP port for an exclusive STUN bind (skip on loopback hub). */ + if (!host_endpoint_is_loopback(g_lc.join.host_endpoint)) { + lobby_rtt_close(); + endpoint[0] = '\0'; + rc = lobby_host_stun_public(endpoint, sizeof(endpoint)); + if (rc == 0 && endpoint[0]) { + strncpy(g_lc.join.host_endpoint, endpoint, + sizeof(g_lc.join.host_endpoint) - 1); + g_lc.join.host_endpoint[sizeof(g_lc.join.host_endpoint) - 1] = '\0'; + } else { + fprintf(stderr, + "psx_lobby: STUN advertise failed (%d) — keeping %s%s\n", rc, + g_lc.join.host_endpoint[0] ? g_lc.join.host_endpoint + : "(none)", + endpoint_is_public_ipv4(g_lc.join.host_endpoint) + ? "" + : " (use LAN beacon for local list RTT)"); + } + /* Answer list/waiting-room PINGs again as soon as STUN frees the port. */ + lobby_rtt_ensure(); + } + + /* Private IPs stay on the LAN beacon only — never on the hub list. */ + lobby_lan_beacon_publish_update(); + + g_host_adv_state = HOST_ADV_DONE; + if (!g_lc.join.host_endpoint[0]) + return; + snprintf(msg, sizeof(msg), + "{\"op\":\"set_host_endpoint\",\"host_endpoint\":\"%s\"}", + g_lc.join.host_endpoint); + queue_send(msg); + flush_pending(); + fprintf(stderr, "psx_lobby: advertised host_endpoint=%s (LAN via local beacon)\n", + g_lc.join.host_endpoint); +} + +static void lobby_rtt_close(void) +{ + rnet_rtt_probe_close(&g_rtt_probe); +} + +static void lobby_list_rtt_close(void) +{ + rnet_rtt_probe_close(&g_list_rtt_probe); + g_list_rtt_active = 0; + g_list_rtt_pend_count = 0; + memset(g_list_rtt_pend_active, 0, sizeof(g_list_rtt_pend_active)); + memset(g_list_rtt_sent_ts, 0, sizeof(g_list_rtt_sent_ts)); + memset(g_list_rtt_lobby_idx, 0, sizeof(g_list_rtt_lobby_idx)); +} + +static int collect_local_rfc1918(unsigned out[][4], int max_out) +{ + RNetIpv4Address addrs[16]; + int n; + int i; + int count = 0; + + if (!out || max_out <= 0) + return 0; + n = rnet_ipv4_enumerate(addrs, sizeof(addrs) / sizeof(addrs[0])); + if (n < 0) + n = 0; + if (n > (int)(sizeof(addrs) / sizeof(addrs[0]))) + n = (int)(sizeof(addrs) / sizeof(addrs[0])); + for (i = 0; i < n && count < max_out; ++i) { + unsigned o[4]; + if (!parse_ipv4_dotted(addrs[i].address, o) || !ipv4_is_rfc1918(o)) + continue; + memcpy(out[count], o, sizeof(o)); + ++count; + } + return count; +} + +static int cand_already(char cands[][PSX_LOBBY_ENDPOINT_LEN], int n, const char *ep) +{ + int i; + for (i = 0; i < n; ++i) { + if (strcmp(cands[i], ep) == 0) + return 1; + } + return 0; +} + +/* Prefer local beacon LAN, then legacy server lan_endpoints, then public. */ +static int lobby_row_build_candidates(const PsxLobbyRow *row, + char cands[][PSX_LOBBY_ENDPOINT_LEN], + int max_cands) +{ + unsigned local[8][4]; + int local_n; + int n = 0; + int i; + int pass; + char beacon_ep[PSX_LOBBY_ENDPOINT_LEN]; + + if (!row || !cands || max_cands <= 0) + return 0; + + beacon_ep[0] = '\0'; + if (g_lan_beacon_listen && row->lobby_id[0] && + rnet_lan_beacon_lookup(g_lan_beacon_listen, row->lobby_id, beacon_ep, + sizeof(beacon_ep)) && + beacon_ep[0] && !endpoint_port_is_zero(beacon_ep)) { + strncpy(cands[n], beacon_ep, PSX_LOBBY_ENDPOINT_LEN - 1); + cands[n][PSX_LOBBY_ENDPOINT_LEN - 1] = '\0'; + ++n; + } + + local_n = collect_local_rfc1918(local, 8); + + for (pass = 0; pass < 2; ++pass) { + for (i = 0; i < row->lan_count && n < max_cands; ++i) { + char host[64]; + int port = 0; + unsigned o[4]; + int same = 0; + int j; + if (!row->lan_endpoints[i][0] || + endpoint_port_is_zero(row->lan_endpoints[i])) + continue; + if (!endpoint_host_port(row->lan_endpoints[i], host, sizeof(host), &port) || + !parse_ipv4_dotted(host, o) || !ipv4_is_rfc1918(o)) + continue; + for (j = 0; j < local_n; ++j) { + if (ipv4_same_lan24(local[j], o)) { + same = 1; + break; + } + } + if (pass == 0 && !same) + continue; + if (pass == 1 && same) + continue; /* already added */ + if (cand_already(cands, n, row->lan_endpoints[i])) + continue; + strncpy(cands[n], row->lan_endpoints[i], PSX_LOBBY_ENDPOINT_LEN - 1); + cands[n][PSX_LOBBY_ENDPOINT_LEN - 1] = '\0'; + ++n; + } + } + if (n < max_cands && row->host_endpoint[0] && + !endpoint_port_is_zero(row->host_endpoint) && + !cand_already(cands, n, row->host_endpoint)) { + strncpy(cands[n], row->host_endpoint, PSX_LOBBY_ENDPOINT_LEN - 1); + cands[n][PSX_LOBBY_ENDPOINT_LEN - 1] = '\0'; + ++n; + } + return n; +} + +/* force_all: Refresh — re-probe every row. Otherwise only rows with unknown RTT. */ +static void lobby_list_rtt_start(int force_all) +{ + int i; + + lobby_list_rtt_close(); + if (g_lc.list_count <= 0) + return; + /* Drain local beacons before building candidates (may beat WS list). */ + if (!g_lan_beacon_listen) + (void)rnet_lan_beacon_listen_open(&g_lan_beacon_listen, 0); + if (g_lan_beacon_listen) + (void)rnet_lan_beacon_listen_pump(g_lan_beacon_listen); + if (rnet_rtt_probe_open(&g_list_rtt_probe, NULL) != 0) + return; + + for (i = 0; i < g_lc.list_count; ++i) { + char cands[PSX_LOBBY_MAX_LAN_EPS + 1][PSX_LOBBY_ENDPOINT_LEN]; + int cn; + int c; + if (force_all) + g_lc.list[i].latency_ms = -1; + if (g_lc.list[i].latency_ms >= 0) + continue; + cn = lobby_row_build_candidates(&g_lc.list[i], cands, + PSX_LOBBY_MAX_LAN_EPS + 1); + for (c = 0; c < cn && g_list_rtt_pend_count < PSX_LOBBY_MAX_PROBE_PEND; ++c) { + unsigned long long sent = 0; + int slot = g_list_rtt_pend_count; + if (rnet_rtt_probe_set_peer(g_list_rtt_probe, cands[c]) != 0) + continue; + if (rnet_rtt_probe_ping_ts(g_list_rtt_probe, &sent) != 0) + continue; + g_list_rtt_sent_ts[slot] = sent; + g_list_rtt_lobby_idx[slot] = i; + g_list_rtt_pend_active[slot] = 1; + ++g_list_rtt_pend_count; + } + } + + if (g_list_rtt_pend_count <= 0) { + lobby_list_rtt_close(); + return; + } + g_list_rtt_active = 1; + /* STUN advertise briefly drops the host answer sock; give guests time. */ + g_list_rtt_deadline_ms = lobby_mono_ms() + 1500ull; +} + +static void lobby_list_rtt_tick(void) +{ + int remaining; + + if (!g_list_rtt_active || !g_list_rtt_probe) + return; + + for (;;) { + int ms = 0; + unsigned long long echo = 0; + int p; + int got = rnet_rtt_probe_pump_ex(g_list_rtt_probe, &ms, &echo); + if (got != 1) + break; + for (p = 0; p < g_list_rtt_pend_count; ++p) { + int li; + int q; + if (!g_list_rtt_pend_active[p] || g_list_rtt_sent_ts[p] != echo) + continue; + li = g_list_rtt_lobby_idx[p]; + if (li >= 0 && li < g_lc.list_count && g_lc.list[li].latency_ms < 0) + g_lc.list[li].latency_ms = ms; + /* Drop remaining candidates for this lobby. */ + for (q = 0; q < g_list_rtt_pend_count; ++q) { + if (g_list_rtt_lobby_idx[q] == li) + g_list_rtt_pend_active[q] = 0; + } + break; + } + } + + remaining = 0; + { + int p; + for (p = 0; p < g_list_rtt_pend_count; ++p) { + if (g_list_rtt_pend_active[p]) + ++remaining; + } + } + if (remaining <= 0 || lobby_mono_ms() >= g_list_rtt_deadline_ms) + lobby_list_rtt_close(); +} static void member_rtt_clear(void) { @@ -389,6 +935,65 @@ static const char *json_get_str(const char *json, const char *key, char *out, si return out; } +/* Parse JSON string array values for key into out[0..max_out). Returns count. */ +static int json_parse_str_array(const char *json, const char *key, + char out[][PSX_LOBBY_ENDPOINT_LEN], int max_out) +{ + char pat[80]; + const char *p; + int n = 0; + + if (!json || !key || !out || max_out <= 0) + return 0; + snprintf(pat, sizeof(pat), "\"%s\"", key); + p = strstr(json, pat); + if (!p) + return 0; + p = strchr(p + strlen(pat), '['); + if (!p) + return 0; + ++p; + while (*p && n < max_out) { + size_t o = 0; + while (*p && (isspace((unsigned char)*p) || *p == ',')) + ++p; + if (*p == ']') + break; + if (*p != '"') + break; + ++p; + while (*p && *p != '"' && o + 1 < PSX_LOBBY_ENDPOINT_LEN) + out[n][o++] = *p++; + out[n][o] = '\0'; + if (*p == '"') + ++p; + if (out[n][0]) + ++n; + } + return n; +} + +static void lobby_row_lan_fingerprint(const PsxLobbyRow *row, char *out, size_t cap) +{ + size_t o = 0; + int i; + if (!out || cap == 0) + return; + out[0] = '\0'; + if (!row) + return; + for (i = 0; i < row->lan_count; ++i) { + int wrote; + if (!row->lan_endpoints[i][0]) + continue; + wrote = snprintf(out + o, cap > o ? cap - o : 0, "%s%s", o ? "|" : "", + row->lan_endpoints[i]); + if (wrote < 0 || (size_t)wrote >= (cap > o ? cap - o : 0)) + break; + o += (size_t)wrote; + } +} + static size_t json_escape(const char *in, char *out, size_t cap) { size_t o = 0; @@ -424,6 +1029,8 @@ static size_t json_escape(const char *in, char *out, size_t cap) static void enqueue_signal(int type, int flag, const char *text) { int i; + if (!g_lc.ice_signal_accept) + return; if (g_lc.sig_count >= (int)(sizeof(g_lc.sig_q) / sizeof(g_lc.sig_q[0]))) { g_lc.sig_head = (g_lc.sig_head + 1) % (int)(sizeof(g_lc.sig_q) / sizeof(g_lc.sig_q[0])); g_lc.sig_count--; @@ -438,6 +1045,18 @@ static void enqueue_signal(int type, int flag, const char *text) g_lc.sig_count++; } +void psx_lobby_clear_signals(void) +{ + g_lc.sig_head = 0; + g_lc.sig_tail = 0; + g_lc.sig_count = 0; +} + +void psx_lobby_set_ice_signal_accept(int accept) +{ + g_lc.ice_signal_accept = accept ? 1 : 0; +} + static int json_get_int(const char *json, const char *key, int def) { char pat[80]; @@ -813,8 +1432,9 @@ static void handle_server_json(const char *json) char err[64]; json_get_str(json, "error", err, sizeof(err)); fprintf(stderr, - "psx_lobby: turn_credentials failed (%s) — ICE will be " - "STUN-only unless PSX_NET_TURN_* is set\n", + "psx_lobby: turn_credentials failed (%s) — online ICE " + "requires Coturn (or PSX_NET_TURN_* / " + "PSX_NET_ALLOW_STUN_ONLY=1)\n", err[0] ? err : "unknown"); return; } @@ -849,6 +1469,25 @@ static void handle_server_json(const char *json) if (strcmp(op, "lobby_list") == 0) { const char *p = strstr(json, "\"lobbies\""); int n = 0; + /* Keep prior RTTs across server list pushes; Refresh re-probes. + * Invalidate when host_endpoint or lan_endpoints change. */ + char prev_ids[PSX_LOBBY_MAX_LIST][PSX_LOBBY_ID_LEN]; + char prev_eps[PSX_LOBBY_MAX_LIST][PSX_LOBBY_ENDPOINT_LEN]; + char prev_lan[PSX_LOBBY_MAX_LIST][256]; + int prev_ms[PSX_LOBBY_MAX_LIST]; + int prev_n = g_lc.list_count; + int i; + int want_probe = g_list_rtt_on_next_list; + g_list_rtt_on_next_list = 0; + for (i = 0; i < prev_n && i < PSX_LOBBY_MAX_LIST; ++i) { + strncpy(prev_ids[i], g_lc.list[i].lobby_id, PSX_LOBBY_ID_LEN - 1); + prev_ids[i][PSX_LOBBY_ID_LEN - 1] = '\0'; + strncpy(prev_eps[i], g_lc.list[i].host_endpoint, + PSX_LOBBY_ENDPOINT_LEN - 1); + prev_eps[i][PSX_LOBBY_ENDPOINT_LEN - 1] = '\0'; + lobby_row_lan_fingerprint(&g_lc.list[i], prev_lan[i], sizeof(prev_lan[i])); + prev_ms[i] = g_lc.list[i].latency_ms; + } g_lc.list_count = 0; if (!p) { return; @@ -863,6 +1502,10 @@ static void handle_server_json(const char *json) while (*p && *p != '{') { if (*p == ']') { g_lc.list_count = n; + if (want_probe) + lobby_list_rtt_start(1); + else if (n > 0 && !g_list_rtt_active) + lobby_list_rtt_start(0); return; } ++p; @@ -883,13 +1526,16 @@ static void handle_server_json(const char *json) ++end; } while (*end && depth > 0); { - char chunk[1024]; + char chunk[1536]; + char lan_fp[256]; size_t len = (size_t)(end - obj); if (len >= sizeof(chunk)) { len = sizeof(chunk) - 1; } memcpy(chunk, obj, len); chunk[len] = '\0'; + memset(&g_lc.list[n], 0, sizeof(g_lc.list[n])); + g_lc.list[n].latency_ms = -1; json_get_str(chunk, "lobby_id", g_lc.list[n].lobby_id, sizeof(g_lc.list[n].lobby_id)); json_get_str(chunk, "name", g_lc.list[n].name, sizeof(g_lc.list[n].name)); json_get_str(chunk, "game_name", g_lc.list[n].game_name, sizeof(g_lc.list[n].game_name)); @@ -923,12 +1569,45 @@ static void handle_server_json(const char *json) g_lc.list[n].player_count = json_get_int(chunk, "player_count", 0); g_lc.list[n].max_slots = json_get_int(chunk, "max_slots", 2); g_lc.list[n].has_password = json_get_bool(chunk, "has_password", 0); + json_get_str(chunk, "host_endpoint", g_lc.list[n].host_endpoint, + sizeof(g_lc.list[n].host_endpoint)); + g_lc.list[n].lan_count = json_parse_str_array( + chunk, "lan_endpoints", g_lc.list[n].lan_endpoints, + PSX_LOBBY_MAX_LAN_EPS); + lobby_row_lan_fingerprint(&g_lc.list[n], lan_fp, sizeof(lan_fp)); + if (!want_probe) { + for (i = 0; i < prev_n; ++i) { + if (prev_ids[i][0] && + strcmp(prev_ids[i], g_lc.list[n].lobby_id) == 0 && + strcmp(prev_eps[i], g_lc.list[n].host_endpoint) == 0 && + strcmp(prev_lan[i], lan_fp) == 0) { + g_lc.list[n].latency_ms = prev_ms[i]; + break; + } + } + } ++n; p = end; } } } g_lc.list_count = n; + if (want_probe) + lobby_list_rtt_start(1); + else { + int need = 0; + for (i = 0; i < n; ++i) { + if (g_lc.list[i].latency_ms < 0 && + (g_lc.list[i].host_endpoint[0] || g_lc.list[i].lan_count > 0)) { + need = 1; + break; + } + } + /* Restart even if a prior burst is mid-flight — advertise may have + * just published a public host_endpoint / LAN candidate. */ + if (need) + lobby_list_rtt_start(0); + } return; } if (strcmp(op, "created") == 0) { @@ -963,6 +1642,13 @@ static void handle_server_json(const char *json) g_lc.member_count = 1; g_lc.local_ready = 0; } + /* After create: LAN beacon immediately; STUN for public host_endpoint. */ + g_host_adv_state = HOST_ADV_WAIT_TURN; + g_host_adv_deadline_ms = lobby_mono_ms() + 500ull; + lobby_lan_beacon_publish_update(); + return; + } + if (strcmp(op, "host_endpoint_ok") == 0) { return; } if (strcmp(op, "joined") == 0) { @@ -1058,6 +1744,11 @@ static void handle_server_json(const char *json) } g_lc.join.last_error[0] = '\0'; g_lc.launch_pending = 1; + /* Accept ICE for this match; queue was idle (accept=0) during lobby. */ + g_lc.ice_signal_accept = 1; + lobby_rtt_close(); /* free game UDP port for the session bind */ + rnet_lan_beacon_close(&g_lan_beacon_pub); + lobby_host_advertise_reset(); return; } if (strcmp(op, "signal") == 0) { @@ -1069,30 +1760,10 @@ static void handle_server_json(const char *json) from[0] = '\0'; json_get_str(json, "text", text_buf, sizeof(text_buf)); json_get_str(json, "from_player_id", from, sizeof(from)); - if (type == PSX_LOBBY_SIG_RTT_PING) { - if (g_lc.is_host) - (void)psx_lobby_send_signal(PSX_LOBBY_SIG_RTT_PONG, 0, text_buf); + /* Legacy WS RTT_PING/PONG ignored — waiting-room latency uses UDP + * rnet_rtt_probe (peer path). REPORT still accepted from peers. */ + if (type == PSX_LOBBY_SIG_RTT_PING || type == PSX_LOBBY_SIG_RTT_PONG) return; - } - if (type == PSX_LOBBY_SIG_RTT_PONG) { - unsigned long long sent = 0; - uint64_t now = lobby_mono_ms(); - int slot; - if (sscanf(text_buf, "%llu", &sent) == 1 && (uint64_t)sent <= now) { - int ms = (int)(now - (uint64_t)sent); - if (ms < 0) ms = 0; - if (ms > 60000) ms = 60000; - slot = local_member_slot(); - if (slot >= 0 && slot < PSX_LOBBY_MAX_MEMBERS) - g_lc.member_rtt_ms[slot] = ms; - { - char report[32]; - snprintf(report, sizeof(report), "%d", ms); - (void)psx_lobby_send_signal(PSX_LOBBY_SIG_RTT_REPORT, 0, report); - } - } - return; - } if (type == PSX_LOBBY_SIG_RTT_REPORT) { int slot = member_slot_for_player(from); int ms = (int)strtol(text_buf, NULL, 10); @@ -1126,6 +1797,8 @@ static void handle_server_json(const char *json) } if (strcmp(op, "lobby_closed") == 0 || strcmp(op, "left") == 0 || strcmp(op, "kicked") == 0) { + lobby_rtt_close(); + lobby_host_advertise_reset(); g_lc.in_lobby = 0; g_lc.is_host = 0; g_lc.host_player_id[0] = '\0'; @@ -1244,6 +1917,11 @@ int psx_lobby_connect(const char *ws_url) void psx_lobby_disconnect(void) { + lobby_rtt_close(); + lobby_list_rtt_close(); + lobby_lan_beacon_close_all(); + lobby_host_advertise_reset(); + g_list_rtt_on_next_list = 0; if (g_lc.fd >= 0) { close(g_lc.fd); } @@ -1281,6 +1959,91 @@ const char *psx_lobby_player_id(void) return g_lc.player_id; } +static int first_guest_member_slot(void) +{ + int i; + for (i = 0; i < g_lc.member_count; ++i) { + if (!psx_lobby_member_is_host(&g_lc.members[i])) + return g_lc.members[i].slot; + } + return -1; +} + +/* UDP peer RTT for the waiting-room latency column (not WS signal RTT). */ +static void lobby_rtt_ensure(void) +{ + const char *bind; + const char *peer; + + if (!g_lc.in_lobby || g_lc.launch_pending || using_server_input_relay(&g_lc.join)) { + lobby_rtt_close(); + return; + } + + if (!g_rtt_probe) { + bind = g_lc.my_bind[0] ? g_lc.my_bind : NULL; + /* 3+ guests use ephemeral session binds; probe the same way. */ + if (!g_lc.is_host && g_lc.join.max_slots >= 3) + bind = NULL; + if (rnet_rtt_probe_open(&g_rtt_probe, bind) != 0) + return; + } + + peer = NULL; + if (g_lc.join.peer_hostport[0] && !endpoint_port_is_zero(g_lc.join.peer_hostport)) + peer = g_lc.join.peer_hostport; + else if (!g_lc.is_host && g_lc.join.host_endpoint[0] && + !endpoint_port_is_zero(g_lc.join.host_endpoint)) + peer = g_lc.join.host_endpoint; + if (peer) + (void)rnet_rtt_probe_set_peer(g_rtt_probe, peer); +} + +static void lobby_rtt_tick(void) +{ + int ms = 0; + int got; + + if (!g_lc.in_lobby || g_lc.launch_pending) { + lobby_rtt_close(); + return; + } + if (using_server_input_relay(&g_lc.join)) { + lobby_rtt_close(); + return; + } + + lobby_rtt_ensure(); + if (!g_rtt_probe) + return; + + got = rnet_rtt_probe_pump(g_rtt_probe, &ms); + if (got == 1) { + if (g_lc.is_host) { + int slot = first_guest_member_slot(); + if (slot >= 0) + g_lc.member_rtt_ms[slot] = ms; + } else { + int slot = local_member_slot(); + if (slot >= 0) + g_lc.member_rtt_ms[slot] = ms; + { + char report[32]; + snprintf(report, sizeof(report), "%d", ms); + (void)psx_lobby_send_signal(PSX_LOBBY_SIG_RTT_REPORT, 0, report); + } + } + } + + { + uint64_t now = lobby_mono_ms(); + if (now >= g_lc.rtt_next_ping_ms && rnet_rtt_probe_peer_known(g_rtt_probe)) { + (void)rnet_rtt_probe_ping(g_rtt_probe); + g_lc.rtt_next_ping_ms = now + 2500ull; + } + } +} + void psx_lobby_pump(void) { char buf[4096]; @@ -1367,16 +2130,14 @@ void psx_lobby_pump(void) break; } } - /* Guests: probe host RTT about once per second while seated. */ - if (g_lc.in_lobby && !g_lc.is_host && !g_lc.launch_pending) { - uint64_t now = lobby_mono_ms(); - if (now >= g_lc.rtt_next_ping_ms) { - char ts[32]; - snprintf(ts, sizeof(ts), "%llu", (unsigned long long)now); - (void)psx_lobby_send_signal(PSX_LOBBY_SIG_RTT_PING, 0, ts); - g_lc.rtt_next_ping_ms = now + 1000ull; - } - } + /* Host STUN advertise (may briefly close the waiting-room RTT sock). */ + lobby_host_advertise_tick(); + /* Local UDP broadcast: host announce / guest cache for list RTT. */ + lobby_lan_beacon_tick(); + /* Peer-path UDP latency for the lobby seat table. */ + lobby_rtt_tick(); + /* One-shot list latency after Refresh / lobby_list. */ + lobby_list_rtt_tick(); } void psx_lobby_set_game_identity(const char *game_name, const char *game_version) @@ -1404,6 +2165,7 @@ const char *psx_lobby_game_version(void) void psx_lobby_request_list(void) { + g_list_rtt_on_next_list = 1; queue_list_request(); flush_pending(); } @@ -1488,6 +2250,8 @@ int psx_lobby_leave(void) { queue_send("{\"op\":\"leave\"}"); flush_pending(); + lobby_rtt_close(); + lobby_host_advertise_reset(); g_lc.in_lobby = 0; g_lc.is_host = 0; g_lc.host_player_id[0] = '\0'; @@ -1495,6 +2259,8 @@ int psx_lobby_leave(void) g_lc.local_ready = 0; g_lc.all_ready = 0; g_lc.launch_pending = 0; + g_lc.ice_signal_accept = 0; + psx_lobby_clear_signals(); match_caps_clear(&g_lc.match_caps); member_rtt_clear(); return 0; diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index 8476c48af..51ab51e4b 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -204,10 +204,21 @@ int psx_netplay_input_delay(void) { return 2; } int psx_netplay_catchup_budget(void) { return 0; } void psx_netplay_catchup_consume_frame(void) {} void psx_netplay_wait_recv(int timeout_ms) { (void)timeout_ms; } +void psx_netplay_admit_wait_info(char *stall_out, size_t stall_cap, + uint32_t *sim_tick_out, int *lead_out) +{ + if (stall_out && stall_cap) { + stall_out[0] = '\0'; + if (stall_cap > 1) + strncpy(stall_out, "off", stall_cap - 1); + } + if (sim_tick_out) *sim_tick_out = 0; + if (lead_out) *lead_out = 0; +} #else /* PSX_HAS_RECOMP_NET */ -#define NP_SANDBOX_DIR "saves/netplay" +#define NP_SANDBOX_FALLBACK "saves/netplay" #define NP_MC_BLOB_BYTES (4u + (size_t)MEMCARD_SIZE * 2u) /* LOAD probe size==0 + this crc = post-load ready rendezvous (not SAVE coord). */ #define NP_LOAD_READY_CRC 0x4C4F4144u /* 'LOAD' */ @@ -310,6 +321,7 @@ static uint32_t np_mono_ms(void) static void np_enter_load_ready(int slot); static void np_commit_load_sync(void); static void np_begin_load_apply(int slot); +static void np_starv_reset(void); static int np_file_crc(const uint8_t *data, size_t size, uint32_t *crc_out) { @@ -394,6 +406,7 @@ static void np_enter_guest_sandbox(void) const char *p0 = NULL; const char *p1 = NULL; uint32_t bios = 0, entry = 0; + char sandbox[560]; savestate_get_integrity(&bios, &entry); g_np.bios_checksum = bios; @@ -405,9 +418,24 @@ static void np_enter_guest_sandbox(void) if (p0) strncpy(g_np.personal_mc0, p0, sizeof(g_np.personal_mc0) - 1); if (p1) strncpy(g_np.personal_mc1, p1, sizeof(g_np.personal_mc1) - 1); - savestate_configure(NP_SANDBOX_DIR, bios, entry); - (void)memcard_rebind_dir(NP_SANDBOX_DIR); + /* Prefer /netplay (absolute, next to the binary) so CWD does + * not matter. Relative "saves/netplay" only as a last-resort fallback. */ + if (g_np.personal_save_dir[0]) { + size_t n = strlen(g_np.personal_save_dir); + while (n > 0 && (g_np.personal_save_dir[n - 1] == '/' || + g_np.personal_save_dir[n - 1] == '\\')) { + g_np.personal_save_dir[--n] = '\0'; + } + snprintf(sandbox, sizeof(sandbox), "%s/netplay", g_np.personal_save_dir); + } else { + snprintf(sandbox, sizeof(sandbox), "%s", NP_SANDBOX_FALLBACK); + } + + savestate_configure(sandbox, bios, entry); + (void)memcard_rebind_dir(sandbox); g_np.guest_sandbox = 1; + printf("psxrecomp: netplay guest sandbox -> %s\n", sandbox); + fflush(stdout); } static void np_leave_guest_sandbox(void) @@ -486,26 +514,29 @@ static void np_apply_ready_state(void) return; } - /* LOAD transfer (hash miss): guest writes sandbox; both stage apply here so - * the host cannot restore (and suppress INPUT) before the guest has bytes. */ + /* LOAD transfer (hash miss): guest stages the wire blob in memory (no disk + * dependency — relative sandbox/CWD issues used to fail write_slot here). + * Both peers request apply so host cannot restore before guest has bytes. */ if (g_np.local_slot != 0) { - if (!savestate_write_slot((int)slot, data, size)) { + if (!savestate_request_load_blob_protocol(data, size)) { + printf("psxrecomp: netplay guest load slot=%u — blob stage failed " + "(%zu bytes, sandbox='%s')\n", + (unsigned)slot, size, savestate_dir()); + fflush(stdout); rnet_session_state_finish(g_np.session, 0); g_np.xfer = NP_XFER_NONE; return; } - { - uint32_t got_sz = 0, got_crc = 0; - if (!np_slot_crc((int)slot, &got_sz, &got_crc) || - got_sz != (uint32_t)size || - got_crc != rnet_checksum((const rnet_u8 *)data, size)) { - rnet_session_state_finish(g_np.session, 0); - g_np.xfer = NP_XFER_NONE; - return; - } + /* Best-effort mirror to sandbox for hash-probe hits on rematch. */ + if (!savestate_write_slot((int)slot, data, size)) { + printf("psxrecomp: netplay guest load slot=%u — sandbox mirror " + "failed (in-memory apply continues)\n", + (unsigned)slot); + fflush(stdout); } + } else { + (void)savestate_request_load_protocol((int)slot); } - (void)savestate_request_load_protocol((int)slot); rnet_session_state_finish(g_np.session, 0); np_begin_load_apply((int)slot); printf("psxrecomp: netplay load slot=%u — applying after transfer…\n", (unsigned)slot); @@ -754,22 +785,53 @@ static void np_host_drive_xfer(void) static void np_prime_after_hard_resync(void) { uint8_t bytes[PSX_NETPLAY_PAD_BYTES]; - /* Released digital + centered sticks — delay tip only; real pads resume after. */ - bytes[0] = 0xFFu; - bytes[1] = 0xFFu; - bytes[2] = bytes[3] = bytes[4] = bytes[5] = 0x80u; - bytes[6] = 1u; + PsxNetPad pad; + + /* Prime delay prefix with the current local hold (not forced neutral) so the + * first D play frames continue what the player is already pressing. Each + * peer only primes its own slot — lockstep stays valid. Tip latency for + * *changes* remains D; we just avoid a post-load dead zone of released pads. */ + memset(&pad, 0, sizeof(pad)); + pad.buttons = 0xFFFFu; + pad.lx = pad.ly = pad.rx = pad.ry = 0x80u; + pad.analog = 1; + pad.connected = 1; + if (g_np.staged_valid) + pad = g_np.staged; + pad.connected = 1; + psx_netplay_normalize_pad(&pad); + + bytes[0] = (uint8_t)(pad.buttons & 0xFFu); + bytes[1] = (uint8_t)((pad.buttons >> 8) & 0xFFu); + bytes[2] = pad.lx; + bytes[3] = pad.ly; + bytes[4] = pad.rx; + bytes[5] = pad.ry; + bytes[6] = pad.analog ? 1u : 0u; bytes[7] = 1u; rnet_session_prime_delay_inputs(g_np.session, bytes, (rnet_u16)PSX_NETPLAY_PAD_BYTES); + + /* Keep staged matching the prime so the first tip sample is not a sudden + * release while [0..D) still holds the live pad. */ + g_np.staged = pad; + g_np.staged_valid = 1; } /* Stage restore. Keep INPUT flowing so try_admit can still run guest cycles - * for savestate_poll — suppress only at mutual ready (np_commit_load_sync). */ + * for savestate_poll — suppress only at mutual ready (np_commit_load_sync). + * Ready probe must also leave INPUT unstalled (recomp-net size==0 LOAD). */ static void np_begin_load_apply(int slot) { + /* Transfer admit failures (state_xfer) often latch starvation; lead can sit + * at D-1 after ICE xfer and would block the only frame savestate_poll needs. */ + np_starv_reset(); g_np.xfer = NP_XFER_LOAD_APPLYING; g_np.load_applied_local = 0; g_np.load_sync_done = 0; + g_np.load_ready_replied = 0; + g_np.needs_advance = 0; + g_np.latched_for_tick = 0; + g_np.staged_valid = 0; g_np.xfer_slot = slot; } @@ -785,7 +847,7 @@ static void np_commit_load_sync(void) g_np.load_sync_done = 1; g_np.needs_advance = 0; g_np.latched_for_tick = 0; - g_np.staged_valid = 0; + /* staged_valid left set by prime — tip must match delay-prefix hold. */ } static void np_enter_load_ready(int slot) @@ -1123,6 +1185,21 @@ static int resolve_use_ice(const PsxNetplayConfig *cfg) in_motk_room = psx_lobby_connected() && psx_lobby_in_lobby(); #endif + /* Server UDP pad relay: dial relay_endpoint with LAN transport (not ICE). + * MotK previously always preferred ICE and ignored the relay rewrite. */ + if (cfg->force_input_relay) { + if (!cfg->peer_hostport || !cfg->peer_hostport[0]) { + fprintf(stderr, + "psx_netplay: force_input_relay set but peer/relay " + "endpoint empty\n"); + return -1; + } + fprintf(stderr, + "psx_netplay: server input relay — LAN transport to %s\n", + cfg->peer_hostport); + return 0; + } + #if defined(RNET_ENABLE_ICE) && defined(PSX_HAS_LOBBY_CLIENT) if (cfg->transport == 1) { if (!in_motk_room) { @@ -1232,15 +1309,19 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) } #if defined(PSX_HAS_LOBBY_CLIENT) + /* Prefer TURN prefetched at WS welcome; re-request and wait if stale. */ if (psx_lobby_connected()) { int i; - (void)psx_lobby_request_turn_credentials(); - for (i = 0; i < 50; ++i) { - const PsxLobbyTurnCredentials *tc = psx_lobby_turn_credentials(); - if (tc && tc->valid) - break; - psx_lobby_pump(); - np_sleep_ms(10); + const PsxLobbyTurnCredentials *tc = psx_lobby_turn_credentials(); + if (!tc || !tc->valid) { + (void)psx_lobby_request_turn_credentials(); + for (i = 0; i < 200; ++i) { /* up to ~2s */ + tc = psx_lobby_turn_credentials(); + if (tc && tc->valid) + break; + psx_lobby_pump(); + np_sleep_ms(10); + } } } { @@ -1306,20 +1387,31 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) ice.turn_host, (unsigned)ice.turn_port, ice.turn_user, ice.bind_address ? ice.bind_address : "(any)"); } else { + const char *allow_stun = getenv("PSX_NET_ALLOW_STUN_ONLY"); fprintf(stderr, "psx_netplay: ICE STUN-only (no TURN) stun=%s:%u " - "bind=%s — remote NAT may hang; configure Coturn on the " - "lobby or PSX_NET_TURN_*\n", + "bind=%s — online MotK requires Coturn " + "(lobby get_turn_credentials or PSX_NET_TURN_*); set " + "PSX_NET_ALLOW_STUN_ONLY=1 to override\n", ice.stun_host ? ice.stun_host : "(default)", (unsigned)ice.stun_port, ice.bind_address ? ice.bind_address : "(any)"); + /* BattleShip-style: refuse WAN ICE without TURN (CGNAT hangs). */ + if (!allow_stun || !allow_stun[0] || allow_stun[0] == '0') { + rnet_session_destroy(g_np.session); + g_np.session = NULL; + return -4; + } } { + /* Online default is Force TURN (match_caps / UI); env overrides. */ int force_turn = cfg->force_turn ? 1 : 0; const char *ft = getenv("PSX_NET_FORCE_TURN"); if (ft && ft[0] && ft[0] != '0') force_turn = 1; + else if (ft && ft[0] == '0') + force_turn = 0; if (force_turn && !g_np.ice_has_turn) { fprintf(stderr, "psx_netplay: FORCE_TURN requires Coturn credentials " @@ -1581,7 +1673,12 @@ int psx_netplay_in_load_barrier(void) { if (!psx_netplay_active()) return 0; - return (g_np.xfer == NP_XFER_LOAD_APPLYING || g_np.xfer == NP_XFER_LOAD_READY) ? 1 : 0; + /* Probe/SEND too: large ICE/TURN transfers can exceed the normal admit + * stall timeout, and FPS/present must stay frozen until mutual ready. */ + return (g_np.xfer == NP_XFER_LOAD_PROBE || g_np.xfer == NP_XFER_LOAD_SEND || + g_np.xfer == NP_XFER_LOAD_APPLYING || g_np.xfer == NP_XFER_LOAD_READY) + ? 1 + : 0; } @@ -1595,6 +1692,21 @@ static int np_diag_enabled(void) return cached; } +/* Verbose delay-sync starvation latch/clear spam. Off by default — the latch + * can toggle every few frames under jitter and floods stderr. Enable with + * PSX_NET_DELAY_SYNC_DIAG=1 (alias: PSX_NET_STARVATION_DIAG=1). */ +static int np_delay_sync_diag_enabled(void) +{ + static int cached = -1; + if (cached < 0) { + const char *v = getenv("PSX_NET_DELAY_SYNC_DIAG"); + if (!v || !v[0]) + v = getenv("PSX_NET_STARVATION_DIAG"); + cached = (v && v[0] && v[0] != '0') ? 1 : 0; + } + return cached; +} + static unsigned np_diag_interval_ms(void) { static unsigned cached = 0; @@ -1886,6 +1998,14 @@ int psx_netplay_poll_admit(void) if (g_np.xfer == NP_XFER_LOAD_APPLYING && !savestate_pending()) return 0; + /* Staged load must run guest cycles — bypass starvation latch. ICE xfer + * often leaves lead=D-1 and would otherwise block try_admit forever. */ + if (g_np.xfer == NP_XFER_LOAD_APPLYING && savestate_pending()) { + if (g_np.needs_advance) + return 1; + return np_try_admit_gameplay(); + } + /* Both peers: after mutual ready + sync, stay in LOAD_READY until try_admit * succeeds (fresh tip exchange + INPUT_CONFIRM). Dropping the barrier early * on the host let it spin on confirm with FPS/present already "live". */ @@ -1918,6 +2038,18 @@ int psx_netplay_poll_admit(void) exit_need = np_starv_env_int("PSX_NET_STARVATION_EXIT_FRAMES", PSX_STARVATION_EXIT_DEFAULT); + /* Probe/SEND: state_xfer stalls are expected — do not latch starvation. */ + if (g_np.xfer == NP_XFER_LOAD_PROBE || g_np.xfer == NP_XFER_LOAD_SEND || + g_np.xfer == NP_XFER_SAVE_PROBE || g_np.xfer == NP_XFER_SAVE_SEND || + g_np.xfer == NP_XFER_SAVE_COORD || g_np.xfer == NP_XFER_MC_PROBE || + g_np.xfer == NP_XFER_MC_SEND) { + g_starv.enter_run = 0; + g_starv.exit_run = 0; + g_starv.latched = 0; + g_starv.just_cleared = 0; + return np_try_admit_gameplay(); + } + /* Startup grace: do not latch before the delay rings warm up. */ if (sim < (rnet_u32)PSX_STARVATION_GRACE_TICKS) { g_starv.enter_run = 0; @@ -1952,18 +2084,20 @@ int psx_netplay_poll_admit(void) PSX_STARVATION_RECOVERY_BURST_DEFAULT); g_starv.just_cleared = 0; g_starv.recovery_amount = burst; - if (burst > 0) { - fprintf(stderr, - "psxrecomp: delay_sync_starvation cleared sim=%u lead=%d " - "D=%d — recovery burst %d\n", - (unsigned)psx_netplay_sim_tick(), psx_netplay_remote_lead(), - psx_netplay_input_delay(), burst); - } else { - fprintf(stderr, - "psxrecomp: delay_sync_starvation cleared sim=%u lead=%d " - "D=%d — resume 1:1 (rebuild input buffer)\n", - (unsigned)psx_netplay_sim_tick(), psx_netplay_remote_lead(), - psx_netplay_input_delay()); + if (np_delay_sync_diag_enabled()) { + if (burst > 0) { + fprintf(stderr, + "psxrecomp: delay_sync_starvation cleared sim=%u lead=%d " + "D=%d — recovery burst %d\n", + (unsigned)psx_netplay_sim_tick(), psx_netplay_remote_lead(), + psx_netplay_input_delay(), burst); + } else { + fprintf(stderr, + "psxrecomp: delay_sync_starvation cleared sim=%u lead=%d " + "D=%d — resume 1:1 (rebuild input buffer)\n", + (unsigned)psx_netplay_sim_tick(), psx_netplay_remote_lead(), + psx_netplay_input_delay()); + } } } return 1; @@ -1975,11 +2109,13 @@ int psx_netplay_poll_admit(void) g_starv.latched = 1; g_starv.enter_run = 0; if (!g_starv.latch_logged) { - fprintf(stderr, - "psxrecomp: delay_sync_starvation latched sim=%u lead=%d " - "D=%d (enter=%d)\n", - (unsigned)psx_netplay_sim_tick(), psx_netplay_remote_lead(), - psx_netplay_input_delay(), enter_need); + if (np_delay_sync_diag_enabled()) { + fprintf(stderr, + "psxrecomp: delay_sync_starvation latched sim=%u lead=%d " + "D=%d (enter=%d)\n", + (unsigned)psx_netplay_sim_tick(), psx_netplay_remote_lead(), + psx_netplay_input_delay(), enter_need); + } g_starv.latch_logged = 1; } } @@ -2057,4 +2193,66 @@ void psx_netplay_wait_recv(int timeout_ms) (void)rnet_session_wait_recv(g_np.session, timeout_ms); } +void psx_netplay_admit_wait_info(char *stall_out, size_t stall_cap, + uint32_t *sim_tick_out, int *lead_out) +{ + RNetSessionStats st; + const char *name = "inactive"; + char phase[96]; + memset(&st, 0, sizeof(st)); + phase[0] = '\0'; + if (psx_netplay_active() && g_np.session) { + rnet_session_get_stats(g_np.session, &st); + name = rnet_admit_stall_name(st.last_stall); + if (!name || !name[0]) + name = "unknown"; + /* LOAD_READY never calls try_admit, so last_stall stays "ok" — surface + * the app barrier phase (+ transfer progress) instead. */ + switch (g_np.xfer) { + case NP_XFER_LOAD_PROBE: + snprintf(phase, sizeof(phase), "load_probe"); + break; + case NP_XFER_LOAD_SEND: + if (st.state_bytes_total > 0) + snprintf(phase, sizeof(phase), "load_xfer_%u/%u", + (unsigned)st.state_bytes_acked, (unsigned)st.state_bytes_total); + else + snprintf(phase, sizeof(phase), "load_xfer"); + break; + case NP_XFER_LOAD_APPLYING: + if (savestate_pending()) { + if (g_starv.latched) + snprintf(phase, sizeof(phase), "load_applying+starv_%s", name); + else + snprintf(phase, sizeof(phase), "load_applying+%s", name); + } else { + snprintf(phase, sizeof(phase), "load_apply_done+%s", name); + } + break; + case NP_XFER_LOAD_READY: + if (g_np.load_ready_replied) + snprintf(phase, sizeof(phase), "load_ready_admit+%s", name); + else if (g_np.load_applied_local) + snprintf(phase, sizeof(phase), "load_ready_wait_peer+%s", name); + else + snprintf(phase, sizeof(phase), "load_ready+%s", name); + break; + default: + break; + } + } + if (stall_out && stall_cap) { + if (phase[0]) + snprintf(stall_out, stall_cap, "%s", phase); + else { + strncpy(stall_out, name, stall_cap - 1); + stall_out[stall_cap - 1] = '\0'; + } + } + if (sim_tick_out) + *sim_tick_out = st.sim_tick; + if (lead_out) + *lead_out = st.remote_lead; +} + #endif /* PSX_HAS_RECOMP_NET */ diff --git a/runtime/src/savestate.c b/runtime/src/savestate.c index 6ee07ab9d..667eddee6 100644 --- a/runtime/src/savestate.c +++ b/runtime/src/savestate.c @@ -12,6 +12,7 @@ #include "psx_cycles.h" #include "psx_netplay.h" #include "psx_scheduler.h" +#include #include #include #include @@ -44,26 +45,53 @@ static int s_configured = 0; static int s_save_pending = -1; /* slot, or -1 */ static int s_load_pending = -1; static int s_load_completed = 0; -static uint64_t s_load_cooldown_until_frame = 0; -static int s_load_cooldown_notice = 0; +static uint8_t *s_load_blob = NULL; /* optional in-memory .pst for netplay */ +static size_t s_load_blob_len = 0; extern int psx_hle_scheduler_enabled(void); -extern uint64_t s_frame_count; - -/* Debounce only — long enough to ignore key-repeat / double F-key, short - * enough that a deliberate second load is not blocked for a full second. - * Wall time stretches if a restore hitch drops FPS (cooldown is in frames). */ -#define SAVESTATE_LOAD_COOLDOWN_FRAMES 12u +/* Create each path component (mkdir -p). Single-level mkdir fails for + * "saves/netplay" when parent "saves" is missing. */ static void ensure_dir(const char* dir) { + char tmp[512]; + size_t len; + size_t i; if (!dir || !dir[0]) return; + strncpy(tmp, dir, sizeof(tmp) - 1); + tmp[sizeof(tmp) - 1] = '\0'; + len = strlen(tmp); + while (len > 1 && (tmp[len - 1] == '/' || tmp[len - 1] == '\\')) { + tmp[--len] = '\0'; + } + for (i = 1; i < len; i++) { + if (tmp[i] == '/' || tmp[i] == '\\') { +#ifdef _WIN32 + /* Keep drive prefix "C:" intact — do not mkdir("C:"). */ + if (i == 2 && tmp[1] == ':') + continue; +#endif + tmp[i] = '\0'; +#ifdef _WIN32 + (void)_mkdir(tmp); +#else + (void)mkdir(tmp, 0755); +#endif + tmp[i] = '/'; + } + } #ifdef _WIN32 - (void)_mkdir(dir); + (void)_mkdir(tmp); #else - (void)mkdir(dir, 0755); + (void)mkdir(tmp, 0755); #endif } +static void clear_load_blob(void) { + free(s_load_blob); + s_load_blob = NULL; + s_load_blob_len = 0; +} + void savestate_configure(const char* dir, uint32_t bios_checksum, uint32_t entry_pc) { if (dir && dir[0]) { strncpy(s_dir, dir, sizeof(s_dir) - 1); @@ -140,17 +168,34 @@ int savestate_read_slot(int slot, uint8_t** data_out, size_t* size_out) { int savestate_write_slot(int slot, const void* data, size_t size) { char path[600]; FILE* f; + size_t wrote; if (!data || size == 0) return 0; - if (!savestate_slot_path(slot, path, sizeof(path))) return 0; + if (!savestate_slot_path(slot, path, sizeof(path))) { + fprintf(stderr, + "savestate: write_slot=%d failed (not configured / bad slot) " + "dir='%s' configured=%d\n", + slot, s_dir, s_configured); + return 0; + } ensure_dir(s_dir); f = fopen(path, "wb"); - if (!f) return 0; - if (fwrite(data, 1, size, f) != size) { + if (!f) { + fprintf(stderr, "savestate: write_slot fopen('%s') failed: %s\n", + path, strerror(errno)); + return 0; + } + wrote = fwrite(data, 1, size, f); + if (wrote != size) { + fprintf(stderr, + "savestate: write_slot fwrite('%s') %zu/%zu failed: %s\n", + path, wrote, size, strerror(errno)); fclose(f); remove(path); return 0; } if (fflush(f) != 0 || fclose(f) != 0) { + fprintf(stderr, "savestate: write_slot flush/close('%s') failed: %s\n", + path, strerror(errno)); remove(path); return 0; } @@ -180,18 +225,6 @@ static int request_save_inner(int slot) { static int request_load_inner(int slot) { if (!s_configured) { fprintf(stderr, "savestate: not configured\n"); return 0; } if (slot < 0 || slot >= SAVESTATE_SLOTS) return 0; - if (s_frame_count < s_load_cooldown_until_frame) { - if (!s_load_cooldown_notice) { - uint64_t left = s_load_cooldown_until_frame - s_frame_count; - fprintf(stderr, - "savestate: load ignored (%llu frame cooldown after restore; " - "%llu left)\n", - (unsigned long long)SAVESTATE_LOAD_COOLDOWN_FRAMES, - (unsigned long long)left); - s_load_cooldown_notice = 1; - } - return 1; - } if (!psx_hle_scheduler_enabled()) { /* LLE (host-fiber) mode: the restore longjmp target lives on the * scheduler fiber; cross-fiber unwind is unsafe. HLE is the default. */ @@ -220,9 +253,35 @@ int savestate_request_save_protocol(int slot) { int savestate_request_load_protocol(int slot) { /* Follow-host sync: guests must apply the host-authoritative .pst. */ + clear_load_blob(); return request_load_inner(slot); } +int savestate_request_load_blob_protocol(const void* data, size_t size) { + uint8_t* copy; + if (!s_configured) { + fprintf(stderr, "savestate: load_blob — not configured\n"); + return 0; + } + if (!data || size == 0 || size > 64u * 1024u * 1024u) + return 0; + if (!psx_hle_scheduler_enabled()) { + fprintf(stderr, "savestate: load_blob requires the HLE scheduler\n"); + return 0; + } + copy = (uint8_t*)malloc(size); + if (!copy) { + fprintf(stderr, "savestate: load_blob malloc(%zu) failed\n", size); + return 0; + } + memcpy(copy, data, size); + clear_load_blob(); + s_load_blob = copy; + s_load_blob_len = size; + s_load_pending = 0; /* non-negative: poll will prefer the blob */ + return 1; +} + int savestate_pending(void) { return (s_save_pending >= 0 || s_load_pending >= 0) ? 1 : 0; } @@ -253,13 +312,34 @@ void savestate_poll(CPUState* cpu, uint32_t resume_pc) { if (s_load_pending >= 0) { int slot = s_load_pending; + int loaded = 0; s_load_pending = -1; char path[600]; const double t_load0 = savestate_mono_ms(); double t_after_boot = t_load0; double t_after_frontend = t_load0; - if (!savestate_slot_path(slot, path, sizeof(path))) return; - if (boot_state_load(path, s_bios_checksum, s_entry_pc, cpu)) { + path[0] = '\0'; + if (s_load_blob && s_load_blob_len > 0) { + const size_t blob_len = s_load_blob_len; + loaded = boot_state_load_buffer(s_load_blob, blob_len, + s_bios_checksum, s_entry_pc, cpu); + clear_load_blob(); + if (!loaded) { + fprintf(stderr, + "savestate: LOAD FAILED blob (%zu bytes, entry=%08X)\n", + blob_len, (unsigned)s_entry_pc); + } + } else if (savestate_slot_path(slot, path, sizeof(path))) { + loaded = boot_state_load(path, s_bios_checksum, s_entry_pc, cpu); + if (!loaded) { + fprintf(stderr, + "savestate: LOAD FAILED slot %d (missing/mismatched) %s\n", + slot, path); + } + } else { + fprintf(stderr, "savestate: LOAD FAILED slot %d (no path)\n", slot); + } + if (loaded) { t_after_boot = savestate_mono_ms(); psx_cycles_resync_after_restore(cpu); /* Drop absolute-cycle IRQ cooldowns / VBlank phase from the @@ -270,9 +350,6 @@ void savestate_poll(CPUState* cpu, uint32_t resume_pc) { * Init, seeks) so the picture does not freeze for ~1s after the * restored frame presents. */ cdrom_accelerate_after_savestate(); - s_load_cooldown_until_frame = - s_frame_count + SAVESTATE_LOAD_COOLDOWN_FRAMES; - s_load_cooldown_notice = 0; /* Netplay post-load barrier observes this before the longjmp. */ s_load_completed = 1; /* Restage FBO/present latch so the restored frame is visible @@ -281,17 +358,15 @@ void savestate_poll(CPUState* cpu, uint32_t resume_pc) { t_after_frontend = savestate_mono_ms(); fprintf(stderr, "savestate: LOADED slot %d -> resuming pc=0x%08X " - "(boot=%.1f frontend=%.1f poll_total=%.1f ms)\n", + "(boot=%.1f frontend=%.1f poll_total=%.1f ms)%s\n", slot, (unsigned)cpu->pc, t_after_boot - t_load0, t_after_frontend - t_after_boot, - t_after_frontend - t_load0); + t_after_frontend - t_load0, + path[0] ? "" : " [blob]"); /* Unwind to the scheduler and re-dispatch the restored PC. Never * returns; abandons the suspended CPS frames on the current stack. */ psx_scheduler_resume_at(cpu->pc); - } else { - fprintf(stderr, "savestate: LOAD FAILED slot %d (missing/mismatched) %s\n", - slot, path); } } } From 7b1b890a46ed4a2bd1ef51390e59fae5b34fd031 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Tue, 28 Jul 2026 00:17:56 -0400 Subject: [PATCH 33/38] ICE optimizations, connectivity, latency polling, save/loadstate performance --- lib/recomp-net | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/recomp-net b/lib/recomp-net index 9bd27bd53..91cfe3c9f 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit 9bd27bd5372d77bd030bfab8b6e3e5135006ad5d +Subproject commit 91cfe3c9ffab0f8465248067e7e27e262bf78793 From 54a691617e715f25e31084f931964a25f8d5a79e Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Tue, 28 Jul 2026 15:42:53 -0400 Subject: [PATCH 34/38] PGO workflow CI hardening --- runtime/src/crash_trace.c | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/runtime/src/crash_trace.c b/runtime/src/crash_trace.c index 23ca73dbc..317a3da3d 100644 --- a/runtime/src/crash_trace.c +++ b/runtime/src/crash_trace.c @@ -8,6 +8,10 @@ * - trap_crash * - TCP "post_mortem_dump" command (future) * + * Soft-exit (SIGINT / SIGTERM / SIGUSR1, plus Windows console Ctrl handlers) + * calls exit(0) so atexit / __gcov_exit / LLVM profile writers flush — required + * for PGO train scripts that stop the process with kill. + * * Mirrors the sibling SuperMarioWorldRecomp project's src/post_mortem.c. The file * is OVERWRITTEN on each dump (last-write-wins, single file per run); * this is not a log per CLAUDE.md §3 — it's a one-shot final state @@ -647,6 +651,15 @@ static void psx_signal_handler(int sig) { raise(sig); } +/* Default SIGINT/SIGTERM terminate without running atexit, so GCC/LLVM + * never flush PGO profiles (train scripts use kill). Route those through + * exit(0) so __gcov_exit / instr-profile writers run. Not async-signal-safe; + * acceptable for intentional train/Ctrl+C stop. */ +static void psx_soft_exit_handler(int sig) { + (void)sig; + exit(0); +} + #ifdef _WIN32 static LONG WINAPI psx_seh_handler(EXCEPTION_POINTERS *info) { psx_crash_trace_dump("seh", info); @@ -655,6 +668,15 @@ static LONG WINAPI psx_seh_handler(EXCEPTION_POINTERS *info) { freeze_heartbeat_fatal_dump("seh"); return EXCEPTION_EXECUTE_HANDLER; } + +static BOOL WINAPI psx_console_ctrl_handler(DWORD type) { + if (type == CTRL_C_EVENT || type == CTRL_BREAK_EVENT || + type == CTRL_CLOSE_EVENT) { + exit(0); + return TRUE; + } + return FALSE; +} #endif static void psx_atexit_handler(void) { @@ -666,6 +688,12 @@ static void psx_atexit_handler(void) { void psx_crash_trace_install_handlers(void) { #ifndef _WIN32 signal(SIGSEGV, psx_signal_handler); +#endif + /* Soft-exit on all hosts (incl. MinGW): MSYS2 kill -TERM must flush PGO. */ + signal(SIGINT, psx_soft_exit_handler); + signal(SIGTERM, psx_soft_exit_handler); +#ifdef SIGUSR1 + signal(SIGUSR1, psx_soft_exit_handler); #endif signal(SIGABRT, psx_signal_handler); #ifdef _WIN32 @@ -677,6 +705,7 @@ void psx_crash_trace_install_handlers(void) { * filter and we can write the report without the user having to * dismiss a popup first. */ SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); + SetConsoleCtrlHandler(psx_console_ctrl_handler, TRUE); #endif atexit(psx_atexit_handler); } From 6cfe713011acc518d81d51b9bd21d95c794427da Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Tue, 28 Jul 2026 15:52:23 -0400 Subject: [PATCH 35/38] workflow and cross platform update --- lib/recomp-net | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/recomp-net b/lib/recomp-net index 91cfe3c9f..f8143fd91 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit 91cfe3c9ffab0f8465248067e7e27e262bf78793 +Subproject commit f8143fd91e951132461ab8d9d85d12e946addcfd From 3b72058a5c1e832da4917c4b2ca25694cf7c17f8 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Tue, 28 Jul 2026 15:58:56 -0400 Subject: [PATCH 36/38] Update recomp-net --- lib/recomp-net | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/recomp-net b/lib/recomp-net index f8143fd91..7e9f647f6 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit f8143fd91e951132461ab8d9d85d12e946addcfd +Subproject commit 7e9f647f6bc4aee19291629e7f70b9bc0e2c2e6a From e13e197ce6a61d53f4d0c9102baecd892e9537ca Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Tue, 28 Jul 2026 16:04:08 -0400 Subject: [PATCH 37/38] Update recomp-net --- lib/recomp-net | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/recomp-net b/lib/recomp-net index f8143fd91..79f84323e 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit f8143fd91e951132461ab8d9d85d12e946addcfd +Subproject commit 79f84323eba5bbd855842020fe9861ce4fd2539d From 92efb5c4fd58fdf34578e8dff8a870338bb36cae Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Wed, 29 Jul 2026 02:40:50 -0400 Subject: [PATCH 38/38] no mods in MP, minor patches --- lib/recomp-net | 2 +- runtime/include/boot_state.h | 7 + runtime/include/gpu.h | 3 + runtime/include/gpu_gl_renderer.h | 6 + runtime/include/gpu_vk_renderer.h | 3 + runtime/include/mdec.h | 5 + runtime/include/mod_runtime.h | 3 + runtime/include/psx_cycles.h | 12 +- runtime/include/psx_netplay.h | 7 +- runtime/include/savestate.h | 9 + runtime/include/sio.h | 3 + runtime/src/boot_state.c | 155 +++++++++--- runtime/src/gpu.c | 9 + runtime/src/gpu_gl_renderer.c | 244 +++++++++++-------- runtime/src/gpu_vk_renderer.c | 39 +++ runtime/src/main.cpp | 383 +++++++++++++++++++++--------- runtime/src/mdec.c | 138 +++++++++++ runtime/src/mod_runtime.cpp | 36 +++ runtime/src/psx_netplay.c | 231 ++++++++++++++---- runtime/src/savestate.c | 38 ++- 20 files changed, 1042 insertions(+), 291 deletions(-) diff --git a/lib/recomp-net b/lib/recomp-net index 79f84323e..a1d77a32d 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit 79f84323eba5bbd855842020fe9861ce4fd2539d +Subproject commit a1d77a32dd4cbbfe95bd56d59febce11086f51a5 diff --git a/runtime/include/boot_state.h b/runtime/include/boot_state.h index fd288f463..5b66bdbde 100644 --- a/runtime/include/boot_state.h +++ b/runtime/include/boot_state.h @@ -91,6 +91,7 @@ enum { BS_SEC_DMA = 0x0C, /* DMA channels[7] + dpcr/dicr + async-transfer state */ 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) */ }; /* Save a COMPLETE snapshot at game handoff. Returns 1 on success. */ @@ -107,6 +108,12 @@ int boot_state_load_buffer(const uint8_t* file, size_t file_len, uint32_t bios_checksum, uint32_t entry_pc, CPUState* cpu); +/* Header-only integrity check (no section inflate/apply). Returns 1 if this + * build can load the image; 0 and fills reason (when non-NULL) on reject. */ +int boot_state_check_buffer(const uint8_t* file, size_t file_len, + uint32_t bios_checksum, uint32_t entry_pc, + char* reason, size_t reason_cap); + /* Register a deferred capture: when boot_state_trigger_capture() fires (from * fntrace at game-start), serialize to path. One-shot. */ void boot_state_set_capture(const char* path, uint32_t bios_checksum, diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index c4718d3c1..811b220e4 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -56,6 +56,9 @@ void gpu_depth24_upload_span_reset(void); * Swap (keep prior frame) so stale trailing VRAM never flashes. One tick * per vblank; returns non-zero while the hold is still active after tick. */ int gpu_depth24_present_hold_tick(void); +/* After savestate restore: clear ephemeral hold so the restored depth24 + * frame can present immediately (span/prev_h come from the GPU snap). */ +void gpu_depth24_on_savestate_loaded(void); /* GP1(06h)/GP1(07h)/GP1(08h) fields for debug (gpu_state). */ void gpu_get_crtc_debug(uint32_t *x1, uint32_t *x2, uint32_t *y1, uint32_t *y2, uint32_t *hres1_out, uint32_t *hres2_out); diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index d29f997e4..a12b653fc 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -65,6 +65,12 @@ void gl_renderer_flush_cpu_uploads(void); * reloaded identical frame still reaches the window (double/triple buffer). */ void gl_renderer_invalidate_present(void); +/* After savestate restore: push the CPU VRAM mirror into the GL FBO. Needed + * when the load happened while GP1 depth24 was on — the normal path skips + * framebuffer-sized uploads, which also skipped the full-VRAM boot_state + * blit and left post-FMV menus without texture pages. */ +void gl_renderer_restage_vram_after_savestate(void); + /* Post-savestate freeze probe: skip/swap/dirty-mark counters (GL present path). * take() returns deltas since the previous take/reset. Safe no-ops when GL is * inactive. rect_dirty tests the current present-tile dirty bits. */ diff --git a/runtime/include/gpu_vk_renderer.h b/runtime/include/gpu_vk_renderer.h index fa12405e9..47820fa87 100644 --- a/runtime/include/gpu_vk_renderer.h +++ b/runtime/include/gpu_vk_renderer.h @@ -45,6 +45,9 @@ void vk_renderer_present_blank(void); * server, 24-bit present). No-op when the Vulkan path is inactive. */ void vk_renderer_sync_cpu(void); +/* After savestate restore: push CPU VRAM into the Vulkan image (see GL twin). */ +void vk_renderer_restage_vram_after_savestate(void); + /* Set the present policy: 1=tear-free, 0=IMMEDIATE (lowest latency, may tear), * -1=MAILBOX. Tear-free prefers MAILBOX because the frontend already paces * frames; unsupported modes fall back to FIFO (always available). */ diff --git a/runtime/include/mdec.h b/runtime/include/mdec.h index 0db6e52fa..edc83fbb0 100644 --- a/runtime/include/mdec.h +++ b/runtime/include/mdec.h @@ -79,6 +79,11 @@ void mdec_debug_dma_in_end(uint32_t addr, uint32_t words); void mdec_debug_dma_out_start(uint32_t addr, uint32_t words); void mdec_debug_dma_out_end(uint32_t addr, uint32_t words); +/* boot_state / savestate: full MDEC FIFO + tables (required for FMV resume). */ +uint32_t mdec_snapshot_bytes(void); +void mdec_snapshot_write(uint8_t *p); +int mdec_snapshot_read(const uint8_t *p, uint32_t len); + #ifdef __cplusplus } #endif diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index b62056ea3..a4f5a66a1 100644 --- a/runtime/include/mod_runtime.h +++ b/runtime/include/mod_runtime.h @@ -18,6 +18,9 @@ bool mod_runtime_initialize(const std::filesystem::path& root, std::string* error = nullptr); bool mod_runtime_commit(const std::filesystem::path& disc_path = {}, std::string* error = nullptr); +/* Drop the in-session mod plan for a netplay launch without rewriting the + * user's persisted offline selection on disk. */ +bool mod_runtime_clear_for_netplay(std::string* error = nullptr); const std::string& mod_runtime_fingerprint(); const std::filesystem::path& mod_runtime_effective_disc_path(); diff --git a/runtime/include/psx_cycles.h b/runtime/include/psx_cycles.h index d7926aa71..529c55838 100644 --- a/runtime/include/psx_cycles.h +++ b/runtime/include/psx_cycles.h @@ -147,9 +147,17 @@ extern uint64_t g_idle_skip_cycles; extern uint32_t g_idle_skip_last_pc; extern uint32_t g_idle_skip_last_quantum; +/* Post-load probe cycle diagnostics (optional; main.cpp soft-load tooling). */ +extern int g_plp_cycle_diag; +extern uint64_t g_plp_adv_calls; +extern uint32_t g_plp_adv_max_chunk; +extern uint64_t g_plp_adv_sum; +extern uint64_t g_plp_svc_calls; + /* Save-state restore: re-anchor the deadline device model after psx_cycle_count - * is overwritten from a snapshot. */ -void psx_cycles_resync_after_restore(void); + * is overwritten from a snapshot. Pass the live CPU so GTE/muldiv completion + * stamps and load-absorb give-back are rewound with the guest clock. */ +void psx_cycles_resync_after_restore(struct CPUState *cpu); /* Soft rematch / session_reboot: zero the guest clock and deadline bookkeeping. * Soft-exit longjmps out of vblank (inside psx_devices_service_to_now) leave diff --git a/runtime/include/psx_netplay.h b/runtime/include/psx_netplay.h index 7188a0b33..c9632eea3 100644 --- a/runtime/include/psx_netplay.h +++ b/runtime/include/psx_netplay.h @@ -103,9 +103,14 @@ int psx_netplay_is_host(void); int psx_netplay_request_save(int slot); int psx_netplay_request_load(int slot); -/* 1 while load probe/transfer/apply/ready owns the clock (no FPS / no present). */ +/* 1 while a save/load/memcard probe, chunk transfer, or post-load ready owns + * the clock (long admit timeout, no peer-silence kick, FPS suppressed). */ int psx_netplay_in_load_barrier(void); +/* 1 once after a staged netplay load apply failed (stale .pst / mismatch). + * Clears. Caller should soft-exit to lobby — do not keep waiting on the barrier. */ +int psx_netplay_consume_load_apply_failed(void); + /* Stage local pad for the current sim tick. Ignored once that tick is latched. */ void psx_netplay_stage_local(const PsxNetPad *pad); diff --git a/runtime/include/savestate.h b/runtime/include/savestate.h index f55309bda..b45d86ffa 100644 --- a/runtime/include/savestate.h +++ b/runtime/include/savestate.h @@ -45,6 +45,11 @@ int savestate_write_slot(int slot, const void* data, size_t size); /* 1 if the slot file exists and is non-empty. */ int savestate_slot_exists(int slot); +/* 1 if the slot .pst header matches this build's integrity key (BIOS/entry/ + * codegen). 0 + optional reason when missing or stale — use before netplay + * load probe so incompatible saves never enter the post-load barrier. */ +int savestate_slot_compatible(int slot, char* reason, size_t reason_cap); + /* Stage a save/load of slot [0..SAVESTATE_SLOTS-1]. Executed at the next safe * boundary by savestate_poll (called every block from psx_check_interrupts). * Safe to call from the SDL key handler or a debug-server command. @@ -68,6 +73,10 @@ int savestate_pending(void); /* 1 once after a successful load restore (before scheduler longjmp). Clears. */ int savestate_take_load_completed(void); +/* 1 once after a staged load failed in savestate_poll (missing/mismatched). + * Clears. Netplay uses this to abort the load barrier instead of hanging. */ +int savestate_take_load_failed(void); + /* Frontend hook (main.cpp): restage VRAM present path after a successful load. */ void psx_frontend_on_savestate_loaded(void); diff --git a/runtime/include/sio.h b/runtime/include/sio.h index c36131dc2..587f599dc 100644 --- a/runtime/include/sio.h +++ b/runtime/include/sio.h @@ -79,6 +79,9 @@ int sio_get_multitap(void); /* phys_port: 0 = console Port 1, 1 = console Port 2. Default 0. */ void sio_set_multitap_port(int phys_port); int sio_get_multitap_port(void); +/* Tomba-only legacy pad config path ([controller] legacy_pad_config). */ +void sio_set_legacy_cfg(int enabled); +int sio_get_legacy_cfg(void); /* 1 when multitap is armed and `logical_slot` is a tap pad (not the lone * pad on the opposite console port). SCPH-1070 taps are treated as plain * digital controllers (0x41) — DualShock/analog on a tap is not reliable diff --git a/runtime/src/boot_state.c b/runtime/src/boot_state.c index 4f2a93a88..eeb7a1e95 100644 --- a/runtime/src/boot_state.c +++ b/runtime/src/boot_state.c @@ -69,6 +69,9 @@ extern int dma_snapshot_read(const uint8_t* p, uint32_t len); extern uint32_t sio_snapshot_bytes(void); extern void sio_snapshot_write(uint8_t* p); extern int sio_snapshot_read(const uint8_t* p, uint32_t len); +extern uint32_t mdec_snapshot_bytes(void); +extern void mdec_snapshot_write(uint8_t* p); +extern int mdec_snapshot_read(const uint8_t* p, uint32_t len); /* CPU regs wire: 32+3+32+32+32 LE u32 = 131 * 4 = 524 bytes (no padding). */ #define CPU_REGS_WIRE_BYTES (524u) @@ -208,7 +211,7 @@ int boot_state_save(const CPUState* cpu, uint32_t bios_checksum, h.codegen_hash = (uint32_t)PSX_OVERLAY_CODEGEN_HASH; h.abi_tag = (int32_t)PSX_OVERLAY_ABI_TAG; h.codegen_ver = (uint32_t)PSX_OVERLAY_CODEGEN_VER; - h.section_count = 14; + h.section_count = 15; int ok = write_header_le(f, &h); @@ -257,6 +260,7 @@ int boot_state_save(const CPUState* cpu, uint32_t bios_checksum, if (ok) ok = write_module_section(f, BS_SEC_CDROM, cdrom_snapshot_bytes, cdrom_snapshot_write); if (ok) ok = write_module_section(f, BS_SEC_DMA, dma_snapshot_bytes, dma_snapshot_write); if (ok) ok = write_module_section(f, BS_SEC_SIO, sio_snapshot_bytes, sio_snapshot_write); + if (ok) ok = write_module_section(f, BS_SEC_MDEC, mdec_snapshot_bytes, mdec_snapshot_write); if (ok) { uint32_t wc = dirty_ram_get_bitmap_word_count(); uint64_t nbytes = (uint64_t)wc * 4u; @@ -393,6 +397,8 @@ static int apply_section(uint32_t tag, const uint8_t* p, uint32_t len, return dma_snapshot_read(p, len); case BS_SEC_SIO: return sio_snapshot_read(p, len); + case BS_SEC_MDEC: + return mdec_snapshot_read(p, len); case BS_SEC_DIRTY: { uint32_t wc; uint32_t* words; @@ -417,18 +423,121 @@ static int apply_section(uint32_t tag, const uint8_t* p, uint32_t len, } } +static int boot_state_parse_header(const uint8_t* file, size_t file_len, + BootStateHeader* h_out) { + PstR hr; + if (!file || !h_out || file_len < BOOT_STATE_HEADER_WIRE_BYTES || + file_len > 64u * 1024u * 1024u) { + return 0; + } + pst_r_init(&hr, file, BOOT_STATE_HEADER_WIRE_BYTES); + memset(h_out, 0, sizeof(*h_out)); + if (!pst_r_u32(&hr, &h_out->magic) || + !pst_r_u32(&hr, &h_out->version) || + !pst_r_u32(&hr, &h_out->bios_checksum) || + !pst_r_u32(&hr, &h_out->entry_pc) || + !pst_r_u32(&hr, &h_out->codegen_hash) || + !pst_r_i32(&hr, &h_out->abi_tag) || + !pst_r_u32(&hr, &h_out->codegen_ver) || + !pst_r_u32(&hr, &h_out->section_count) || + !pst_r_u32(&hr, &h_out->reserved)) { + return 0; + } + return 1; +} + +static void boot_state_append_reason(char* reason, size_t reason_cap, + const char* part) { + size_t n; + if (!reason || reason_cap == 0 || !part || !part[0]) return; + n = strlen(reason); + if (n + 1 >= reason_cap) return; + if (n > 0) { + reason[n++] = ','; + reason[n] = '\0'; + if (n + 1 >= reason_cap) return; + } + snprintf(reason + n, reason_cap - n, "%s", part); +} + +int boot_state_check_buffer(const uint8_t* file, size_t file_len, + uint32_t bios_checksum, uint32_t entry_pc, + char* reason, size_t reason_cap) { + BootStateHeader h; + char part[96]; + + if (reason && reason_cap) + reason[0] = '\0'; + + if (!file || file_len < BOOT_STATE_HEADER_WIRE_BYTES) { + boot_state_append_reason(reason, reason_cap, "missing_or_truncated"); + return 0; + } + if (file_len > 64u * 1024u * 1024u) { + boot_state_append_reason(reason, reason_cap, "too_large"); + return 0; + } + if (!boot_state_parse_header(file, file_len, &h)) { + boot_state_append_reason(reason, reason_cap, "header_parse"); + return 0; + } + + if (h.magic != BOOT_STATE_MAGIC) { + snprintf(part, sizeof(part), "magic=%08X(want %08X)", + (unsigned)h.magic, (unsigned)BOOT_STATE_MAGIC); + boot_state_append_reason(reason, reason_cap, part); + } + if (h.version < BOOT_STATE_VERSION_MIN_READ || + h.version > BOOT_STATE_VERSION) { + snprintf(part, sizeof(part), "version=%u(want %u..%u)", + (unsigned)h.version, (unsigned)BOOT_STATE_VERSION_MIN_READ, + (unsigned)BOOT_STATE_VERSION); + boot_state_append_reason(reason, reason_cap, part); + } + if (h.bios_checksum != bios_checksum) { + snprintf(part, sizeof(part), "bios=%08X(want %08X)", + (unsigned)h.bios_checksum, (unsigned)bios_checksum); + boot_state_append_reason(reason, reason_cap, part); + } + if (h.entry_pc != entry_pc) { + snprintf(part, sizeof(part), "entry=%08X(want %08X)", + (unsigned)h.entry_pc, (unsigned)entry_pc); + boot_state_append_reason(reason, reason_cap, part); + } + if (h.codegen_hash != (uint32_t)PSX_OVERLAY_CODEGEN_HASH) { + snprintf(part, sizeof(part), "codegen_hash=%08X(want %08X)", + (unsigned)h.codegen_hash, + (unsigned)PSX_OVERLAY_CODEGEN_HASH); + boot_state_append_reason(reason, reason_cap, part); + } + if (h.abi_tag != (int32_t)PSX_OVERLAY_ABI_TAG) { + snprintf(part, sizeof(part), "abi_tag=%d(want %d)", + (int)h.abi_tag, (int)PSX_OVERLAY_ABI_TAG); + boot_state_append_reason(reason, reason_cap, part); + } + if (h.codegen_ver != (uint32_t)PSX_OVERLAY_CODEGEN_VER) { + snprintf(part, sizeof(part), "codegen_ver=%u(want %u)", + (unsigned)h.codegen_ver, (unsigned)PSX_OVERLAY_CODEGEN_VER); + boot_state_append_reason(reason, reason_cap, part); + } + + if (reason && reason_cap && reason[0]) + return 0; + return 1; +} + int boot_state_load_buffer(const uint8_t* file, size_t file_len, uint32_t bios_checksum, uint32_t entry_pc, CPUState* cpu) { const uint8_t* cur; const uint8_t* end; BootStateHeader h; - PstR hr; + char reject[256]; const uint32_t required = (1u< 64u * 1024u * 1024u) { + if (!boot_state_check_buffer(file, file_len, bios_checksum, entry_pc, + reject, sizeof(reject))) { + fprintf(stderr, "boot_state: reject — %s\n", + reject[0] ? reject : "unknown"); return 0; } - - /* Parse header from the in-memory image (one I/O, then CPU-side inflate). */ - pst_r_init(&hr, file, BOOT_STATE_HEADER_WIRE_BYTES); - memset(&h, 0, sizeof h); - if (!pst_r_u32(&hr, &h.magic) || - !pst_r_u32(&hr, &h.version) || - !pst_r_u32(&hr, &h.bios_checksum) || - !pst_r_u32(&hr, &h.entry_pc) || - !pst_r_u32(&hr, &h.codegen_hash) || - !pst_r_i32(&hr, &h.abi_tag) || - !pst_r_u32(&hr, &h.codegen_ver) || - !pst_r_u32(&hr, &h.section_count) || - !pst_r_u32(&hr, &h.reserved)) { + if (!boot_state_parse_header(file, file_len, &h)) return 0; - } - - if (h.magic != BOOT_STATE_MAGIC || - h.version < BOOT_STATE_VERSION_MIN_READ || - h.version > BOOT_STATE_VERSION || - h.bios_checksum != bios_checksum || - h.entry_pc != entry_pc || - h.codegen_hash != (uint32_t)PSX_OVERLAY_CODEGEN_HASH || - h.abi_tag != (int32_t)PSX_OVERLAY_ABI_TAG || - h.codegen_ver != (uint32_t)PSX_OVERLAY_CODEGEN_VER) { - return 0; - } cur = file + BOOT_STATE_HEADER_WIRE_BYTES; end = file + file_len; @@ -571,10 +658,16 @@ int boot_state_load(const char* path, uint32_t bios_checksum, const double t0 = boot_state_mono_ms(); double t_after_read; - if (!f) return 0; + if (!f) { + fprintf(stderr, "boot_state: reject — missing %s\n", + path ? path : "(null)"); + return 0; + } if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return 0; } sz = ftell(f); if (sz < (long)BOOT_STATE_HEADER_WIRE_BYTES || sz > 64L * 1024L * 1024L) { + fprintf(stderr, "boot_state: reject — bad size %ld for %s\n", + sz, path ? path : "(null)"); fclose(f); return 0; } diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 7dd6d347e..b78d320c7 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -2545,6 +2545,12 @@ int gpu_depth24_present_hold_tick(void) { return 1; } +void gpu_depth24_on_savestate_loaded(void) { + /* Hold skips Swap — after restore we want the restored VRAM visible now. + * Upload span / prev_h were restored from the GPU snap. */ + s_d24_present_hold = 0; +} + /* ---- Present-time screen-colour LUT (verified-enhancement, opt-in) ------- * * PRESENT-TIME ONLY. This sits on the 15-bit-scanout -> RGB888 conversion that @@ -4999,6 +5005,8 @@ static int gpu_snap_emit(PstW *w) { WH(vram_write_col); WH(vram_write_row); WU(vram_write_remaining); WI(vram_read_active); WH(vram_read_x); WH(vram_read_y); WH(vram_read_w); WH(vram_read_h); WH(vram_read_col); WH(vram_read_row); + /* Depth24 present helpers (MotK FMV) — must resume with upload span. */ + WU(s_d24_upload_x1); WI(s_d24_present_hold); WU(s_d24_prev_disp_h); #undef WU #undef WI #undef WH @@ -5031,6 +5039,7 @@ static int gpu_snap_parse(PstR *r) { RH(vram_write_col); RH(vram_write_row); RU(vram_write_remaining); RI(vram_read_active); RH(vram_read_x); RH(vram_read_y); RH(vram_read_w); RH(vram_read_h); RH(vram_read_col); RH(vram_read_row); + RU(s_d24_upload_x1); RI(s_d24_present_hold); RU(s_d24_prev_disp_h); #undef RU #undef RI #undef RH diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 077cc023b..a780c4c02 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -69,12 +69,12 @@ #include "gpu_gl_renderer.h" #include "latency_ring.h" -#include "psx_sdl.h" -#if defined(PSX_SDL3) -#include -#else -#include -#endif +#include "psx_sdl.h" +#if defined(PSX_SDL3) +#include +#else +#include +#endif #include #include #include @@ -315,12 +315,12 @@ static GLint s_present_uTex = -1, s_present_uUvRect = -1; static GLuint s_interp_prog = 0, s_interp_tex[3]; static GLsync s_interp_fence[3]; static GLsync s_interp_draw_fence = NULL; -static GLint s_interp_uPrev = -1, s_interp_uCurr = -1; -static GLint s_interp_uAlpha = -1, s_interp_uUvRect = -1; -static GLint s_interp_uBlendMode = -1; -static int s_interp_enabled = 0, s_interp_valid = 0; -static int s_interp_suspended = 0; -static int s_interp_blend_mode = 0; +static GLint s_interp_uPrev = -1, s_interp_uCurr = -1; +static GLint s_interp_uAlpha = -1, s_interp_uUvRect = -1; +static GLint s_interp_uBlendMode = -1; +static int s_interp_enabled = 0, s_interp_valid = 0; +static int s_interp_suspended = 0; +static int s_interp_blend_mode = 0; static int s_interp_prev = 0, s_interp_cur = 0; static int s_interp_w = 0, s_interp_h = 0, s_interp_linear = 0; static int s_interp_force_4_3 = 0, s_interp_source_path = -1; @@ -748,21 +748,21 @@ static const char *PRESENT_FS = "#version 330\n" "in vec2 v_uv; uniform sampler2D u_tex; out vec4 frag;\n" "void main(){ frag = texture(u_tex, v_uv); }\n"; -static const char *INTERP_FS = - "#version 330\n" - "in vec2 v_uv; uniform sampler2D u_prev; uniform sampler2D u_curr;\n" - "uniform float u_alpha; uniform int u_blend_mode; out vec4 frag;\n" - "void main(){\n" - " vec4 prev=texture(u_prev,v_uv), curr=texture(u_curr,v_uv);\n" - " float alpha=u_alpha;\n" - " if(u_blend_mode==1){\n" - " vec3 d=abs(prev.rgb-curr.rgb);\n" - " float change=max(max(d.r,d.g),d.b);\n" - " float safe_blend=1.0-smoothstep(0.08,0.20,change);\n" - " alpha=mix(step(0.5,u_alpha),u_alpha,safe_blend);\n" - " }\n" - " frag=mix(prev,curr,alpha);\n" - "}\n"; +static const char *INTERP_FS = + "#version 330\n" + "in vec2 v_uv; uniform sampler2D u_prev; uniform sampler2D u_curr;\n" + "uniform float u_alpha; uniform int u_blend_mode; out vec4 frag;\n" + "void main(){\n" + " vec4 prev=texture(u_prev,v_uv), curr=texture(u_curr,v_uv);\n" + " float alpha=u_alpha;\n" + " if(u_blend_mode==1){\n" + " vec3 d=abs(prev.rgb-curr.rgb);\n" + " float change=max(max(d.r,d.g),d.b);\n" + " float safe_blend=1.0-smoothstep(0.08,0.20,change);\n" + " alpha=mix(step(0.5,u_alpha),u_alpha,safe_blend);\n" + " }\n" + " frag=mix(prev,curr,alpha);\n" + "}\n"; /* Geometry: position in VRAM pixels (draw offset already applied by gpu.c), * color rgb in 0..1, color a = mask bit (0/1). The clip transform is in @@ -2139,6 +2139,28 @@ static int depth24_is_fb_transfer(int w, int h) { return 0; } +/* Remember only the depth24 scanout band for clear-on-leave. Used after a + * full-VRAM savestate restage so texture pages stay in the FBO while the + * RGB888 movie band is still wiped when GP1 leaves 24-bit (avoids MotK + * rainbow/static from treating packed RGB as 1555). */ +static void depth24_mark_scanout_band(void) { + GpuDisplayInfo di; + int fb_w, fb_h, x0, y0, x1, y1; + if (!gpu_display_is_depth24()) return; + gpu_get_display_info(&di); + fb_w = (int)((di.width * 3u + 1u) / 2u); + fb_h = (int)di.height; + if (fb_w < 8) fb_w = 8; + if (fb_h < 1) fb_h = 1; + x0 = (int)(di.display_x & 1023u); + y0 = (int)(di.display_y & 511u); + x1 = x0 + fb_w - 1; + y1 = y0 + fb_h - 1; + if (x1 > VRAM_W - 1) x1 = VRAM_W - 1; + if (y1 > VRAM_H - 1) y1 = VRAM_H - 1; + rect_add(&s_d24_skip_fb, x0, y0, x1, y1); +} + static void depth24_clear_skipped_fb(void) { if (!s_raster_ok || !s_d24_skip_fb.set) return; flush_flat_batch(); @@ -2194,6 +2216,16 @@ static void glb_vram_transfer_in(int x,int y,int w,int h,const uint16_t *d){ sw_vram_transfer_in(x,y,w,h,d); depth24_upload_policy(); if (s_depth24_skip_up && depth24_is_fb_transfer(w, h)) { + /* Full-VRAM restore (boot_state): must stage into the FBO or every + * texture page outside the movie band is missing after FMV→menus. + * Only the scanout band is remembered for clear-on-leave. */ + if (w >= VRAM_W && h >= VRAM_H) { + up_add_transfer(x, y, w, h); + rect_clear(&s_d24_skip_fb); + depth24_mark_scanout_band(); + coh_record(GL_COH_UPLOAD, x, y, x + w - 1, y + h - 1); + return; + } int x0 = x & (VRAM_W - 1), y0 = y & (VRAM_H - 1); rect_add(&s_d24_skip_fb, x0, y0, x0 + w - 1, y0 + h - 1); coh_record(GL_COH_UPLOAD, x, y, x+w-1, y+h-1); @@ -2479,11 +2511,11 @@ int gl_renderer_init_context(SDL_Window *win) { s_present_uTex = p_glGetUniformLocation(s_present_prog, "u_tex"); s_present_uUvRect = p_glGetUniformLocation(s_present_prog, "u_uv_rect"); s_interp_uPrev = p_glGetUniformLocation(s_interp_prog, "u_prev"); - s_interp_uCurr = p_glGetUniformLocation(s_interp_prog, "u_curr"); - s_interp_uAlpha = p_glGetUniformLocation(s_interp_prog, "u_alpha"); - s_interp_uUvRect = p_glGetUniformLocation(s_interp_prog, "u_uv_rect"); - s_interp_uBlendMode = - p_glGetUniformLocation(s_interp_prog, "u_blend_mode"); + s_interp_uCurr = p_glGetUniformLocation(s_interp_prog, "u_curr"); + s_interp_uAlpha = p_glGetUniformLocation(s_interp_prog, "u_alpha"); + s_interp_uUvRect = p_glGetUniformLocation(s_interp_prog, "u_uv_rect"); + s_interp_uBlendMode = + p_glGetUniformLocation(s_interp_prog, "u_blend_mode"); glGenTextures(3, s_interp_tex); for (int i = 0; i < 3; i++) { glBindTexture(GL_TEXTURE_2D, s_interp_tex[i]); @@ -2656,6 +2688,22 @@ void gl_renderer_invalidate_present(void) { interp_reset_history(); } +void gl_renderer_restage_vram_after_savestate(void) { + if (!s_raster_ok || !s_vram) return; + /* Belt-and-suspenders after boot_state VRAM apply: force CPU mirror → FBO + * even if a depth24 skip swallowed the restore, then re-arm scanout-band + * clear so leaving FMV does not keep RGB888-as-1555 junk. */ + s_up_nrects = 0; + rect_clear(&s_d24_skip_fb); + s_depth24_skip_up = 0; + up_add_transfer(0, 0, VRAM_W, VRAM_H); + flush_cpu_upload(); + if (gpu_display_is_depth24()) { + s_depth24_skip_up = 1; + depth24_mark_scanout_band(); + } +} + void gl_renderer_present_probe_reset(void) { s_probe_skip = 0; s_probe_swap = 0; @@ -3290,13 +3338,13 @@ static void interp_reset_history(void) { if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); } -void gl_renderer_set_interpolation(int enabled, double host_hz, double target_hz, - int blend_mode) { - double effective_hz = target_hz < 0.0 - ? -1.0 - : (target_hz >= 60.0 ? target_hz : host_hz); - int active = (enabled && - (effective_hz < 0.0 || effective_hz >= 50.0)) ? 1 : 0; +void gl_renderer_set_interpolation(int enabled, double host_hz, double target_hz, + int blend_mode) { + double effective_hz = target_hz < 0.0 + ? -1.0 + : (target_hz >= 60.0 ? target_hz : host_hz); + int active = (enabled && + (effective_hz < 0.0 || effective_hz >= 50.0)) ? 1 : 0; const char *diag = getenv("PSX_GL_INTERP_DIAG"); s_interp_diag = diag && diag[0] && diag[0] != '0'; if (active && !s_interp_ctx && s_ctx) { @@ -3324,20 +3372,20 @@ void gl_renderer_set_interpolation(int enabled, double host_hz, double target_hz if (s_interp_mutex) SDL_LockMutex(s_interp_mutex); if (active != s_interp_enabled) interp_reset_history_unlocked(); s_interp_enabled = active; - s_interp_host_hz = host_hz; - s_interp_target_hz = active ? effective_hz : 0.0; - s_interp_blend_mode = blend_mode == 1 ? 1 : 0; + s_interp_host_hz = host_hz; + s_interp_target_hz = active ? effective_hz : 0.0; + s_interp_blend_mode = blend_mode == 1 ? 1 : 0; if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); - if (active && effective_hz < 0.0) - fprintf(stdout, "psxrecomp: GL frame interpolation enabled: uncapped " - "target on %.1f Hz display (%s blend)\n", host_hz, - s_interp_blend_mode ? "motion-adaptive" : "linear"); - else if (active) - fprintf(stdout, "psxrecomp: GL frame interpolation enabled: %.1f FPS " - "target on %.1f Hz display (%s blend)\n", effective_hz, host_hz, - s_interp_blend_mode ? "motion-adaptive" : "linear"); - else - fprintf(stdout, "psxrecomp: GL frame interpolation disabled (host %.1f Hz)\n", host_hz); + if (active && effective_hz < 0.0) + fprintf(stdout, "psxrecomp: GL frame interpolation enabled: uncapped " + "target on %.1f Hz display (%s blend)\n", host_hz, + s_interp_blend_mode ? "motion-adaptive" : "linear"); + else if (active) + fprintf(stdout, "psxrecomp: GL frame interpolation enabled: %.1f FPS " + "target on %.1f Hz display (%s blend)\n", effective_hz, host_hz, + s_interp_blend_mode ? "motion-adaptive" : "linear"); + else + fprintf(stdout, "psxrecomp: GL frame interpolation disabled (host %.1f Hz)\n", host_hz); } void gl_renderer_set_interpolation_suspended(int suspended) { @@ -3452,9 +3500,9 @@ static void interp_draw_quad(float alpha, int lx, int ly, int lw, int lh) { glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, s_interp_linear ? GL_LINEAR : GL_NEAREST); p_glUseProgram(s_interp_prog); p_glUniform1i(s_interp_uPrev, 0); - p_glUniform1i(s_interp_uCurr, 1); - p_glUniform1f(s_interp_uAlpha, alpha); - p_glUniform1i(s_interp_uBlendMode, s_interp_blend_mode); + p_glUniform1i(s_interp_uCurr, 1); + p_glUniform1f(s_interp_uAlpha, alpha); + p_glUniform1i(s_interp_uBlendMode, s_interp_blend_mode); p_glUniform4f(s_interp_uUvRect, 0.f, 0.f, 1.f, 1.f); p_glBindVertexArray(s_interp_thread_vao); glDrawArrays(GL_TRIANGLES, 0, 3); @@ -3498,46 +3546,46 @@ static int interp_present(void) { return 1; } -static int interp_thread_main(void *opaque) { - (void)opaque; - if (SDL_GL_MakeCurrent(s_win, s_interp_ctx) != 0) return -1; - SDL_GL_SetSwapInterval(0); /* host-period scheduler owns cadence */ +static int interp_thread_main(void *opaque) { + (void)opaque; + if (SDL_GL_MakeCurrent(s_win, s_interp_ctx) != 0) return -1; + SDL_GL_SetSwapInterval(0); /* host-period scheduler owns cadence */ p_glGenVertexArrays(1, &s_interp_thread_vao); - uint64_t freq = SDL_GetPerformanceFrequency(); - uint64_t deadline = SDL_GetPerformanceCounter(); - uint64_t diag_start = deadline, diag_swaps = 0, diag_captures = 0; - - while (SDL_AtomicGet(&s_interp_thread_run)) { - SDL_LockMutex(s_interp_mutex); - double hz = s_interp_target_hz; - SDL_UnlockMutex(s_interp_mutex); - int uncapped = hz < 0.0; - uint64_t now = SDL_GetPerformanceCounter(); - if (!uncapped) { - if (hz < 50.0) hz = 60.0; - uint64_t period = (uint64_t)((double)freq / hz); - if (!period) period = 1; - deadline += period; - if (now > deadline + period * 4u) deadline = now + period; - for (;;) { - now = SDL_GetPerformanceCounter(); - if (now >= deadline) break; - uint64_t remain = deadline - now; - uint32_t ms = (uint32_t)((remain * 1000u) / - (freq ? freq : 1u)); - if (ms > 1) SDL_Delay(ms - 1); - } - while (SDL_GetPerformanceCounter() < deadline) {} - now = SDL_GetPerformanceCounter(); - } else { - deadline = now; - } - - SDL_LockMutex(s_interp_mutex); - int presented = 0; - if (SDL_AtomicGet(&s_interp_thread_run) && s_interp_enabled) - presented = interp_present(); - if (s_interp_diag && now - diag_start >= freq * 5u) { + uint64_t freq = SDL_GetPerformanceFrequency(); + uint64_t deadline = SDL_GetPerformanceCounter(); + uint64_t diag_start = deadline, diag_swaps = 0, diag_captures = 0; + + while (SDL_AtomicGet(&s_interp_thread_run)) { + SDL_LockMutex(s_interp_mutex); + double hz = s_interp_target_hz; + SDL_UnlockMutex(s_interp_mutex); + int uncapped = hz < 0.0; + uint64_t now = SDL_GetPerformanceCounter(); + if (!uncapped) { + if (hz < 50.0) hz = 60.0; + uint64_t period = (uint64_t)((double)freq / hz); + if (!period) period = 1; + deadline += period; + if (now > deadline + period * 4u) deadline = now + period; + for (;;) { + now = SDL_GetPerformanceCounter(); + if (now >= deadline) break; + uint64_t remain = deadline - now; + uint32_t ms = (uint32_t)((remain * 1000u) / + (freq ? freq : 1u)); + if (ms > 1) SDL_Delay(ms - 1); + } + while (SDL_GetPerformanceCounter() < deadline) {} + now = SDL_GetPerformanceCounter(); + } else { + deadline = now; + } + + SDL_LockMutex(s_interp_mutex); + int presented = 0; + if (SDL_AtomicGet(&s_interp_thread_run) && s_interp_enabled) + presented = interp_present(); + if (s_interp_diag && now - diag_start >= freq * 5u) { double seconds = (double)(now - diag_start) / (double)freq; fprintf(stdout, "psxrecomp: GL interpolation cadence: " "%.2f captures/s, %.2f presents/s\n", @@ -3547,10 +3595,10 @@ static int interp_thread_main(void *opaque) { diag_start = now; diag_captures = s_interp_captures; diag_swaps = s_interp_swaps; - } - SDL_UnlockMutex(s_interp_mutex); - if (uncapped && !presented) SDL_Delay(1); - } + } + SDL_UnlockMutex(s_interp_mutex); + if (uncapped && !presented) SDL_Delay(1); + } p_glBindVertexArray(0); SDL_GL_MakeCurrent(s_win, NULL); return 0; diff --git a/runtime/src/gpu_vk_renderer.c b/runtime/src/gpu_vk_renderer.c index 5e9b4d6f9..6ccc4bc78 100644 --- a/runtime/src/gpu_vk_renderer.c +++ b/runtime/src/gpu_vk_renderer.c @@ -39,6 +39,7 @@ int vk_renderer_present_wide(int a,int b,int c,int d){(void)a;(void)b;(void)c;( void vk_renderer_present_cpu(const uint32_t*p,int w,int h,int l,int f){(void)p;(void)w;(void)h;(void)l;(void)f;} void vk_renderer_present_blank(void){} void vk_renderer_sync_cpu(void){} +void vk_renderer_restage_vram_after_savestate(void){} void vk_renderer_set_present_mode(int m){(void)m;} int vk_perf_json(char *out,int cap,int count){(void)count; return cap>2?snprintf(out,cap,"[]"):0;} const GpuRenderBackend *vk_backend_get(void) { return 0; } @@ -2259,6 +2260,24 @@ static int depth24_is_fb_transfer(int w, int h) { return 0; } +static void depth24_mark_scanout_band(void) { + GpuDisplayInfo di; + int fb_w, fb_h, x0, y0, x1, y1; + if (!gpu_display_is_depth24()) return; + gpu_get_display_info(&di); + fb_w = (int)((di.width * 3u + 1u) / 2u); + fb_h = (int)di.height; + if (fb_w < 8) fb_w = 8; + if (fb_h < 1) fb_h = 1; + x0 = (int)(di.display_x & 1023u); + y0 = (int)(di.display_y & 511u); + x1 = x0 + fb_w - 1; + y1 = y0 + fb_h - 1; + if (x1 > VRAM_W - 1) x1 = VRAM_W - 1; + if (y1 > VRAM_H - 1) y1 = VRAM_H - 1; + rect_add(&s_d24_skip_fb, x0, y0, x1, y1); +} + static void depth24_clear_skipped_fb(void) { /* GL scissor-clears the skipped FB union. VK keeps texture uploads that * landed during depth24; the FB bands are overwritten by the next 15-bit @@ -2284,12 +2303,32 @@ static void vkb_vram_transfer_in(int x, int y, int w, int h, const uint16_t *dat if (!s_ctx_ok) return; depth24_upload_policy(); if (s_depth24_skip_up && depth24_is_fb_transfer(w, h)) { + /* Full-VRAM savestate restore: stage FBO; mark scanout band only. */ + if (w >= VRAM_W && h >= VRAM_H) { + up_add_transfer(x, y, w, h); + rect_clear(&s_d24_skip_fb); + depth24_mark_scanout_band(); + return; + } int x0 = x & (VRAM_W - 1), y0 = y & (VRAM_H - 1); rect_add(&s_d24_skip_fb, x0, y0, x0 + w - 1, y0 + h - 1); return; } up_add_transfer(x, y, w, h); /* exact touched rects, incl. per-pixel wrap */ } + +void vk_renderer_restage_vram_after_savestate(void) { + if (!s_ctx_ok || !s_vram) return; + s_up_nrects = 0; + rect_clear(&s_d24_skip_fb); + s_depth24_skip_up = 0; + up_add_transfer(0, 0, VRAM_W, VRAM_H); + flush_cpu_upload(); + if (gpu_display_is_depth24()) { + s_depth24_skip_up = 1; + depth24_mark_scanout_band(); + } +} static void vkb_vram_transfer_out(int x, int y, int w, int h, uint16_t *data) { ensure_cpu(); /* sync GPU-rendered content down to the CPU mirror first */ for (int row = 0; row < h; row++) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index fa5d526ac..79b05dc72 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -15,6 +15,7 @@ #include "text_xlate.h" #include "boot_state.h" #include "bios_hle.h" +#include "psx_bios_backend.h" #include "psx_cycles.h" #include "starvation_ring.h" #include "load_accel.h" @@ -67,7 +68,8 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "launcher_profile.h" /* per-system variant profile (theme/caps bundle) */ #include "launcher_boot_timing.h" /* PSX_LAUNCHER_BOOT_TIMING stamps */ #endif -#include +#include "psx_sdl.h" +#include "psx_sdl_audio.h" #if defined(PSX_WEB) #include #endif @@ -110,8 +112,14 @@ extern "C" void psx_event_step_conservative_env_init(void); #endif #ifndef PSX_DEFAULT_BIOS_PATH +/* Compile-time fallback name only — not an implicit player choice. + * Runtime default with no explicit pick is bios/openbios.bin (see + * resolve_bios_for_runtime / docs/BIOS_SELECTION.md). */ #define PSX_DEFAULT_BIOS_PATH "bios/SCPH1001.BIN" #endif +#ifndef PSX_BUNDLED_BIOS_PATH +#define PSX_BUNDLED_BIOS_PATH "bios/openbios.bin" +#endif #ifndef PSX_DEFAULT_GAME_CONFIG_PATH #define PSX_DEFAULT_GAME_CONFIG_PATH "" #endif @@ -827,6 +835,19 @@ extern "C" void psx_frontend_on_savestate_loaded(void) { s_fps_last_frame = 0; /* Re-anchor guest-cycle→sample budgeting (pump clears queued PCM too). */ g_audio_cycle_resync = 1; + /* Depth24 FMV: drop ephemeral present hold/cutover so restored VRAM shows. + * Upload span came back with the GPU snap; treat MDEC as already active if + * depth24 is on so we don't full-black the first post-load presents. */ + gpu_depth24_on_savestate_loaded(); + s_d24_cutover_blank = 0; + s_d24_saw_gap = 0; + s_d24_prev_mdec = gpu_display_is_depth24() ? 1 : 0; + /* Depth24 load skipped framebuffer-sized CPU→GPU uploads — including the + * full-VRAM boot_state blit — so restage the mirror into the FBO/image + * before present. Otherwise post-FMV menus miss texture pages. Safe + * no-ops when that backend was never brought up. */ + gl_renderer_restage_vram_after_savestate(); + vk_renderer_restage_vram_after_savestate(); /* GL present-dirty early-out can skip SwapWindow when the restored frame * matches the last swap (typical on 2nd+ load of the same slot). Invalidate * tiles + force several swaps so the window actually updates. Safe no-op @@ -1379,57 +1400,133 @@ static std::filesystem::path normalize_disc_path_for_launch(const std::filesyste return p; } -static bool validate_bios_for_launch(const std::filesystem::path& path) { +/* BIOS selection state (docs/BIOS_SELECTION.md). s_openbios_allowed is the + * game's [runtime] openbios; s_bundled_bios_rel is where the shipped + * redistributable image lives relative to the executable. */ +static bool s_openbios_allowed = true; +static std::string s_bundled_bios_rel = PSX_BUNDLED_BIOS_PATH; + +/* Identity-match a file against a linked backend. Size+CRC must agree — + * bytes is a guaranteed wild jump. Identity therefore decides WHICH backend + * may run, not merely whether to warn. Null = no linked image matches. + */ +static const PsxBiosBackend* bios_backend_for_file(const std::filesystem::path& path, + uint32_t* out_crc, + uint64_t* out_size) { std::ifstream f(path, std::ios::binary | std::ios::ate); - if (!f.is_open()) return false; + if (!f.is_open()) return nullptr; const std::streamoff size = f.tellg(); - if (size != 512 * 1024) { - launcher_warning("BIOS Warning", - "The selected BIOS is not 512 KiB. Please select SCPH1001.BIN."); - return false; - } + if (out_size) *out_size = (uint64_t)size; + if (size <= 0) return nullptr; std::vector data((size_t)size); - if (!read_at(f, 0, data.data(), data.size())) return false; + if (!read_at(f, 0, data.data(), data.size())) return nullptr; const uint32_t crc = crc32_compute(data.data(), data.size()); - if (crc != 0x37157331u) { - char buf[256]; - std::snprintf(buf, sizeof(buf), - "The selected BIOS CRC32 is %08X, but this build was validated with SCPH1001.BIN CRC32 37157331.\n\n" - "The runtime will try it anyway, but boot may fail.", crc); - launcher_warning("BIOS Warning", buf); - } - return true; + if (out_crc) *out_crc = crc; + for (uint32_t i = 0; i < psx_bios_registry_count; i++) { + const PsxBiosBackend* b = psx_bios_registry[i]; + if (!b || !b->image) continue; + if ((uint64_t)size == (uint64_t)b->image->image_size && + crc == b->image->image_crc32) + return b; + } + return nullptr; } -static std::filesystem::path resolve_bios_path(const char* requested, const char* argv0); +/* What a player may supply, for mismatch and picker copy. The bundled image is + * excluded: it is never something to go and find. */ +static std::string bios_accepted_images() { + std::string s; + for (uint32_t i = 0; i < psx_bios_registry_count; i++) { + const PsxBiosBackend* b = psx_bios_registry[i]; + if (!b || !b->image || b->image->image_bundled) continue; + if (!s.empty()) s += ", "; + s += b->image->image_id; + s += " (" + std::to_string(b->image->image_size / 1024u) + " KB)"; + } + return s.empty() ? std::string("(this build ships its own BIOS)") : s; +} -static std::filesystem::path resolve_bios_for_runtime(const char* requested, - const char* argv0) { - std::filesystem::path resolved = resolve_bios_path(requested, argv0); - if (!resolved.empty() && std::filesystem::exists(resolved) && - validate_bios_for_launch(resolved)) { - return resolved; - } +/* Identity-gate a player-chosen BIOS and activate its backend on success. */ +static bool validate_bios_for_launch(const std::filesystem::path& path) { + uint32_t crc = 0; uint64_t size = 0; + const PsxBiosBackend* b = bios_backend_for_file(path, &crc, &size); + if (b) return psx_bios_activate(b) != 0; + + char buf[512]; + std::snprintf(buf, sizeof(buf), + "That BIOS (%llu bytes, CRC32 %08X) is not an image this build was " + "compiled from.\n\nThis build accepts: %s\n\nThe compiled-in code " + "would execute against mismatched data and crash.", + (unsigned long long)size, crc, bios_accepted_images().c_str()); + launcher_warning("BIOS Mismatch", buf); + return false; +} - std::filesystem::path cached = read_cached_path(argv0, "bios.cfg"); - if (!cached.empty() && std::filesystem::exists(cached) && - validate_bios_for_launch(cached)) { - return cached; - } +static std::filesystem::path resolve_bios_path(const char* requested, const char* argv0); - /* Interactive pick. Be explicit about WHICH of the two user-supplied - * files is being requested — first-time users see two pickers in a - * row and the difference between "BIOS" and "disc image" is not - * obvious to non-technical players. */ +/* Resolve which BIOS image file to load, and activate its backend. + * + * Policy (docs/BIOS_SELECTION.md): + * no explicit player choice -> bundled OpenBIOS, silently + * explicit choice, matching -> that image + * explicit choice, mismatched-> explained; falls back to OpenBIOS if allowed + * openbios disabled for this title -> a retail image is required + * + * "Explicit" means --bios or a remembered launcher/settings pick. Finding a + * file on disk deliberately does NOT count: discovery used to adopt whatever + * happened to sit near the executable, so two players with the same build + * could end up on different BIOSes. + */ +static std::filesystem::path resolve_bios_for_runtime(const char* requested, + const char* argv0, + bool requested_is_explicit) { + const bool openbios_allowed = s_openbios_allowed; + const PsxBiosBackend* bundled = psx_bios_bundled(); + + /* 1. An explicit choice: --bios, else a remembered pick. */ + std::filesystem::path chosen; + if (requested_is_explicit && requested && requested[0]) { + chosen = resolve_bios_path(requested, argv0); + } else { + std::filesystem::path cached = read_cached_path(argv0, "bios.cfg"); + if (!cached.empty() && std::filesystem::exists(cached)) chosen = cached; + } + if (!chosen.empty() && std::filesystem::exists(chosen)) { + if (validate_bios_for_launch(chosen)) return chosen; /* activates it */ + if (!(openbios_allowed && bundled)) return {}; + } + + /* 2. No usable explicit choice -> bundled OpenBIOS, if this title allows it. */ + if (openbios_allowed && bundled) { + std::filesystem::path img = resolve_bios_path( + s_bundled_bios_rel.empty() ? nullptr : s_bundled_bios_rel.c_str(), argv0); + if (!img.empty() && std::filesystem::exists(img) && + bios_backend_for_file(img, nullptr, nullptr) == bundled && + psx_bios_activate(bundled)) { + return img; + } + launcher_warning("Bundled BIOS Missing", + std::string("This build ships its own BIOS (") + + bundled->image->image_id + "), but the bundled image is missing " + "or does not match.\n\nExpected next to the executable:\n" + + (s_bundled_bios_rel.empty() ? "(default path)" : s_bundled_bios_rel) + + "\n\nReinstall or rebuild."); + return {}; + } + + /* 3. This title requires a retail BIOS: ask for one. */ + const std::string accepted = bios_accepted_images(); launcher_info((s_picker_game_name + " — PlayStation BIOS needed").c_str(), - s_picker_game_name + " does not include any Sony or game files.\n\n" + s_picker_game_name + " requires a PlayStation BIOS.\n\n" "Step 1 of 2 — PlayStation BIOS\n\n" - "In the next window, select your PlayStation BIOS dump. The file is " - "usually named SCPH1001.BIN and is exactly 512 KB. You must dump it " - "from your own console or otherwise legally obtain it.\n\n" + "In the next window, select your PlayStation BIOS dump. This build " + "requires the exact image it was compiled from: " + accepted + ". " + "Usually named SCPH1001.BIN and exactly 512 KB. Dump from your own " + "console or otherwise legally obtain it.\n\n" "(This is NOT the game disc — that is asked for next.)"); std::string bios_title = - s_picker_game_name + " — Step 1 of 2: select PlayStation BIOS (SCPH1001.BIN)"; + s_picker_game_name + " — Step 1 of 2: select PlayStation BIOS (" + + accepted + ")"; for (;;) { std::filesystem::path picked; if (!pick_runtime_file( @@ -1508,6 +1605,7 @@ static std::filesystem::path resolve_disc_for_runtime(const std::filesystem::pat static std::filesystem::path resolve_bios_path(const char* requested, const char* argv0) { namespace fs = std::filesystem; std::error_code ec; + if (!requested || !requested[0]) return {}; fs::path p(requested); if (fs::exists(p, ec)) { fs::path abs = fs::absolute(p, ec); @@ -1591,8 +1689,8 @@ static void shutdown_runtime(void) { overlay_capture_wait_pending(); overlay_capture_write_json(); if (sdl_audio_device) { - SDL_ClearQueuedAudio(sdl_audio_device); - SDL_CloseAudioDevice(sdl_audio_device); /* stops the pull callback */ + psx_sdl_audio_clear(sdl_audio_device); + psx_sdl_audio_close(sdl_audio_device); /* stops the pull callback */ sdl_audio_device = 0; } if (s_drc_ready) { rab_free(&s_drc); s_drc_ready = false; } @@ -1607,8 +1705,8 @@ static void teardown_game_session_keep_lobby(void) { psx_netplay_shutdown(); memcard_flush_all(); if (sdl_audio_device) { - SDL_ClearQueuedAudio(sdl_audio_device); - SDL_CloseAudioDevice(sdl_audio_device); + psx_sdl_audio_clear(sdl_audio_device); + psx_sdl_audio_close(sdl_audio_device); sdl_audio_device = 0; } if (s_drc_ready) { rab_free(&s_drc); s_drc_ready = false; } @@ -1668,7 +1766,7 @@ static void sdl_audio_pump(bool discard_output = false) { * means the device was silence-filling since the last pump = a gap. * Only meaningful after the first audio has been queued. */ const uint32_t max_queue_bytes = 44100u * bytes_per_frame / 5u; - queued = SDL_GetQueuedAudioSize(sdl_audio_device); + queued = psx_sdl_audio_queued_size(sdl_audio_device); if (queued == 0 && had_audio) { g_legacy_underruns++; audio_trace_event(AUDIO_EV_UNDERRUN, 0, 0); @@ -1717,7 +1815,7 @@ static void sdl_audio_pump(bool discard_output = false) { cycle_carry = 0; g_audio_cycle_resync = 0; if (legacy) - SDL_ClearQueuedAudio(sdl_audio_device); + psx_sdl_audio_clear(sdl_audio_device); else g_audio_unmute_resync = 1; /* skip mute-drain underrun reports */ return; @@ -1756,17 +1854,16 @@ static void sdl_audio_pump(bool discard_output = false) { if (legacy) { /* T3 tap: the exact post-fade bytes handed to the host audio queue. */ audio_trace_pcm(AUDIO_TAP_HOST, sdl_audio_buf, frames); - SDL_QueueAudio(sdl_audio_device, sdl_audio_buf, - (uint32_t)frames * bytes_per_frame); + psx_sdl_audio_queue(sdl_audio_device, sdl_audio_buf, (uint32_t)frames * bytes_per_frame); had_audio = 1; } else { /* Hand to the bridge (band-limited resample + DRC) instead of * SDL_QueueAudio. Lock guards the SPSC ring against the pull callback. * The T3 tap moves to sdl_drc_callback: what the device actually * receives is the bridge's device-rate output, not this buffer. */ - SDL_LockAudioDevice(sdl_audio_device); + psx_sdl_audio_lock(sdl_audio_device); rab_push(&s_drc, sdl_audio_buf, frames); - SDL_UnlockAudioDevice(sdl_audio_device); + psx_sdl_audio_unlock(sdl_audio_device); } } @@ -1793,7 +1890,7 @@ extern "C" int psx_audio_out_stats(double *fill_ms, uint64_t *underruns, *host_rate = g_audio_host_rate; if (*legacy || !s_drc_ready) { *fill_ms = sdl_audio_device - ? (double)SDL_GetQueuedAudioSize(sdl_audio_device) + ? (double)psx_sdl_audio_queued_size(sdl_audio_device) / (44100.0 * 4.0) * 1000.0 : 0.0; *underruns = g_legacy_underruns; @@ -2148,12 +2245,11 @@ static void sdl_audio_update(int hard_mute_active, int turbo_sink_active) { audio_trace_event(AUDIO_EV_MUTE, (uint32_t)tail, 0); if (audio_legacy_mode()) { audio_trace_pcm(AUDIO_TAP_HOST, sdl_audio_buf, tail); - SDL_QueueAudio(sdl_audio_device, sdl_audio_buf, - (uint32_t)tail * sizeof(int16_t) * 2u); + psx_sdl_audio_queue(sdl_audio_device, sdl_audio_buf, (uint32_t)tail * sizeof(int16_t) * 2u); } else if (s_drc_ready) { - SDL_LockAudioDevice(sdl_audio_device); + psx_sdl_audio_lock(sdl_audio_device); rab_push(&s_drc, sdl_audio_buf, tail); - SDL_UnlockAudioDevice(sdl_audio_device); + psx_sdl_audio_unlock(sdl_audio_device); } muted = 1; } @@ -3252,16 +3348,23 @@ static void netplay_barrier_admit(int override) { netplay_soft_exit("netplay_peer_disconnect"); if (psx_return_to_lobby_requested()) return; } + /* Staged .pst rejected (stale codegen / BIOS / missing) — do not wait + * out the 90s load barrier with stall=load_apply_done. */ + if (psx_netplay_consume_load_apply_failed()) { + netplay_soft_exit("netplay_load_failed"); + if (psx_return_to_lobby_requested()) return; + } /* Mutual INPUT/CONFIRM stall still refreshes last_peer_rx — detect * "no sim progress" separately (common rematch + TURN loss mode). - * Load probe/xfer/apply/ready uses a longer budget (TURN + 1.4MB). */ + * Save/load/memcard probe+chunk xfer uses a longer budget (TURN + + * ~1.4MB .pst). The old 20s admit timeout killed SAVE mid-transfer. */ if (psx_netplay_in_load_barrier() && now_ms - barrier_t0 >= 90000u) { char stall[96]; uint32_t sim = 0; int lead = 0; psx_netplay_admit_wait_info(stall, sizeof(stall), &sim, &lead); std::fprintf(stderr, - "psxrecomp: netplay load barrier timeout sim=%u " + "psxrecomp: netplay state barrier timeout sim=%u " "stall=%s lead=%d — returning to lobby\n", (unsigned)sim, stall[0] ? stall : "?", lead); netplay_soft_exit("netplay_load_stall"); @@ -3337,9 +3440,16 @@ static void netplay_barrier_admit(int override) { netplay_soft_exit("sdl_window_close"); if (psx_return_to_lobby_requested()) return; } - if (ev.type == SDL_KEYDOWN && ev.key.keysym.sym == SDLK_ESCAPE) { - netplay_soft_exit("netplay_barrier_escape"); - if (psx_return_to_lobby_requested()) return; + if (ev.type == SDL_KEYDOWN) { +#if defined(PSX_SDL3) + const SDL_Keycode key = ev.key.key; +#else + const SDL_Keycode key = ev.key.keysym.sym; +#endif + if (key == SDLK_ESCAPE) { + netplay_soft_exit("netplay_barrier_escape"); + if (psx_return_to_lobby_requested()) return; + } } if (ev.type == SDL_CONTROLLERDEVICEADDED || ev.type == SDL_CONTROLLERDEVICEREMOVED) { @@ -3587,18 +3697,20 @@ static void depth24_cutover_tick(int depth24) { } /* Depth24 FMV: CRTC width stays full (e.g. MotK 512) while MDEC uploads may - * not cover the right side yet — cutover flashes a large colorful junk block - * when leftover VRAM is read as RGB888. Present width is never shrunk - * (cropping caused a flickering black pillar); black-fill uncovered columns. + * not cover the right side yet — leftover VRAM read as RGB888 flashes junk. + * Present width is never shrunk; black-fill only the trailing uncovered cols. + * + * Match origin/master's span policy: when upload span is unknown (lim==0), + * only blank the last ~8 columns. Blanking [0..w) on lim==0 (an older tip + * path) turned MotK FMV into a permanent black screen after GP1(07h) hold + * reset the span — hold ticks skip Swap, then the next present saw lim=0 + * and wiped the whole frame every vblank. * - * 0) Cutover hold: full-frame black for the first presents after an MDEC gap - * 1) Upload-span blank: [gpu_depth24_rgb_limit .. w) - * 2) Chroma fringe: if span reports full, still black the last ~8 cols when - * dense chroma junk is present (MotK crawl). Do NOT replicate the last - * good column — that stretched a tinted edge into an 8-wide streak. */ + * Optional short cutover blank (tip): full-frame black for 1–2 presents when + * MDEC returns after an idle gap, hiding one-frame transitional junk. */ static void depth24_fix_trailing_margin(uint32_t *buf, uint32_t w, uint32_t h, uint32_t display_x) { - if (!buf || w == 0u || h == 0u) return; + if (!buf || w < 8u || h == 0u) return; if (s_d24_cutover_blank > 0) { s_d24_cutover_blank--; @@ -3608,37 +3720,14 @@ static void depth24_fix_trailing_margin(uint32_t *buf, uint32_t w, uint32_t h, return; } - uint32_t good = gpu_depth24_rgb_limit(display_x, w); - if (good > w) good = w; - if (good < w) { - for (uint32_t y = 0; y < h; y++) { - for (uint32_t x = good; x < w; x++) - buf[y * w + x] = 0xFF000000u; - } - return; /* span blank already covered the junk region */ - } - - if (w < 24u) return; - const uint32_t margin = 8u; - const uint32_t edge = w - margin; - const uint32_t total = margin * h; - uint32_t junk_px = 0; - for (uint32_t x = edge; x < w; x++) { - for (uint32_t y = 0; y < h; y++) { - uint32_t p = buf[y * w + x]; - int r = (int)((p >> 16) & 255u); - int g = (int)((p >> 8) & 255u); - int b = (int)(p & 255u); - int m = (r + g + b) / 3; - int ch = (r > m ? r - m : m - r) + (g > m ? g - m : m - g) + - (b > m ? b - m : m - b); - if (ch > 40) junk_px++; - } - } - /* ~12% of margin texels — sparse stars stay; dense chroma fringe cleans. */ - if (total == 0u || junk_px * 100u < total * 12u) return; + /* Default: last 8 columns. If the upload span is known and ends earlier + * inside that margin, start blanking from the span edge instead. */ + uint32_t start = w - 8u; + uint32_t lim = gpu_depth24_rgb_limit(display_x, w); + if (lim > 0u && lim < w && lim < start) + start = lim; for (uint32_t y = 0; y < h; y++) { - for (uint32_t x = edge; x < w; x++) + for (uint32_t x = start; x < w; x++) buf[y * w + x] = 0xFF000000u; } } @@ -3766,23 +3855,33 @@ static void sdl_vblank_present(void) { } else if (ev.type == SDL_CONTROLLERDEVICEREMOVED) { bool ours = false; for (int s = 0; s < PSX_MAX_PLAYERS; s++) { +#if defined(PSX_SDL3) + if (ev.gdevice.which == g_players[s].instance) { ours = true; break; } +#else if (ev.cdevice.which == g_players[s].instance) { ours = true; break; } +#endif } if (ours) { close_controller(); refresh_player_devices(); } } else if (ev.type == SDL_KEYDOWN) { +#if defined(PSX_SDL3) + const SDL_Keymod mod = ev.key.mod; + const SDL_Keycode key = ev.key.key; +#else const Uint16 mod = ev.key.keysym.mod; - if (ev.key.keysym.sym == SDLK_ESCAPE && psx_netplay_active()) { + const SDL_Keycode key = ev.key.keysym.sym; +#endif + if (key == SDLK_ESCAPE && psx_netplay_active()) { netplay_soft_exit("netplay_escape"); return; } /* Save states: Shift+F1-F12 = save slot 0-11, F1-F12 = load. * (F11 is a save slot per the user's spec, so fullscreen is * Alt+Enter / Cmd+Ctrl+F only — no F11.) */ - if (ev.key.keysym.sym >= SDLK_F1 && ev.key.keysym.sym <= SDLK_F12) { - int slot = (int)(ev.key.keysym.sym - SDLK_F1); /* 0..11 */ + if (key >= SDLK_F1 && key <= SDLK_F12) { + int slot = (int)(key - SDLK_F1); /* 0..11 */ if (psx_netplay_active()) { /* Match host only — guest F-keys must not initiate. */ if (!psx_netplay_is_host()) { @@ -3798,7 +3897,7 @@ static void sdl_vblank_present(void) { savestate_request_load(slot); } } - else if (ev.key.keysym.sym == SDLK_c && (mod & KMOD_CTRL)) { + else if (key == SDLK_c && (mod & KMOD_CTRL)) { std::fprintf(stdout, "[DEBUG] Forzando reinserción de CD...\n"); debug_force_cd_reinsert(); } @@ -3810,8 +3909,8 @@ static void sdl_vblank_present(void) { * set in both SDL_WINDOW_FULLSCREEN and * SDL_WINDOW_FULLSCREEN_DESKTOP, so testing just that bit * detects "currently fullscreen, either mode". */ - else if ((ev.key.keysym.sym == SDLK_RETURN && (mod & KMOD_ALT)) || - (ev.key.keysym.sym == SDLK_f && (mod & (KMOD_GUI | KMOD_CTRL)))) { + else if ((key == SDLK_RETURN && (mod & KMOD_ALT)) || + (key == SDLK_f && (mod & (KMOD_GUI | KMOD_CTRL)))) { Uint32 is_fs = SDL_GetWindowFlags(sdl_window) & SDL_WINDOW_FULLSCREEN; if (is_fs) { @@ -4095,6 +4194,14 @@ static void sdl_vblank_present(void) { /* Mod hooks. Run after all normal input sampling. */ mod_call_frame_hooks(); + /* Depth24 GP1(07h) retarget (MotK intro→crawl): keep the prior Swap for a + * few vblanks so stale trailing VRAM never flashes on the right edge. + * Must tick every present — gpu.c arms s_d24_present_hold and also + * freezes upload-span tracking while hold > 0; without this call the hold + * sticks and depth24_fix_trailing_margin blanks the whole FMV forever. */ + if (gpu_depth24_present_hold_tick()) + return; + /* Engage widescreen at game entry: BIOS boot stays authentic 4:3. */ if (!g_ws_engaged) { extern int fntrace_is_game_started(void); @@ -4436,8 +4543,20 @@ namespace { const char* g_lnch_argv0 = nullptr; int ae_bios_verify(const char* bios_path, RecompLauncherCBiosVerify* out) { - if (!bios_path || !bios_path[0] || !out) return 0; + if (!out) return 0; std::memset(out, 0, sizeof(*out)); + /* Empty path = use bundled OpenBIOS when this title allows it. */ + if (!bios_path || !bios_path[0]) { + if (s_openbios_allowed && psx_bios_bundled()) { + out->ok = 1; + std::snprintf(out->detail, sizeof(out->detail), + "Using bundled OpenBIOS."); + return 1; + } + std::snprintf(out->detail, sizeof(out->detail), + "PlayStation BIOS required (SCPH1001.BIN)."); + return 1; + } std::ifstream f(bios_path, std::ios::binary | std::ios::ate); if (!f.is_open()) { std::snprintf(out->detail, sizeof(out->detail), "BIOS file not found."); @@ -6639,6 +6758,10 @@ int main(int argc, char** argv) { const char* game_config_path = nullptr; const char* disc_override_path = nullptr; bool bios_from_cli = false; /* CLI --bios/positional wins over settings.toml */ + /* Did the PLAYER choose this BIOS (CLI or settings), as opposed to it + * being the compile-time default? Only a real choice overrides the + * bundled OpenBIOS — see docs/BIOS_SELECTION.md. */ + bool bios_explicit = false; /* Launcher overrides (mirrors snesrecomp): --launcher forces the GUI back on * even when [launcher] skip_launcher = true is set; --no-launcher (and the * PSX_NO_LAUNCHER env) forces it off. --launcher wins if both are given. */ @@ -6679,6 +6802,7 @@ int main(int argc, char** argv) { if (std::strcmp(argv[i], "--bios") == 0 && i + 1 < argc) { bios_path = argv[++i]; bios_from_cli = true; + bios_explicit = true; } else if (std::strcmp(argv[i], "--game") == 0 && i + 1 < argc) { game_config_path = argv[++i]; } else if (std::strcmp(argv[i], "--disc") == 0 && i + 1 < argc) { @@ -6729,6 +6853,7 @@ int main(int argc, char** argv) { if (!bios_from_cli) { bios_path = argv[i]; bios_from_cli = true; + bios_explicit = true; } else { std::fprintf(stderr, "psxrecomp: ignoring unexpected positional argument after BIOS selection: %s\n", @@ -6832,6 +6957,7 @@ std::string player_device[PSX_MAX_PLAYERS]; bool deferred_overlay_cache = false; std::filesystem::path deferred_overlay_project_root; std::vector deferred_overlay_native_block; + uint32_t deferred_overlay_config_hash = 0; std::string deferred_overlay_backend; bool deferred_has_overlay_ac = false; std::string deferred_overlay_ac; @@ -7038,6 +7164,9 @@ std::string player_device[PSX_MAX_PLAYERS]; fast_boot = gc.runtime.fast_boot; bios_hle = gc.runtime.bios_hle; bios_hle_keep_intro = gc.runtime.bios_hle_keep_intro; + /* Developer compatibility finding, applied before BIOS selection. + * Not exposed to settings.toml on purpose — see BIOS_SELECTION.md. */ + s_openbios_allowed = gc.runtime.openbios; /* Let the dispatch layer distinguish "dirty because text was * loaded" from "diverged because runtime wrote different code over * the original EXE image". Packed/self-modifying games can rewrite @@ -7073,6 +7202,8 @@ std::string player_device[PSX_MAX_PLAYERS]; deferred_overlay_cache = true; deferred_overlay_project_root = gc.project_root; deferred_overlay_native_block = gc.runtime.overlay_native_block; + deferred_overlay_config_hash = + PSXRecompV4::overlay_codegen_config_hash(gc); deferred_overlay_backend = gc.runtime.overlay_backend; deferred_has_overlay_ac = gc.runtime.has_overlay_autocompile_cmd; deferred_overlay_ac = gc.runtime.overlay_autocompile_cmd; @@ -7166,9 +7297,10 @@ std::string player_device[PSX_MAX_PLAYERS]; g_video_aspect_den = us.aspect_den; } if (us.has_spu_hq) g_audio_spu_hq = us.spu_hq; - if (us.has_bios_path && !bios_from_cli) { + if (us.has_bios_path && !bios_from_cli && !us.bios_path.empty()) { settings_bios_storage = us.bios_path.string(); bios_path = settings_bios_storage.c_str(); + bios_explicit = true; } if (us.has_disc_path && !disc_override_path) resolved_disc = normalize_disc_path_for_launch(us.disc_path); @@ -7353,7 +7485,8 @@ std::string player_device[PSX_MAX_PLAYERS]; capture_persist_dir.empty() ? nullptr : capture_persist_dir.c_str(), game_id.c_str()); - overlay_loader_init(cache_dir.c_str(), game_id.c_str()); + overlay_loader_init(cache_dir.c_str(), game_id.c_str(), + deferred_overlay_config_hash); for (uint32_t addr : deferred_overlay_native_block) { overlay_loader_native_block_add(addr); } @@ -7499,7 +7632,10 @@ std::string player_device[PSX_MAX_PLAYERS]; seed.netplay_lobby_url = g_lnch_lobby_url; seed.has_netplay_lobby_url = true; } - if (bios_path && bios_path[0]) { seed.bios_path = bios_path; seed.has_bios_path = true; } + if (bios_explicit && bios_path && bios_path[0]) { + seed.bios_path = bios_path; + seed.has_bios_path = true; + } if (!resolved_disc.empty()) { seed.disc_path = resolved_disc; seed.has_disc_path = true; } seed.memcard_dir = memcard_dir; seed.has_memcard_dir = true; seed.memcard1_enabled = memcard1_enabled; seed.has_memcard1_enabled = true; @@ -7701,11 +7837,14 @@ std::string player_device[PSX_MAX_PLAYERS]; * the setup wizard inside recomp-ui (cross-platform file pickers). */ { bool bios_ok = false; + RecompLauncherCBiosVerify bv{}; if (ls.bios_path[0]) { - RecompLauncherCBiosVerify bv{}; if (ae_bios_verify(ls.bios_path, &bv) && bv.ok) bios_ok = true; else ls.bios_path[0] = '\0'; } + if (!bios_ok) { + if (ae_bios_verify("", &bv) && bv.ok) bios_ok = true; + } bool disc_ok = false; if (!rui_initial_disc.empty()) { std::error_code ec; @@ -7812,6 +7951,9 @@ std::string player_device[PSX_MAX_PLAYERS]; if (ls.bios_path[0]) { seed.bios_path = ls.bios_path; seed.has_bios_path = true; + } else { + seed.bios_path.clear(); + seed.has_bios_path = false; } /* Memory-card slots: enable flags + any Browse/New paths. */ seed.memcard1_enabled = ls.memcard_enabled[0] != 0; seed.has_memcard1_enabled = true; @@ -7901,7 +8043,13 @@ std::string player_device[PSX_MAX_PLAYERS]; if (seed.has_bios_path) { settings_bios_storage = seed.bios_path.string(); bios_path = settings_bios_storage.c_str(); + bios_explicit = true; write_cached_path(argv[0], "bios.cfg", seed.bios_path); + } else if (!bios_from_cli) { + /* Cleared to bundled OpenBIOS — drop any cached retail pick. */ + bios_explicit = false; + std::error_code ec; + std::filesystem::remove(sidecar_cfg_path(argv[0], "bios.cfg"), ec); } if (seed.has_disc_path) { seed.disc_path = normalize_disc_path_for_launch(seed.disc_path); @@ -7966,7 +8114,8 @@ std::string player_device[PSX_MAX_PLAYERS]; memcard2_path.clear(); } - std::filesystem::path resolved_bios = resolve_bios_for_runtime(bios_path, argv[0]); + std::filesystem::path resolved_bios = + resolve_bios_for_runtime(bios_path, argv[0], bios_explicit); if (resolved_bios.empty()) { std::fprintf(stderr, "psxrecomp: no BIOS selected; exiting.\n"); return 1; @@ -8219,18 +8368,17 @@ std::string player_device[PSX_MAX_PLAYERS]; #ifndef PSX_SDL_NO_AUDIO audio_trace_init(); if (SDL_InitSubSystem(SDL_INIT_AUDIO) == 0) { - SDL_AudioSpec want; - SDL_AudioSpec have; - SDL_zero(want); + PsxSdlAudioSpec want = {}; + PsxSdlAudioSpec have = {}; want.freq = 44100; want.format = AUDIO_S16SYS; want.channels = 2; want.samples = 1024; const bool legacy = audio_legacy_mode(); + want.allow_frequency_change = legacy ? 0 : 1; if (!legacy) want.callback = sdl_drc_callback; /* pull model: bridge resamples + DRC */ - sdl_audio_device = SDL_OpenAudioDevice(NULL, 0, &want, &have, - legacy ? 0 : SDL_AUDIO_ALLOW_FREQUENCY_CHANGE); + sdl_audio_device = psx_sdl_audio_open(&want, &have); if (sdl_audio_device) { if (!legacy) { rab_config cfg; rab_config_defaults(&cfg); @@ -8241,7 +8389,7 @@ std::string player_device[PSX_MAX_PLAYERS]; } g_audio_host_rate = have.freq; audio_trace_set_tap_rate(AUDIO_TAP_HOST, (uint32_t)have.freq); - SDL_PauseAudioDevice(sdl_audio_device, 0); + (void)psx_sdl_audio_resume(sdl_audio_device); } } #endif @@ -8314,7 +8462,8 @@ std::string player_device[PSX_MAX_PLAYERS]; * undersized and the wide readback overflows it. */ g_video_scale = gr_scale(); gl_renderer_set_interpolation(g_frame_interpolation, g_host_refresh_hz, - (double)g_frame_interpolation_fps); + (double)g_frame_interpolation_fps, + /*blend_mode*/ 0); } /* Vulkan backend: create the instance/device/swapchain on the * SDL_WINDOW_VULKAN window. On failure, fall back to software (vkb_init diff --git a/runtime/src/mdec.c b/runtime/src/mdec.c index d9b7ae6e5..64526476e 100644 --- a/runtime/src/mdec.c +++ b/runtime/src/mdec.c @@ -1,4 +1,5 @@ #include "mdec.h" +#include "pst_wire.h" #include #include @@ -756,3 +757,140 @@ void mdec_debug_dma_out_end(uint32_t addr, uint32_t words) { (void)addr; trace_event(MDEC_EVT_DMA_OUT_END, words); } + +/* ---- boot_state snapshot (variable-length input/output FIFOs) ------------ */ +#define MDEC_SNAP_VER 1u +#define MDEC_SNAP_INPUT_MAX (4u * 1024u * 1024u) /* halfwords */ +#define MDEC_SNAP_OUTPUT_MAX (8u * 1024u * 1024u) /* bytes */ + +static uint32_t mdec_snap_fixed_bytes(void) { + /* ver + scalars + tables + counts + last_color_age */ + return 4u + /* ver */ + 4u * 14u + /* u32 scalars */ + 1u * 8u + /* u8 flags */ + 64u + 64u + /* y/uv quant */ + 64u * 2u + /* scale i16 */ + 4u + 4u + /* input_count, output_size */ + 8u; /* last_color_age */ +} + +uint32_t mdec_snapshot_bytes(void) { + uint64_t n = (uint64_t)mdec_snap_fixed_bytes() + + (uint64_t)mdec.input_count * 2u + + (uint64_t)mdec.output_size; + if (n > 0xffffffffu) return 0; + return (uint32_t)n; +} + +void mdec_snapshot_write(uint8_t *p) { + PstW w; + uint32_t n = mdec_snapshot_bytes(); + uint64_t age; + if (!p || n == 0) return; + pst_w_init(&w, p, n); + (void)pst_w_u32(&w, MDEC_SNAP_VER); + (void)pst_w_u32(&w, mdec.command); + (void)pst_w_u32(&w, mdec.expected_halfwords); + (void)pst_w_u32(&w, mdec.last_status); + (void)pst_w_u32(&w, mdec.decode_macroblocks); + (void)pst_w_u32(&w, mdec.decode_blocks); + (void)pst_w_u32(&w, mdec.decode_stop_reason); + (void)pst_w_u32(&w, mdec.decode_input_pos); + (void)pst_w_u32(&w, mdec.decode_input_end); + (void)pst_w_u32(&w, mdec.dma_in_words); + (void)pst_w_u32(&w, mdec.dma_out_words); + (void)pst_w_u32(&w, mdec.dma_read_underflows); + (void)pst_w_u32(&w, mdec.output_pos); + (void)pst_w_u32(&w, 0u); /* reserved */ + (void)pst_w_u32(&w, 0u); /* reserved */ + (void)pst_w_u8(&w, mdec.output_bit15); + (void)pst_w_u8(&w, mdec.output_signed); + (void)pst_w_u8(&w, mdec.output_depth); + (void)pst_w_u8(&w, mdec.current_block); + (void)pst_w_u8(&w, mdec.busy); + (void)pst_w_u8(&w, mdec.input_full); + (void)pst_w_u8(&w, mdec.enable_dma_in); + (void)pst_w_u8(&w, mdec.enable_dma_out); + (void)pst_w_bytes(&w, mdec.y_quant, 64u); + (void)pst_w_bytes(&w, mdec.uv_quant, 64u); + for (int i = 0; i < 64; i++) + (void)pst_w_i16(&w, mdec.scale[i]); + (void)pst_w_u32(&w, mdec.input_count); + (void)pst_w_u32(&w, mdec.output_size); + if (s_frame_count >= mdec_last_color_decode_frame) + age = s_frame_count - mdec_last_color_decode_frame; + else + age = 1000ull; + (void)pst_w_u64(&w, age); + for (uint32_t i = 0; i < mdec.input_count; i++) + (void)pst_w_u16(&w, mdec.input ? mdec.input[i] : 0u); + if (mdec.output_size && mdec.output) + (void)pst_w_bytes(&w, mdec.output, mdec.output_size); +} + +int mdec_snapshot_read(const uint8_t *p, uint32_t len) { + PstR r; + uint32_t ver = 0, input_count = 0, output_size = 0, reserved; + uint64_t age = 1000ull; + int16_t s16; + if (!p || len < mdec_snap_fixed_bytes()) return 0; + pst_r_init(&r, p, len); + if (!pst_r_u32(&r, &ver) || ver != MDEC_SNAP_VER) return 0; + if (!pst_r_u32(&r, &mdec.command) || + !pst_r_u32(&r, &mdec.expected_halfwords) || + !pst_r_u32(&r, &mdec.last_status) || + !pst_r_u32(&r, &mdec.decode_macroblocks) || + !pst_r_u32(&r, &mdec.decode_blocks) || + !pst_r_u32(&r, &mdec.decode_stop_reason) || + !pst_r_u32(&r, &mdec.decode_input_pos) || + !pst_r_u32(&r, &mdec.decode_input_end) || + !pst_r_u32(&r, &mdec.dma_in_words) || + !pst_r_u32(&r, &mdec.dma_out_words) || + !pst_r_u32(&r, &mdec.dma_read_underflows) || + !pst_r_u32(&r, &mdec.output_pos) || + !pst_r_u32(&r, &reserved) || + !pst_r_u32(&r, &reserved)) + return 0; + if (!pst_r_u8(&r, &mdec.output_bit15) || + !pst_r_u8(&r, &mdec.output_signed) || + !pst_r_u8(&r, &mdec.output_depth) || + !pst_r_u8(&r, &mdec.current_block) || + !pst_r_u8(&r, &mdec.busy) || + !pst_r_u8(&r, &mdec.input_full) || + !pst_r_u8(&r, &mdec.enable_dma_in) || + !pst_r_u8(&r, &mdec.enable_dma_out)) + return 0; + if (!pst_r_bytes(&r, mdec.y_quant, 64u) || + !pst_r_bytes(&r, mdec.uv_quant, 64u)) + return 0; + for (int i = 0; i < 64; i++) { + if (!pst_r_i16(&r, &s16)) return 0; + mdec.scale[i] = s16; + } + if (!pst_r_u32(&r, &input_count) || !pst_r_u32(&r, &output_size) || + !pst_r_u64(&r, &age)) + return 0; + if (input_count > MDEC_SNAP_INPUT_MAX || output_size > MDEC_SNAP_OUTPUT_MAX) + return 0; + if (mdec.output_pos > output_size) return 0; + if ((size_t)(r.end - r.p) < + (size_t)input_count * 2u + (size_t)output_size) + return 0; + if (!ensure_input_capacity(input_count ? input_count : 1u)) return 0; + if (!ensure_output_capacity(output_size ? output_size : 1u)) return 0; + mdec.input_count = input_count; + mdec.output_size = output_size; + for (uint32_t i = 0; i < input_count; i++) { + uint16_t hw; + if (!pst_r_u16(&r, &hw)) return 0; + mdec.input[i] = hw; + } + if (output_size && !pst_r_bytes(&r, mdec.output, output_size)) + return 0; + if (age > 100000ull) age = 100000ull; + if (age >= s_frame_count) + mdec_last_color_decode_frame = 0; + else + mdec_last_color_decode_frame = s_frame_count - age; + return 1; +} diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 0adc09adb..5df9a0ec8 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -763,6 +763,17 @@ int provider_commit(void*, const char* image_path) { return 1; } +int provider_commit_netplay(void*, const char* image_path) { + (void)image_path; + std::string error; + if (!mod_runtime_clear_for_netplay(&error)) { + set_error(error); + return 0; + } + state().error.clear(); + return 1; +} + const char* provider_error(void*) { return state().error.c_str(); } @@ -790,6 +801,9 @@ RecompLauncherCModProvider provider = { provider_feature_set_option, provider_diagnostic_count, provider_diagnostic_get, + nullptr, /* archive_extension — PSX defaults */ + nullptr, /* archive_description */ + provider_commit_netplay, }; #endif @@ -836,6 +850,28 @@ bool mod_runtime_initialize(const std::filesystem::path& root, return true; } +bool mod_runtime_clear_for_netplay(std::string* error) { + RuntimeMods& s = state(); + if (!s.initialized) { + if (error) error->clear(); + return true; + } + s.plan = {}; + s.validation = {}; + s.raw_disc_index.clear(); + s.user_disc_index.clear(); + s.raw_overlay_index.clear(); + s.user_overlay_index.clear(); + s.effective_disc_path.clear(); + s.main_applied = false; + s.disc_enabled = false; + s.disc_guard_failed = false; + s.error.clear(); + if (error) error->clear(); + std::fprintf(stdout, "psxrecomp: mods cleared for netplay (vanilla session)\n"); + return true; +} + bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* error) { RuntimeMods& s = state(); if (!s.initialized) return true; diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index 51ab51e4b..f2910cc8e 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -196,6 +196,7 @@ int psx_netplay_is_host(void) { return 0; } int psx_netplay_request_save(int slot) { (void)slot; return 0; } int psx_netplay_request_load(int slot) { (void)slot; return 0; } int psx_netplay_in_load_barrier(void) { return 0; } +int psx_netplay_consume_load_apply_failed(void) { return 0; } void psx_netplay_pump(void) {} int psx_netplay_poll_admit(void) { return 1; } void psx_netplay_finish_frame(void) {} @@ -260,9 +261,12 @@ typedef struct { int mc_sync_done; int mc_sync_sent; int local_save_staged; + int local_save_acked; /* guest: coord reply already sent */ + uint32_t save_target_tick; /* both peers save during this sim_tick */ int load_applied_local; int load_ready_replied; /* READY exchanged; synced; stay LOAD_READY until admit */ int load_sync_done; /* hard_resync+prime once at mutual ready */ + int load_apply_failed; /* sticky: staged apply rejected — soft-exit */ /* Transport / ICE / diag (MotK online path). */ int use_ice; int ice_has_turn; @@ -322,6 +326,7 @@ static void np_enter_load_ready(int slot); static void np_commit_load_sync(void); static void np_begin_load_apply(int slot); static void np_starv_reset(void); +static void np_maybe_stage_target_save(void); static int np_file_crc(const uint8_t *data, size_t size, uint32_t *crc_out) { @@ -582,24 +587,30 @@ static void np_guest_handle_probe(void) } if (size == 0) { - /* SAVE coordinate local write (admit is not stalled for size==0). */ - if (!g_np.local_save_staged) { - if (savestate_request_save_protocol((int)slot)) { - g_np.local_save_staged = 1; - printf("psxrecomp: netplay guest save slot=%u — writing sandbox…\n", - (unsigned)slot); - fflush(stdout); - } else { - (void)rnet_session_state_probe_reply(g_np.session, 0); - return; - } + /* SAVE coord: crc carries the shared target sim_tick. Both peers + * stage the write when sim reaches that tick (see + * np_maybe_stage_target_save) so CRCs match and skip transfer. */ + if (g_np.xfer != NP_XFER_SAVE_COORD) { + g_np.xfer = NP_XFER_SAVE_COORD; + g_np.xfer_slot = (int)slot; + g_np.save_target_tick = crc; + g_np.local_save_staged = 0; + g_np.local_save_acked = 0; + printf("psxrecomp: netplay guest save slot=%u — armed target " + "sim=%u\n", + (unsigned)slot, (unsigned)crc); + fflush(stdout); } if (savestate_pending()) return; - if (!savestate_slot_exists((int)slot)) return; - g_np.local_save_staged = 0; - (void)rnet_session_state_probe_reply(g_np.session, 1); - printf("psxrecomp: netplay guest save slot=%u — local write done\n", (unsigned)slot); - fflush(stdout); + if (!g_np.local_save_staged || !savestate_slot_exists((int)slot)) return; + if (!g_np.local_save_acked) { + g_np.local_save_acked = 1; + (void)rnet_session_state_probe_reply(g_np.session, 1); + printf("psxrecomp: netplay guest save slot=%u — local write done " + "@ target sim (frozen until hash probe)\n", + (unsigned)slot); + fflush(stdout); + } return; } @@ -613,18 +624,53 @@ static void np_guest_handle_probe(void) { uint32_t local_sz = 0, local_crc = 0; + char reason[192]; match = np_slot_crc((int)slot, &local_sz, &local_crc) && local_sz == size && local_crc == crc; + /* CRC match of a stale .pst (wrong codegen) is not loadable — ask the + * host to transfer. Host also refuses probe start if its own slot is + * stale, so this mainly covers guest-sandbox drift. */ + if (match && op == RNET_STATE_OP_LOAD && + !savestate_slot_compatible((int)slot, reason, sizeof(reason))) { + printf("psxrecomp: netplay guest load slot=%u — hash matched but " + "unloadable (%s); requesting transfer\n", + (unsigned)slot, reason[0] ? reason : "incompatible"); + fflush(stdout); + match = 0; + } (void)rnet_session_state_probe_reply(g_np.session, match); - if (match && op == RNET_STATE_OP_LOAD) { - if (g_np.xfer != NP_XFER_LOAD_APPLYING && g_np.xfer != NP_XFER_LOAD_READY) { - (void)savestate_request_load_protocol((int)slot); - np_begin_load_apply((int)slot); - printf("psxrecomp: netplay guest load slot=%u — hashes match, applying…\n", + if (op == RNET_STATE_OP_SAVE) { + if (match) { + g_np.xfer = NP_XFER_NONE; + printf("psxrecomp: netplay guest save slot=%u — hashes match, " + "skip transfer\n", (unsigned)slot); fflush(stdout); } else { - /* Retransmit of hash probe — already staging/applying. */ + /* Host will chunk the authoritative .pst — stay parked. */ + g_np.xfer = NP_XFER_SAVE_SEND; + g_np.xfer_slot = (int)slot; + } + } else if (op == RNET_STATE_OP_LOAD) { + if (match) { + if (g_np.xfer != NP_XFER_LOAD_APPLYING && + g_np.xfer != NP_XFER_LOAD_READY) { + (void)savestate_request_load_protocol((int)slot); + np_begin_load_apply((int)slot); + printf("psxrecomp: netplay guest load slot=%u — hashes match, " + "applying…\n", + (unsigned)slot); + fflush(stdout); + } + } else { + /* Must mark LOAD_SEND or guest keeps the 20s admit timeout and + * BYEs the host mid-TURN transfer. */ + g_np.xfer = NP_XFER_LOAD_SEND; + g_np.xfer_slot = (int)slot; + printf("psxrecomp: netplay guest load slot=%u — hash miss, " + "waiting for transfer…\n", + (unsigned)slot); + fflush(stdout); } } } @@ -665,8 +711,11 @@ static void np_host_drive_xfer(void) return; case NP_XFER_SAVE_COORD: + /* Host + guest both stage at save_target_tick; wait for local write + * and guest ACK before hashing. */ if (savestate_pending()) return; - if (!savestate_slot_exists(g_np.xfer_slot)) return; + if (!g_np.local_save_staged || !savestate_slot_exists(g_np.xfer_slot)) + return; if (!rnet_session_state_probe_take_reply(g_np.session, &match)) return; rnet_session_state_probe_finish(g_np.session); @@ -874,6 +923,23 @@ static void np_drive_load_barrier(void) return; if (savestate_pending()) return; + if (savestate_take_load_failed()) { + /* Stale/mismatched .pst: do not sit in load_apply_done forever. */ + printf("psxrecomp: netplay load slot=%d — apply failed " + "(incompatible or missing .pst) — aborting barrier\n", + g_np.xfer_slot); + fflush(stdout); + if (g_np.session) + rnet_session_state_finish(g_np.session, 0); + g_np.xfer = NP_XFER_NONE; + g_np.load_applied_local = 0; + g_np.load_ready_replied = 0; + g_np.load_sync_done = 0; + g_np.load_apply_failed = 1; + if (g_np.session) + rnet_session_set_input_send_suppress(g_np.session, 0); + return; + } if (!g_np.load_applied_local && !savestate_take_load_completed()) return; @@ -1622,6 +1688,9 @@ int psx_netplay_is_host(void) int psx_netplay_request_save(int slot) { + uint32_t sim; + uint32_t delay; + uint32_t target; if (!psx_netplay_active() || !rnet_session_is_running(g_np.session)) return 0; if (g_np.local_slot != 0) @@ -1631,17 +1700,24 @@ int psx_netplay_request_save(int slot) if (slot < 0) slot = 0; if (slot >= SAVESTATE_SLOTS) slot = SAVESTATE_SLOTS - 1; - if (!savestate_request_save_protocol(slot)) - return 1; - /* Coord probe (size=0) does not stall admit — both peers must keep - * running until savestate_poll writes the .pst, then hash-probe stalls. - * STATE_* rides the same UDP/relay path as inputs (LAN hub / server - * input relay fan-out). */ - if (rnet_session_state_probe(g_np.session, RNET_STATE_OP_SAVE, (rnet_u8)slot, 0, 0) != 0) + /* Agree a future sim_tick so TURN/coord latency cannot make the host + * write tick T while the guest still writes T+k (CRC miss → transfer). + * crc field of size==0 probe carries the target tick. */ + sim = rnet_session_sim_tick(g_np.session); + delay = (uint32_t)psx_netplay_input_delay(); + if (delay < 1u) delay = 1u; + target = sim + delay + 2u; + if (rnet_session_state_probe(g_np.session, RNET_STATE_OP_SAVE, (rnet_u8)slot, 0, + target) != 0) return 1; g_np.xfer = NP_XFER_SAVE_COORD; g_np.xfer_slot = slot; - printf("psxrecomp: netplay save slot=%d — coordinating local writes…\n", slot); + g_np.save_target_tick = target; + g_np.local_save_staged = 0; + g_np.local_save_acked = 0; + printf("psxrecomp: netplay save slot=%d — coordinating local writes " + "(target sim=%u, now=%u)…\n", + slot, (unsigned)target, (unsigned)sim); fflush(stdout); return 1; } @@ -1649,6 +1725,7 @@ int psx_netplay_request_save(int slot) int psx_netplay_request_load(int slot) { uint32_t size = 0, crc = 0; + char reason[192]; if (!psx_netplay_active() || !rnet_session_is_running(g_np.session)) return 0; if (g_np.local_slot != 0) @@ -1657,6 +1734,13 @@ int psx_netplay_request_load(int slot) return 1; if (slot < 0) slot = 0; if (slot >= SAVESTATE_SLOTS) slot = SAVESTATE_SLOTS - 1; + if (!savestate_slot_compatible(slot, reason, sizeof(reason))) { + printf("psxrecomp: netplay load slot=%d refused — %s " + "(resave with this build: Shift+F%d)\n", + slot, reason[0] ? reason : "incompatible", slot + 1); + fflush(stdout); + return 1; + } if (!np_slot_crc(slot, &size, &crc)) return 1; if (rnet_session_state_probe(g_np.session, RNET_STATE_OP_LOAD, (rnet_u8)slot, size, crc) != 0) @@ -1664,6 +1748,7 @@ int psx_netplay_request_load(int slot) g_np.xfer = NP_XFER_LOAD_PROBE; g_np.xfer_slot = slot; g_np.load_applied_local = 0; + g_np.load_apply_failed = 0; printf("psxrecomp: netplay load slot=%d — hash probe (%u bytes)\n", slot, (unsigned)size); fflush(stdout); return 1; @@ -1673,12 +1758,16 @@ int psx_netplay_in_load_barrier(void) { if (!psx_netplay_active()) return 0; - /* Probe/SEND too: large ICE/TURN transfers can exceed the normal admit - * stall timeout, and FPS/present must stay frozen until mutual ready. */ - return (g_np.xfer == NP_XFER_LOAD_PROBE || g_np.xfer == NP_XFER_LOAD_SEND || - g_np.xfer == NP_XFER_LOAD_APPLYING || g_np.xfer == NP_XFER_LOAD_READY) - ? 1 - : 0; + /* Any save/load/memcard sync phase — TURN chunk xfers of ~1.4MB need the + * 90s budget (20s admit stall was killing SAVE mid-transfer). */ + return (g_np.xfer != NP_XFER_NONE) ? 1 : 0; +} + +int psx_netplay_consume_load_apply_failed(void) +{ + int v = g_np.load_apply_failed; + g_np.load_apply_failed = 0; + return v; } @@ -1936,6 +2025,26 @@ void psx_netplay_diag_tick(void) } } +/* Stage the coord save once sim_tick reaches the agreed target. */ +static void np_maybe_stage_target_save(void) +{ + uint32_t sim; + if (g_np.xfer != NP_XFER_SAVE_COORD || g_np.local_save_staged) + return; + if (!g_np.session || !rnet_session_is_running(g_np.session)) + return; + sim = rnet_session_sim_tick(g_np.session); + if (sim < g_np.save_target_tick) + return; + if (!savestate_request_save_protocol(g_np.xfer_slot)) + return; + g_np.local_save_staged = 1; + printf("psxrecomp: netplay %s save slot=%d — staging @ sim=%u (target=%u)\n", + g_np.local_slot == 0 ? "host" : "guest", g_np.xfer_slot, + (unsigned)sim, (unsigned)g_np.save_target_tick); + fflush(stdout); +} + static void np_pump_session(void) { #if defined(PSX_HAS_LOBBY_CLIENT) @@ -1945,6 +2054,7 @@ static void np_pump_session(void) drain_lobby_signals(); rnet_session_pump(g_np.session); np_guest_handle_probe(); + np_maybe_stage_target_save(); np_apply_ready_state(); np_drive_load_barrier(); np_host_drive_xfer(); @@ -2038,11 +2148,29 @@ int psx_netplay_poll_admit(void) exit_need = np_starv_env_int("PSX_NET_STARVATION_EXIT_FRAMES", PSX_STARVATION_EXIT_DEFAULT); - /* Probe/SEND: state_xfer stalls are expected — do not latch starvation. */ + /* SAVE coord: run admit until both reach save_target_tick and flush the + * staged write. After the local .pst exists, freeze (host also freezes + * unless the guest tip is behind and still needs catch-up admits). */ + if (g_np.xfer == NP_XFER_SAVE_COORD) { + g_starv.enter_run = 0; + g_starv.exit_run = 0; + g_starv.latched = 0; + g_starv.just_cleared = 0; + np_maybe_stage_target_save(); + if (!g_np.local_save_staged || savestate_pending()) + return np_try_admit_gameplay(); + if (g_np.local_slot != 0) + return 0; /* guest saved — wait for hash probe */ + /* Host saved: freeze for same-tick match. If guest is still behind + * the target, keep admitting so it can catch up and write. */ + if (psx_netplay_remote_lead() < 0) + return np_try_admit_gameplay(); + return 0; + } + if (g_np.xfer == NP_XFER_LOAD_PROBE || g_np.xfer == NP_XFER_LOAD_SEND || g_np.xfer == NP_XFER_SAVE_PROBE || g_np.xfer == NP_XFER_SAVE_SEND || - g_np.xfer == NP_XFER_SAVE_COORD || g_np.xfer == NP_XFER_MC_PROBE || - g_np.xfer == NP_XFER_MC_SEND) { + g_np.xfer == NP_XFER_MC_PROBE || g_np.xfer == NP_XFER_MC_SEND) { g_starv.enter_run = 0; g_starv.exit_run = 0; g_starv.latched = 0; @@ -2209,6 +2337,29 @@ void psx_netplay_admit_wait_info(char *stall_out, size_t stall_cap, /* LOAD_READY never calls try_admit, so last_stall stays "ok" — surface * the app barrier phase (+ transfer progress) instead. */ switch (g_np.xfer) { + case NP_XFER_SAVE_COORD: + snprintf(phase, sizeof(phase), "save_coord"); + break; + case NP_XFER_SAVE_PROBE: + snprintf(phase, sizeof(phase), "save_probe"); + break; + case NP_XFER_SAVE_SEND: + if (st.state_bytes_total > 0) + snprintf(phase, sizeof(phase), "save_xfer_%u/%u", + (unsigned)st.state_bytes_acked, (unsigned)st.state_bytes_total); + else + snprintf(phase, sizeof(phase), "save_xfer"); + break; + case NP_XFER_MC_PROBE: + snprintf(phase, sizeof(phase), "mc_probe"); + break; + case NP_XFER_MC_SEND: + if (st.state_bytes_total > 0) + snprintf(phase, sizeof(phase), "mc_xfer_%u/%u", + (unsigned)st.state_bytes_acked, (unsigned)st.state_bytes_total); + else + snprintf(phase, sizeof(phase), "mc_xfer"); + break; case NP_XFER_LOAD_PROBE: snprintf(phase, sizeof(phase), "load_probe"); break; diff --git a/runtime/src/savestate.c b/runtime/src/savestate.c index 667eddee6..0f00f63ac 100644 --- a/runtime/src/savestate.c +++ b/runtime/src/savestate.c @@ -45,6 +45,7 @@ static int s_configured = 0; static int s_save_pending = -1; /* slot, or -1 */ static int s_load_pending = -1; static int s_load_completed = 0; +static int s_load_failed = 0; static uint8_t *s_load_blob = NULL; /* optional in-memory .pst for netplay */ static size_t s_load_blob_len = 0; @@ -137,6 +138,28 @@ int savestate_slot_exists(int slot) { return sz > 0; } +int savestate_slot_compatible(int slot, char* reason, size_t reason_cap) { + uint8_t* data = NULL; + size_t size = 0; + int ok; + if (reason && reason_cap) + reason[0] = '\0'; + if (!s_configured) { + if (reason && reason_cap) + snprintf(reason, reason_cap, "not_configured"); + return 0; + } + if (!savestate_read_slot(slot, &data, &size) || !data) { + if (reason && reason_cap) + snprintf(reason, reason_cap, "missing"); + return 0; + } + ok = boot_state_check_buffer(data, size, s_bios_checksum, s_entry_pc, + reason, reason_cap); + free(data); + return ok; +} + int savestate_read_slot(int slot, uint8_t** data_out, size_t* size_out) { char path[600]; FILE* f; @@ -232,6 +255,8 @@ static int request_load_inner(int slot) { "PSX_HLE_SCHEDULER=0 run cannot load states.\n"); return 0; } + s_load_failed = 0; + s_load_completed = 0; s_load_pending = slot; return 1; } @@ -278,6 +303,8 @@ int savestate_request_load_blob_protocol(const void* data, size_t size) { clear_load_blob(); s_load_blob = copy; s_load_blob_len = size; + s_load_failed = 0; + s_load_completed = 0; s_load_pending = 0; /* non-negative: poll will prefer the blob */ return 1; } @@ -292,6 +319,12 @@ int savestate_take_load_completed(void) { return v; } +int savestate_take_load_failed(void) { + int v = s_load_failed; + s_load_failed = 0; + return v; +} + void savestate_poll(CPUState* cpu, uint32_t resume_pc) { if (s_save_pending < 0 && s_load_pending < 0) return; /* hot path: nothing staged */ @@ -328,16 +361,19 @@ void savestate_poll(CPUState* cpu, uint32_t resume_pc) { fprintf(stderr, "savestate: LOAD FAILED blob (%zu bytes, entry=%08X)\n", blob_len, (unsigned)s_entry_pc); + s_load_failed = 1; } } else if (savestate_slot_path(slot, path, sizeof(path))) { loaded = boot_state_load(path, s_bios_checksum, s_entry_pc, cpu); if (!loaded) { fprintf(stderr, - "savestate: LOAD FAILED slot %d (missing/mismatched) %s\n", + "savestate: LOAD FAILED slot %d %s\n", slot, path); + s_load_failed = 1; } } else { fprintf(stderr, "savestate: LOAD FAILED slot %d (no path)\n", slot); + s_load_failed = 1; } if (loaded) { t_after_boot = savestate_mono_ms();