diff --git a/docs/BIOS_SELECTION.md b/docs/BIOS_SELECTION.md index e3d5bd46..bccf53b9 100644 --- a/docs/BIOS_SELECTION.md +++ b/docs/BIOS_SELECTION.md @@ -135,25 +135,37 @@ netplay guest sandbox remains `/netplay/` (unscoped). ## Netplay lobby settle Online and LAN lobbies advertise a per-peer BIOS offer and freeze a single -session BIOS at host Start (`openbios` or `scph1001`): +session BIOS at host Start (`openbios` or `scph1001`). The token `scph1001` +means "retail", not SCPH-1001: each offer also names the retail image the peer +would boot by its CRC-32 (`retail_crc`), and a retail session carries the CRC +every peer must boot (`session_bios_crc`). The rule lives in +`runtime/src/netplay_bios_settle.c`. -- **Online:** `bios_offer` on `set_ready` → host publishes - `match_caps.session_bios`. -- **LAN:** peers append offer fields on `MOTK3 JOIN`; host broadcasts them on - `MOTK4 UPDATE` and includes the settled token on `MOTK1 START`. +- **Online:** `bios_offer` (with `retail_crc`) on `set_ready` → host publishes + `match_caps.session_bios` and `match_caps.session_bios_crc`. +- **LAN:** peers append offer fields on `MOTK3 JOIN` (the CRC is line 6 of the + tail); host broadcasts the flags on `MOTK4 UPDATE` and puts the settled + token and CRC on `MOTK1 START` (lines 5 and 7). Settle rule (same for both): -- **OpenBIOS** if any seated peer prefers OpenBIOS, or any peer cannot run - SCPH-1001 (no linked retail backend and/or no validated dump), or a peer - sends no offer (legacy client). -- **SCPH-1001** only when every seated peer can run it and nobody selected - OpenBIOS. +- **Retail** only when every seated peer offers a retail image and all the + CRCs are equal, and either the host prefers retail or nobody selected + OpenBIOS. A peer with no offer, no dump or no CRC (an older client) rules + retail out. +- Otherwise **OpenBIOS**, when every peer that sent an offer links it. +- Otherwise retail, if it is possible. +- Otherwise the host **refuses to start** and the launcher says which images + differ ("Players use different BIOS images (SCPH-5552 and SCPH-1001) …"). -Every peer applies that session BIOS before boot. Mixed BIOSes are invalid for -rollback (kernel RAM layout differs). If the session settles to SCPH-1001 but a -peer has no validated dump, that peer **aborts the launch** rather than silently -falling back to OpenBIOS (which would desync immediately). +Every peer applies that session BIOS before boot, and for retail it boots only +a dump whose CRC is the settled one. Mixed BIOSes are invalid for rollback +(kernel RAM layout differs): before this rule an SCPH-5552 peer and an SCPH-1001 +peer both offered `scph1001`, each booted its own image, and the boot digest +never matched. If the session settles to retail but a peer has no matching +dump, that peer **aborts the launch** rather than silently falling back +(which would desync immediately). A session from an older host carries no CRC; +peers then accept any retail dump, as before. Session BIOS is **ephemeral**: it affects only that match’s runtime boot. It does **not** rewrite `bios.cfg`, `settings.toml`, or the launcher Settings BIOS diff --git a/docs/NETPLAY.md b/docs/NETPLAY.md index d9f98ba1..84e88539 100644 --- a/docs/NETPLAY.md +++ b/docs/NETPLAY.md @@ -211,15 +211,28 @@ device. Rollback and delay-sync both carry multitap pad bytes. -**BIOS settle:** each peer advertises a BIOS offer (can run OpenBIOS / -SCPH-1001, and whether OpenBIOS is selected) — online on ready, LAN on JOIN. -At Start the host freezes one session BIOS (`openbios` or `scph1001`) via -`match_caps.session_bios` (online) or the `MOTK1 START` line (LAN). The session -uses OpenBIOS unless every seated peer can run SCPH-1001 and nobody selected -OpenBIOS. Peers that cannot apply a settled SCPH-1001 abort instead of falling +**BIOS settle:** each peer advertises a BIOS offer (can run OpenBIOS, has a +retail dump and that dump's CRC-32, and whether OpenBIOS is selected) — online +on ready, LAN on JOIN. At Start the host freezes one session BIOS (`openbios`, +or `scph1001` = retail with the image's CRC) via `match_caps.session_bios` / +`session_bios_crc` (online) or the `MOTK1 START` lines (LAN). Retail is used +only when every seated peer has the same retail image; otherwise OpenBIOS if +everyone links it; otherwise the host refuses to start and says which images +differ. Peers that cannot apply the settled BIOS abort instead of falling back. That choice boots the match only — it does not change each peer’s saved BIOS preference. See `docs/BIOS_SELECTION.md` (Netplay lobby settle). +**Why a match ended:** a match that ends early returns every peer to the lobby +(`netplay_soft_exit`), and the launcher's status line says why, from +`runtime/src/netplay_exit_reason.c`: the other player left, the shared save +state failed or timed out, the link timed out, the games stopped running in +step, or the consoles started differently. The last one is the rollback boot +digest: when both peers' tick-0 digests are known and differ, and the same pair +holds for 3 s (`NETPLAY_BOOT_MISMATCH_GRACE_MS`), the match ends at once instead +of waiting out the 20 s admit-stall watchdog. A peer that receives the other +side's BYE while it sees the same mismatch reports the mismatch, not a +disconnect. Window close and Escape end the match with no message. + --- ## Disc identity for multi-track titles diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index fb19eeab..394a4cf8 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -23,6 +23,18 @@ psxrecomp_add_runtime_target(psx-runtime # (docs/ENHANCEMENTS.md G1.10). Titles get their _pgxp the same way. if(BUILD_TESTING) + # Netplay: which BIOS a match boots, and its wire helpers. + add_executable(netplay_bios_settle_test + tests/test_netplay_bios_settle.c + src/netplay_bios_settle.c) + target_include_directories(netplay_bios_settle_test PRIVATE include) + add_test(NAME netplay_bios_settle_test COMMAND netplay_bios_settle_test) + # Netplay: why a match ended, and when a boot-digest mismatch is final. + add_executable(netplay_exit_reason_test + tests/test_netplay_exit_reason.c + src/netplay_exit_reason.c) + target_include_directories(netplay_exit_reason_test PRIVATE include) + add_test(NAME netplay_exit_reason_test COMMAND netplay_exit_reason_test) add_executable(ws_scene_hold_test tests/test_ws_scene_hold.c) target_include_directories(ws_scene_hold_test PRIVATE include) add_test(NAME ws_scene_hold_test COMMAND ws_scene_hold_test) @@ -762,7 +774,8 @@ if(BUILD_TESTING) # is needed and none can drift from what the runtime links. target_sources(lobby_chat_ring_test PRIVATE "${PSXRECOMP_ROOT}/runtime/src/lobby_ws/rnet_ws.c" - "${PSXRECOMP_ROOT}/runtime/src/lobby_ws/rnet_sha1.c") + "${PSXRECOMP_ROOT}/runtime/src/lobby_ws/rnet_sha1.c" + "${PSXRECOMP_ROOT}/runtime/src/netplay_bios_settle.c") target_link_libraries(lobby_chat_ring_test PRIVATE recomp_net) add_test(NAME lobby_chat_ring_test COMMAND lobby_chat_ring_test) endif() diff --git a/runtime/include/netplay_bios_settle.h b/runtime/include/netplay_bios_settle.h new file mode 100644 index 00000000..e59d1d63 --- /dev/null +++ b/runtime/include/netplay_bios_settle.h @@ -0,0 +1,83 @@ +/* netplay_bios_settle.h — which BIOS every peer boots for a netplay match. + * + * Each seat offers what it can boot: the bundled OpenBIOS and/or one retail + * image, named by the CRC-32 of the file it would actually load. The host + * settles one BIOS for the room from those offers and sends the result to + * every peer, which then boots only that image. + * + * The wire token for retail is still "scph1001" so older peers read it, but it + * means "the retail image named by retail_crc", not SCPH-1001. Before this rule + * the token was the whole identity: an SCPH-5552 peer and an SCPH-1001 peer + * both offered "scph1001", each booted its own dump, and the rollback boot + * digest never matched. + * + * Rule (netplay_bios_settle): + * 1. Retail is possible only when every seat offered a retail image and all + * the CRCs are equal. A seat with no offer, no dump, or no CRC (an older + * client) makes retail impossible. + * 2. Retail wins when it is possible and the host prefers it, or when nobody + * prefers OpenBIOS. + * 3. Otherwise OpenBIOS, when every seat that sent an offer links it. A seat + * with no offer is an older client and is assumed to link it, as before. + * 4. Otherwise retail, if it is possible. + * 5. Otherwise there is no BIOS every seat can boot: the host must not start. + */ +#ifndef NETPLAY_BIOS_SETTLE_H +#define NETPLAY_BIOS_SETTLE_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct NetplayBiosSeat { + int offered; /* 0 = no offer received (older client / not ready) */ + int can_openbios; /* links the bundled OpenBIOS backend */ + int can_retail; /* has a retail dump a linked backend accepts */ + int prefer_openbios; /* the player picked OpenBIOS */ + uint32_t retail_crc; /* CRC-32 of the retail file it would boot; 0 = not sent */ + int is_host; +} NetplayBiosSeat; + +enum { + NETPLAY_BIOS_OPENBIOS = 0, + NETPLAY_BIOS_RETAIL = 1, + NETPLAY_BIOS_NONE = 2 /* no BIOS every seat can boot */ +}; + +typedef struct NetplayBiosSettle { + int kind; /* NETPLAY_BIOS_* */ + uint32_t retail_crc; /* RETAIL: the image every seat boots */ + /* NONE: two seats that cannot share a retail image (-1 when unused). + * seat_b is -1 when seat_a simply has no usable retail offer. */ + int seat_a; + int seat_b; +} NetplayBiosSettle; + +NetplayBiosSettle netplay_bios_settle(const NetplayBiosSeat *seats, int count); + +/* Wire token for a settled kind: "openbios", "scph1001" (retail), or "". */ +const char *netplay_bios_token(int kind); + +/* retail_crc on the wire: eight lowercase hex digits, "" for 0. */ +void netplay_bios_format_crc(uint32_t crc, char *out, size_t cap); +/* Parses eight hex digits (surrounding spaces allowed). Returns 0 for + * anything else, which callers treat as "not sent". */ +uint32_t netplay_bios_parse_crc(const char *text); + +/* Short player-facing name for a retail image CRC ("SCPH-5552"), or the CRC + * in hex ("BIOS d786f0b9") when the image is not in psx_bios_known_images.h. */ +void netplay_bios_describe_crc(uint32_t crc, char *out, size_t cap); + +/* One line explaining a NONE result, for the launcher's status line. */ +void netplay_bios_describe_refusal(const NetplayBiosSettle *s, + const NetplayBiosSeat *seats, int count, + char *out, size_t cap); + +#ifdef __cplusplus +} +#endif + +#endif /* NETPLAY_BIOS_SETTLE_H */ diff --git a/runtime/include/netplay_exit_reason.h b/runtime/include/netplay_exit_reason.h new file mode 100644 index 00000000..20163e07 --- /dev/null +++ b/runtime/include/netplay_exit_reason.h @@ -0,0 +1,37 @@ +/* netplay_exit_reason.h — why a netplay match ended, in words. + * + * A match that ends early returns every player to the lobby. The runtime knew + * why (netplay_soft_exit's origin) but only printed it to stdout, so players + * saw the lobby again with no reason. These map an origin to one sentence for + * the launcher's status line, and decide when a boot-digest mismatch is final. + */ +#ifndef NETPLAY_EXIT_REASON_H +#define NETPLAY_EXIT_REASON_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* The player-facing sentence for a netplay_soft_exit origin, or NULL when the + * player ended the match themselves (window close, Escape) or the origin is + * unknown. */ +const char *netplay_exit_reason_text(const char *origin); + +/* The rollback boot digests of the two peers are both known and different. + * The peers booted differently, and waiting does not fix that; before this change + * only the generic 20 s admit-stall watchdog ended it. The mismatch is final + * once the same pair of digests has held for grace_ms (the short wait lets a + * stale peer digest be replaced, and the peer reach the same verdict). + * mismatch_since_ms is 0 when there is no mismatch. */ +int netplay_boot_mismatch_final(uint32_t mismatch_since_ms, uint32_t now_ms, + uint32_t grace_ms); + +#define NETPLAY_BOOT_MISMATCH_GRACE_MS 3000u + +#ifdef __cplusplus +} +#endif + +#endif /* NETPLAY_EXIT_REASON_H */ diff --git a/runtime/include/psx_lobby_client.h b/runtime/include/psx_lobby_client.h index 60e62746..eac1b3d9 100644 --- a/runtime/include/psx_lobby_client.h +++ b/runtime/include/psx_lobby_client.h @@ -94,6 +94,9 @@ typedef struct PsxLobbyMember { int bios_can_openbios; /* linked OpenBIOS backend */ int bios_can_scph1001; /* linked retail + validated dump available */ int bios_prefer_openbios; /* explicit OpenBIOS pick (not retail) */ + /* CRC-32 of the retail image this seat would boot (bios_offer.retail_crc); + * 0 = not sent (older client). See netplay_bios_settle.h. */ + uint32_t bios_retail_crc; /* Peer memory-card offer from set_ready memcard_offer (0 if legacy). */ int memcard_offer_valid; int memcard_has_card; /* a slot-1 card is enabled locally */ @@ -114,6 +117,9 @@ typedef struct PsxLobbyBiosOffer { int can_openbios; int can_scph1001; int prefer_openbios; /* 1 = OpenBIOS selected; 0 = retail / willing SCPH */ + /* CRC-32 of the retail file this peer would boot for a retail match; + * 0 when can_scph1001 is 0. The settle compares these, not the token. */ + uint32_t retail_crc; } PsxLobbyBiosOffer; /* @@ -181,8 +187,12 @@ typedef struct PsxLobbyMatchCaps { * so a toggle racing the start cannot split the room. */ int guest_memcard_active; char language[PSX_LOBBY_LANG_LEN]; - /* Settled match BIOS: "openbios" | "scph1001" | "" (unset / legacy). */ + /* Settled match BIOS: "openbios" | "scph1001" | "" (unset / legacy). + * "scph1001" means "the retail image named by session_bios_crc". */ char session_bios[16]; + /* Retail match: CRC-32 of the image every peer boots. 0 = not sent (an + * older host); peers then accept any retail dump, as before. */ + uint32_t session_bios_crc; } PsxLobbyMatchCaps; typedef struct PsxLobbyJoinInfo { @@ -213,7 +223,9 @@ typedef struct PsxLobbyJoinInfo { /* Launch: the host watches from the gallery but runs the match from * session slot 0 (pad muted); player seats sit at lobby seat + 1. */ int host_spectates; - char last_error[64]; /* need_password | bad_password | … */ + /* A server code (need_password | bad_password | …) or, from + * psx_lobby_set_last_error, one sentence the launcher shows as is. */ + char last_error[192]; } PsxLobbyJoinInfo; /* Default URL when PSX_NET_LOBBY_URL unset. Order: @@ -446,14 +458,22 @@ int psx_lobby_chat_get(int index, PsxLobbyChatMsg *out); void psx_lobby_chat_clear(void); /* - * Settle session BIOS from seated peers' bios_offer (+ local offer): - * OpenBIOS if anyone cannot run SCPH-1001 (missing offer ⇒ cannot); - * else SCPH-1001 when the host prefers retail and every peer can; - * else OpenBIOS if anyone prefers OpenBIOS; else SCPH-1001. - * Host preference wins over guest OpenBIOS picks when all can SCPH. - * Writes "openbios" or "scph1001" into out. Returns 0 on success. + * Settle session BIOS from seated peers' bios_offer (+ local offer), by the + * rule in netplay_bios_settle.h: retail only when every seat offers the same + * retail image CRC; else OpenBIOS when every seat links it. + * Returns 0 and writes "openbios" or "scph1001" into out (and the retail CRC + * into *out_crc, 0 for OpenBIOS). Returns 1 when no BIOS suits every seat: + * out is "", and why (when non-NULL) says which images differ; the host must + * not start. Returns -1 on bad arguments. */ -int psx_lobby_settle_session_bios(char *out, size_t out_cap); +int psx_lobby_settle_session_bios(char *out, size_t out_cap, uint32_t *out_crc, + char *why, size_t why_cap); + +/* + * Put one sentence on the launcher's status line (through last_error, which + * the launcher shows as "Lobby error: "). NULL or "" clears it. + */ +void psx_lobby_set_last_error(const char *text); /* * Host: ask server to broadcast launch. When match_caps is non-NULL and valid, diff --git a/runtime/include/psx_netplay_rb.h b/runtime/include/psx_netplay_rb.h index ce68222b..c08f7438 100644 --- a/runtime/include/psx_netplay_rb.h +++ b/runtime/include/psx_netplay_rb.h @@ -152,6 +152,11 @@ void psx_netplay_rb_pump(void); * dig0 not received. Latches open once both digests match (sticky latches — * HC ring may age tick-0 out; must not re-stall after sync). */ int psx_netplay_rb_boot_dig0_gate(void); +/* psx_host_mono_ms() since when the peers' boot digests have been known and + * different (the same pair throughout), or 0. The watchdog ends the match once + * that has lasted NETPLAY_BOOT_MISMATCH_GRACE_MS: the peers booted different + * BIOS images or boot settings, and waiting cannot fix it. */ +uint32_t psx_netplay_rb_boot_dig0_mismatch_since_ms(void); /* Record dig0 cores outside the HC ring (emit / peer FC drain). */ void psx_netplay_rb_boot_dig0_note_local(uint32_t core); void psx_netplay_rb_boot_dig0_note_peer(uint32_t core); diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index a8d6dd51..a0398a5a 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -429,6 +429,8 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/psx_bios_backend.c ${PSXRECOMP_ROOT}/runtime/src/psx_netplay.c ${PSXRECOMP_ROOT}/runtime/src/psx_lobby_client.c + ${PSXRECOMP_ROOT}/runtime/src/netplay_bios_settle.c + ${PSXRECOMP_ROOT}/runtime/src/netplay_exit_reason.c ${PSXRECOMP_ROOT}/recompiler/src/config_loader.cpp ${PSXRECOMP_ROOT}/recompiler/src/ps1_exe_parser.cpp # (sljit Tier-2 in-process JIT backend removed 2026-07-15 — was disabled by diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index a5241863..d46d5e60 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -61,6 +61,9 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "psx_netplay_rb.h" #include "psx_selfcheck.h" #include "psx_lobby_client.h" +#include "netplay_bios_settle.h" +#include "netplay_exit_reason.h" +#include "host_time.h" #if defined(PSX_HAS_RECOMP_NET) #include "recomp_net/auth.h" #include "recomp_net/chat_filter.h" /* chat profanity mask, LAN rooms too */ @@ -2982,13 +2985,19 @@ static std::filesystem::path discover_retail_bios_near(const char* argv0) { /* Match-only BIOS from lobby `session_bios`. Never writes bios.cfg / settings. * Returns true when session_bios is a known settle token. - * *out_path empty ⇒ OpenBIOS for this match; otherwise a validated retail dump. */ + * *out_path empty ⇒ OpenBIOS for this match; otherwise a validated retail dump. + * A retail match boots only the image named by retail_crc, so every + * peer runs the same BIOS; retail_crc 0 (an older host) accepts any retail + * dump, as before. *out_image_id, when given, names the image found. */ static bool resolve_match_session_bios_path( const char* session_bios, + uint32_t retail_crc, const std::filesystem::path& preferred_hint, const char* launcher_bios_path, const char* argv0, - std::filesystem::path* out_path) { + std::filesystem::path* out_path, + const char** out_image_id = nullptr) { + if (out_image_id) *out_image_id = nullptr; if (!out_path || !session_bios || !session_bios[0]) return false; if (std::strcmp(session_bios, "openbios") == 0) { @@ -3004,8 +3013,11 @@ static bool resolve_match_session_bios_path( if (p.empty() || !std::filesystem::exists(p, ec)) return; const PsxBiosBackend* b = bios_backend_for_file(p, nullptr, nullptr); - if (b && b->image && !b->image->image_bundled) + if (b && b->image && !b->image->image_bundled && + (!retail_crc || b->image->image_crc32 == retail_crc)) { retail = p; + if (out_image_id) *out_image_id = b->image->image_id; + } }; try_retail(preferred_hint); if (retail.empty() && launcher_bios_path && launcher_bios_path[0]) @@ -3313,7 +3325,18 @@ static void netplay_host_present_restore(void) { apply_present_cadence(); } +/* Why the last match ended, for the launcher's status line once the lobby is + * back. Set by netplay_soft_exit; NULL when the player ended it. */ +static const char *g_netplay_exit_reason_text = nullptr; + static void netplay_soft_exit(const char *origin) { + /* A peer that gave up on a boot-digest mismatch sends BYE, which reaches + * this side as a disconnect. This side saw the same mismatch: say that, + * not "the other player left". Read it before shutdown clears the latch. */ + if (origin && std::strcmp(origin, "netplay_peer_disconnect") == 0 && + psx_netplay_rb_boot_dig0_mismatch_since_ms() != 0u) + origin = "netplay_boot_mismatch"; + g_netplay_exit_reason_text = netplay_exit_reason_text(origin); psx_crash_trace_set_exit_origin(origin); netplay_host_present_restore(); psx_netplay_shutdown(); /* sends BYE so the peer soft-exits too */ @@ -3324,6 +3347,8 @@ static void netplay_soft_exit(const char *origin) { psx_request_return_to_lobby(); return; } + if (g_netplay_exit_reason_text) + std::fprintf(stderr, "psxrecomp: %s\n", g_netplay_exit_reason_text); shutdown_runtime(); std::exit(0); } @@ -5717,6 +5742,20 @@ static void netplay_barrier_admit(int override) { netplay_soft_exit("netplay_peer_disconnect"); if (psx_return_to_lobby_requested()) goto done; } + /* Both boot digests known and different: the peers booted differently + * (BIOS image, boot settings) and the dig0 gate would hold until the + * 20 s admit-stall watchdog. End it after a short grace, with its own + * reason. */ + if (netplay_boot_mismatch_final(psx_netplay_rb_boot_dig0_mismatch_since_ms(), + (uint32_t)psx_host_mono_ms(), + NETPLAY_BOOT_MISMATCH_GRACE_MS)) { + std::fprintf(stderr, + "psxrecomp: netplay boot digest mismatch held %u ms — " + "returning to lobby\n", + (unsigned)NETPLAY_BOOT_MISMATCH_GRACE_MS); + netplay_soft_exit("netplay_boot_mismatch"); + if (psx_return_to_lobby_requested()) goto done; + } /* 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()) { @@ -8364,10 +8403,14 @@ namespace { int prefer_openbios = 1; int can_openbios = 1; int can_scph1001 = 0; + uint32_t retail_crc = 0; /* image this seat would boot; 0 = not sent */ }; AeLanSlotBios g_lnch_lan_slot_bios[kAeLanMaxSlots]{}; /* Match-only BIOS token from lobby settle or LAN START ("openbios"|"scph1001"). */ char g_lnch_session_bios[16]{}; + /* With "scph1001": CRC-32 of the retail image every peer boots (0 = any, + * from an older host). See netplay_bios_settle.h. */ + uint32_t g_lnch_session_bios_crc = 0; /* Bring-your-own memory card (seat 1 / P2). Per-seat offers for LAN * (mirrors online memcard_offer); the local offer as last published by @@ -8458,13 +8501,14 @@ namespace { static int ae_np_lan_occupied(const AeLanLobbyState& state); static int ae_np_lan_endpoint_port(const std::string& endpoint); static bool ae_np_read_lan_file_state(AeLanLobbyState* state); - static void ae_np_set_session_bios_token(const char* token); + static void ae_np_set_session_bios_token(const char* token, uint32_t retail_crc = 0); static void ae_np_clear_session_bios_token(void); static void ae_np_lan_clear_slot_bios(int slot); static void ae_np_lan_store_slot_bios(int slot, int prefer_open, int can_open, - int can_scph); + int can_scph, uint32_t retail_crc = 0); static void ae_np_lan_sync_local_slot_bios(void); - static int ae_np_lan_settle_session_bios(char* out, size_t out_cap); + static int ae_np_lan_settle_session_bios(char* out, size_t out_cap, uint32_t* out_crc, + char* why, size_t why_cap); static void ae_np_append_lan_bios_join(char* msg, size_t msg_cap, int* io_off); static int ae_np_parse_lan_bios_tail(char* p, int* prefer_open, int* can_open, int* can_scph); @@ -9748,21 +9792,27 @@ namespace { offer.can_openbios = (s_openbios_allowed && psx_bios_bundled() != nullptr) ? 1 : 0; const char* argv0 = g_lnch_argv0 ? g_lnch_argv0 : ""; - auto path_is_retail = [&](const std::filesystem::path& p) -> bool { + /* CRC-32 of the linked retail image a file matches, 0 if none. */ + auto retail_crc_of = [&](const std::filesystem::path& p) -> uint32_t { std::error_code ec; - if (p.empty() || !std::filesystem::exists(p, ec)) return false; + if (p.empty() || !std::filesystem::exists(p, ec)) return 0; const PsxBiosBackend* b = bios_backend_for_file(p, nullptr, nullptr); - return b && b->image && !b->image->image_bundled; + return (b && b->image && !b->image->image_bundled) ? b->image->image_crc32 : 0; }; - bool has_dump = false; - if (launcher_bios_path && launcher_bios_path[0]) { - const auto p = resolve_bios_path(launcher_bios_path, argv0); - has_dump = path_is_retail(p); - } - if (!has_dump) has_dump = path_is_retail(read_cached_path(argv0, "bios.cfg")); - if (!has_dump) has_dump = path_is_retail(discover_retail_bios_near(argv0)); + auto path_is_retail = [&](const std::filesystem::path& p) -> bool { + return retail_crc_of(p) != 0; + }; + /* The first dump in the order resolve_match_session_bios_path tries + * them: that is the image this peer boots for a retail match, so its + * CRC is what the settle must compare. */ + uint32_t dump_crc = 0; + if (launcher_bios_path && launcher_bios_path[0]) + dump_crc = retail_crc_of(resolve_bios_path(launcher_bios_path, argv0)); + if (!dump_crc) dump_crc = retail_crc_of(read_cached_path(argv0, "bios.cfg")); + if (!dump_crc) dump_crc = retail_crc_of(discover_retail_bios_near(argv0)); offer.can_scph1001 = - (psx_bios_has_selectable() && has_dump) ? 1 : 0; + (psx_bios_has_selectable() && dump_crc) ? 1 : 0; + offer.retail_crc = offer.can_scph1001 ? dump_crc : 0; /* Empty / bundled path = explicit OpenBIOS preference. */ offer.prefer_openbios = 1; @@ -9783,18 +9833,35 @@ namespace { ae_np_refresh_bios_offer(nullptr); } - static void ae_np_set_session_bios_token(const char* token) { + static void ae_np_set_session_bios_token(const char* token, uint32_t retail_crc) { g_lnch_session_bios[0] = '\0'; + g_lnch_session_bios_crc = 0; if (!token || !token[0]) return; if (std::strcmp(token, "openbios") != 0 && std::strcmp(token, "scph1001") != 0) return; std::snprintf(g_lnch_session_bios, sizeof(g_lnch_session_bios), "%s", token); + if (std::strcmp(token, "scph1001") == 0) + g_lnch_session_bios_crc = retail_crc; } static void ae_np_clear_session_bios_token(void) { g_lnch_session_bios[0] = '\0'; + g_lnch_session_bios_crc = 0; + } + + static void ae_np_log_settled_bios(const char* where) { + const char* token = g_lnch_session_bios[0] ? g_lnch_session_bios : "openbios"; + if (std::strcmp(token, "scph1001") == 0 && g_lnch_session_bios_crc) { + char name[32]; + netplay_bios_describe_crc(g_lnch_session_bios_crc, name, sizeof(name)); + std::fprintf(stdout, + "psxrecomp: %s settled session BIOS = %s (retail %s, crc %08x)\n", + where, token, name, (unsigned)g_lnch_session_bios_crc); + } else { + std::fprintf(stdout, "psxrecomp: %s settled session BIOS = %s\n", where, token); + } } static void ae_np_lan_clear_slot_bios(int slot) { @@ -9805,13 +9872,14 @@ namespace { } static void ae_np_lan_store_slot_bios(int slot, int prefer_open, int can_open, - int can_scph) { + int can_scph, uint32_t retail_crc) { if (slot < 0 || slot >= kAeLanMaxSlots) return; AeLanSlotBios& b = g_lnch_lan_slot_bios[slot]; b.valid = 1; b.prefer_openbios = prefer_open ? 1 : 0; b.can_openbios = can_open ? 1 : 0; b.can_scph1001 = can_scph ? 1 : 0; + b.retail_crc = can_scph ? retail_crc : 0; if (!b.can_openbios && !b.can_scph1001) b.can_openbios = 1; } @@ -9827,7 +9895,7 @@ namespace { } if (slot < 0 || !offer || !offer->valid) return; ae_np_lan_store_slot_bios(slot, offer->prefer_openbios, offer->can_openbios, - offer->can_scph1001); + offer->can_scph1001, offer->retail_crc); } static void ae_np_lan_send_chat_to_peers(const char* player_id, const char* from, @@ -9988,10 +10056,15 @@ namespace { return (mc.valid && mc.has_card && mc.share) ? 1 : 0; } - /* Same settle rule as psx_lobby_settle_session_bios, over LAN seat offers. */ - static int ae_np_lan_settle_session_bios(char* out, size_t out_cap) { + /* Same settle rule as psx_lobby_settle_session_bios (netplay_bios_settle), + * over LAN seat offers. Returns 0 with the token in out and the retail CRC + * in *out_crc; 1 when no BIOS suits every seat (why says which). */ + static int ae_np_lan_settle_session_bios(char* out, size_t out_cap, uint32_t* out_crc, + char* why, size_t why_cap) { if (!out || out_cap < 9) return -1; out[0] = '\0'; + if (out_crc) *out_crc = 0; + if (why && why_cap) why[0] = '\0'; AeLanLobbyState st; if (!ae_np_read_lan_state(&st)) { std::strncpy(out, "openbios", out_cap - 1); @@ -9999,35 +10072,30 @@ namespace { return 0; } ae_np_lan_sync_local_slot_bios(); - int any_prefer_open = 0; - int any_cannot_scph = 0; - int host_prefer_scph = 0; - int saw_peer = 0; + NetplayBiosSeat seats[kAeLanMaxSlots] = {}; + int n = 0; const int host_slot = (st.host_slot >= 0 && st.host_slot < kAeLanMaxSlots) ? st.host_slot : 0; for (int i = 0; i < st.max_slots && i < kAeLanMaxSlots; ++i) { if (st.slot_name[i].empty()) continue; - saw_peer = 1; const AeLanSlotBios& b = g_lnch_lan_slot_bios[i]; - if (!b.valid) { - any_cannot_scph = 1; - continue; - } - if (b.prefer_openbios) any_prefer_open = 1; - if (!b.can_scph1001) any_cannot_scph = 1; - if (i == host_slot && !b.prefer_openbios && b.can_scph1001) - host_prefer_scph = 1; + NetplayBiosSeat& s = seats[n++]; + s.offered = b.valid; + s.can_openbios = b.can_openbios; + s.can_retail = b.can_scph1001; + s.prefer_openbios = b.prefer_openbios; + s.retail_crc = b.retail_crc; + s.is_host = (i == host_slot) ? 1 : 0; } - if (!saw_peer) any_cannot_scph = 1; - if (any_cannot_scph) - std::strncpy(out, "openbios", out_cap - 1); - else if (host_prefer_scph) - std::strncpy(out, "scph1001", out_cap - 1); - else if (any_prefer_open) - std::strncpy(out, "openbios", out_cap - 1); - else - std::strncpy(out, "scph1001", out_cap - 1); + const NetplayBiosSettle settle = netplay_bios_settle(seats, n); + if (settle.kind == NETPLAY_BIOS_NONE) { + if (why && why_cap) + netplay_bios_describe_refusal(&settle, seats, n, why, why_cap); + return 1; + } + std::strncpy(out, netplay_bios_token(settle.kind), out_cap - 1); out[out_cap - 1] = '\0'; + if (out_crc) *out_crc = settle.retail_crc; return 0; } @@ -10055,6 +10123,36 @@ namespace { g_lnch_memcard_offer.share ? 1 : 0); if (m > 0) *io_off += m; } + /* Line 6: CRC-32 of the retail image this peer boots; "" + * when it has none. Older hosts stop after the memcard lines. */ + if (*io_off > 0 && (size_t)*io_off < msg_cap) { + char crc[16]; + netplay_bios_format_crc((offer && offer->valid && offer->can_scph1001) + ? offer->retail_crc : 0, + crc, sizeof(crc)); + const int c = std::snprintf(msg + *io_off, msg_cap - (size_t)*io_off, + "%s\n", crc); + if (c > 0) *io_off += c; + } + } + + /* Optional JOIN retail CRC: line 6 of the tail, after the three bios and + * two memcard lines. 0 when absent (an older guest). Does not modify tail. */ + static uint32_t ae_np_parse_lan_bios_crc_tail(const char* tail) { + if (!tail) return 0; + const char* p = tail; + for (int i = 0; i < 5; ++i) { + const char* nl = std::strchr(p, '\n'); + if (!nl) return 0; + p = nl + 1; + } + char line[16] = {}; + size_t n = 0; + while (p[n] && p[n] != '\n' && n + 1 < sizeof(line)) { + line[n] = p[n]; + ++n; + } + return netplay_bios_parse_crc(line); } /* Optional JOIN memcard tail after the 3 bios lines: has_card\nshare\n. @@ -10860,10 +10958,13 @@ namespace { int prefer_open = 1, can_open = 1, can_scph = 0; AeLanSlotMemcard mc_offer{}; (void)ae_np_parse_lan_memcard_tail(bios_tail, &mc_offer); + /* Read before ae_np_parse_lan_bios_tail, which cuts the tail. */ + const uint32_t retail_crc = ae_np_parse_lan_bios_crc_tail(bios_tail); if (bios_tail && ae_np_parse_lan_bios_tail(bios_tail, &prefer_open, &can_open, &can_scph) == 0) { - ae_np_lan_store_slot_bios(slot, prefer_open, can_open, can_scph); + ae_np_lan_store_slot_bios(slot, prefer_open, can_open, can_scph, + retail_crc); } else { /* Legacy JOIN without bios_offer — cannot assume SCPH. */ ae_np_lan_clear_slot_bios(slot); @@ -11229,7 +11330,7 @@ namespace { if (std::strncmp(buf, "MOTK1 START\n", 12) == 0) { g_lnch_remote_lan_state.started = true; /* MOTK1 START\n\n[\n\n\n - * [\n]] + * [\n[\n[\n]]]] * Trailing caps are host-authoritative (incl. settled BIOS). */ char* p = buf + 12; char* nl = std::strchr(p, '\n'); @@ -11246,8 +11347,8 @@ namespace { g_lnch_lan_guest_memcard_active = 0; if (nl) { p = nl + 1; - char* lines[5] = {}; - for (int i = 0; i < 5; ++i) { + char* lines[6] = {}; + for (int i = 0; i < 6; ++i) { if (!p || !*p) break; lines[i] = p; char* n2 = std::strchr(p, '\n'); @@ -11276,7 +11377,10 @@ namespace { if (lines[2] && lines[2][0]) g_lnch_rollback = (std::atoi(lines[2]) != 0) ? 1 : 0; if (lines[3] && lines[3][0]) - ae_np_set_session_bios_token(lines[3]); + /* Line 6 names the retail image; an + * older host sends none, which accepts any dump. */ + ae_np_set_session_bios_token( + lines[3], lines[5] ? netplay_bios_parse_crc(lines[5]) : 0u); else /* Legacy host: force OpenBIOS so mixed local prefs * cannot silently desync. */ @@ -12141,12 +12245,21 @@ namespace { else ae_np_refresh_bios_offer_from_disk(); char session_bios[16] = {}; - (void)ae_np_lan_settle_session_bios(session_bios, sizeof(session_bios)); + uint32_t session_crc = 0; + char why[192] = {}; + if (ae_np_lan_settle_session_bios(session_bios, sizeof(session_bios), + &session_crc, why, sizeof(why)) == 1) { + /* No BIOS every seat can boot: starting would only end in a + * boot-digest stall. Say why instead. */ + std::fprintf(stdout, "psxrecomp: LAN start refused: %s\n", why); + psx_lobby_set_last_error(why); + ae_np_clear_session_bios_token(); + return -1; + } ae_np_set_session_bios_token(session_bios[0] ? session_bios - : "openbios"); - std::fprintf(stdout, "psxrecomp: LAN settled session BIOS = %s\n", - g_lnch_session_bios[0] ? g_lnch_session_bios - : "openbios"); + : "openbios", + session_crc); + ae_np_log_settled_bios("LAN"); state.started = true; state.session_id += 1u; if (state.session_id == 0) state.session_id = 1; @@ -12164,13 +12277,17 @@ namespace { g_lnch_lan_guest_memcard_active = ae_np_lan_guest_memcard_effective(state); std::fprintf(stdout, "psxrecomp: LAN guest memcard (P2 card as slot 2) = %s\n", g_lnch_lan_guest_memcard_active ? "on" : "off"); + char session_crc_text[16]; + netplay_bios_format_crc(g_lnch_session_bios_crc, session_crc_text, + sizeof(session_crc_text)); std::snprintf(start_msg, sizeof(start_msg), - "MOTK1 START\n%u\n%d\n%d\n%d\n%s\n%d\n", + "MOTK1 START\n%u\n%d\n%d\n%d\n%s\n%d\n%s\n", (unsigned)state.session_id, delay, pred, g_lnch_rollback ? 1 : 0, g_lnch_session_bios[0] ? g_lnch_session_bios : "openbios", - g_lnch_lan_guest_memcard_active ? 1 : 0); + g_lnch_lan_guest_memcard_active ? 1 : 0, + session_crc_text); 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); @@ -12188,12 +12305,19 @@ namespace { caps.guest_memcard_active = ae_np_ws_guest_memcard_effective(); std::fprintf(stdout, "psxrecomp: lobby guest memcard (P2 card as slot 2) = %s\n", caps.guest_memcard_active ? "on" : "off"); - (void)psx_lobby_settle_session_bios(caps.session_bios, - sizeof(caps.session_bios)); + char why[192] = {}; + if (psx_lobby_settle_session_bios(caps.session_bios, sizeof(caps.session_bios), + &caps.session_bios_crc, why, sizeof(why)) == 1) { + /* No BIOS every seat can boot: refuse, and say why. */ + std::fprintf(stdout, "psxrecomp: lobby start refused: %s\n", why); + psx_lobby_set_last_error(why); + ae_np_clear_session_bios_token(); + return -1; + } ae_np_set_session_bios_token(caps.session_bios[0] ? caps.session_bios - : "openbios"); - std::fprintf(stdout, "psxrecomp: lobby settled session BIOS = %s\n", - caps.session_bios[0] ? caps.session_bios : "openbios"); + : "openbios", + caps.session_bios_crc); + ae_np_log_settled_bios("lobby"); return psx_lobby_request_start(&caps); } @@ -12430,7 +12554,8 @@ namespace { * it once, so a toggle racing the start cannot split the room. */ out->guest_memcard = caps->guest_memcard_active ? 1 : 0; if (caps->session_bios[0]) - ae_np_set_session_bios_token(caps->session_bios); + ae_np_set_session_bios_token(caps->session_bios, + caps->session_bios_crc); return 1; } @@ -14692,24 +14817,27 @@ int main(int argc, char** argv) { * Ephemeral for this boot only — seed / bios.cfg keep * the player's offline preference. */ if (caps->session_bios[0]) - ae_np_set_session_bios_token(caps->session_bios); + ae_np_set_session_bios_token(caps->session_bios, + caps->session_bios_crc); } } /* LAN START / lobby token — apply even when match_caps absent. */ + const char* match_bios_id = nullptr; if (ls.netplay_launch.enabled && g_lnch_session_bios[0] && resolve_match_session_bios_path( - g_lnch_session_bios, + g_lnch_session_bios, g_lnch_session_bios_crc, seed.has_bios_path ? seed.bios_path : std::filesystem::path{}, ls.bios_path, argv[0], - &match_session_bios_path)) { + &match_session_bios_path, &match_bios_id)) { match_session_bios_set = true; if (std::strcmp(g_lnch_session_bios, "scph1001") == 0 && match_session_bios_path.empty()) { std::fprintf(stderr, - "psxrecomp: session BIOS scph1001 required but no " - "validated dump found — aborting launch (mixed " - "OpenBIOS/SCPH would desync)\n"); + "psxrecomp: session BIOS scph1001 (crc %08x) required " + "but no validated dump found — aborting launch (mixed " + "BIOS images would desync)\n", + (unsigned)g_lnch_session_bios_crc); match_session_bios_set = false; ls.netplay_launch.enabled = 0; g_lnch_pending_direct_launch = {}; @@ -14724,8 +14852,9 @@ int main(int argc, char** argv) { "(match only; preference unchanged)\n"); } else { std::fprintf(stdout, - "psxrecomp: netplay session BIOS = SCPH-1001 " + "psxrecomp: netplay session BIOS = %s " "(%s; match only; preference unchanged)\n", + match_bios_id ? match_bios_id : "retail", match_session_bios_path.string().c_str()); } } @@ -16377,6 +16506,11 @@ soft_return_lobby: teardown_game_session_keep_lobby(); #if defined(RECOMP_LAUNCHER) && defined(PSX_HAS_LOBBY_CLIENT) ae_np_prepare_lobby_rematch(); + /* Say why the match ended on the launcher's status line; the + * room and browser views both show last_error. Cleared when the player + * ended the match themselves. */ + psx_lobby_set_last_error(g_netplay_exit_reason_text); + g_netplay_exit_reason_text = nullptr; { std::string assets_dir_str = exe_dir_from_argv(argv[0]).string(); std::string rui_title = (game_name.empty() ? std::string("PSX") : game_name) @@ -16928,19 +17062,22 @@ soft_return_lobby: if (net_cfg.enabled) { const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); if (caps && caps->valid && caps->session_bios[0]) - ae_np_set_session_bios_token(caps->session_bios); + ae_np_set_session_bios_token(caps->session_bios, + caps->session_bios_crc); std::filesystem::path session_path; + const char* session_bios_id = nullptr; if (g_lnch_session_bios[0] && resolve_match_session_bios_path( - g_lnch_session_bios, + g_lnch_session_bios, g_lnch_session_bios_crc, ls.bios_path[0] ? std::filesystem::path(ls.bios_path) : std::filesystem::path{}, - ls.bios_path, argv[0], &session_path)) { + ls.bios_path, argv[0], &session_path, &session_bios_id)) { if (std::strcmp(g_lnch_session_bios, "scph1001") == 0 && session_path.empty()) { std::fprintf(stderr, - "psxrecomp: rematch session BIOS scph1001 required " - "but no validated dump — aborting\n"); + "psxrecomp: rematch session BIOS scph1001 (crc %08x) " + "required but no validated dump — aborting\n", + (unsigned)g_lnch_session_bios_crc); SDL_Quit(); return 1; } @@ -16955,8 +17092,9 @@ soft_return_lobby: } else { bios_path_str = session_path.string(); std::fprintf(stdout, - "psxrecomp: rematch session BIOS = SCPH-1001 (%s; " + "psxrecomp: rematch session BIOS = %s (%s; " "preference unchanged)\n", + session_bios_id ? session_bios_id : "retail", bios_path_str.c_str()); } } diff --git a/runtime/src/netplay_bios_settle.c b/runtime/src/netplay_bios_settle.c new file mode 100644 index 00000000..0fb5384f --- /dev/null +++ b/runtime/src/netplay_bios_settle.c @@ -0,0 +1,142 @@ +/* Which BIOS every peer boots for a netplay match. + * See netplay_bios_settle.h for the rule. */ +#include "netplay_bios_settle.h" +#include "psx_bios_known_images.h" + +#include +#include + +NetplayBiosSettle netplay_bios_settle(const NetplayBiosSeat *seats, int count) +{ + NetplayBiosSettle s; + int retail_ok = count > 0; + int open_ok = count > 0; + int host_prefers_retail = 0; + int any_prefer_open = 0; + int first = -1; + int i; + + s.kind = NETPLAY_BIOS_OPENBIOS; + s.retail_crc = 0; + s.seat_a = -1; + s.seat_b = -1; + if (!seats || count <= 0) + return s; + + for (i = 0; i < count; ++i) { + const NetplayBiosSeat *seat = &seats[i]; + if (!seat->offered) { + /* Older client: no retail identity; assumed to link OpenBIOS. */ + if (retail_ok) s.seat_a = i; + retail_ok = 0; + continue; + } + if (!seat->can_openbios) open_ok = 0; + if (seat->prefer_openbios) any_prefer_open = 1; + if (seat->is_host && !seat->prefer_openbios && seat->can_retail) + host_prefers_retail = 1; + if (!seat->can_retail || !seat->retail_crc) { + if (retail_ok) s.seat_a = i; + retail_ok = 0; + continue; + } + if (first < 0) { + first = i; + } else if (seat->retail_crc != seats[first].retail_crc && retail_ok) { + retail_ok = 0; + s.seat_a = first; + s.seat_b = i; + } + } + + if (retail_ok && (host_prefers_retail || !any_prefer_open)) { + s.kind = NETPLAY_BIOS_RETAIL; + } else if (open_ok) { + s.kind = NETPLAY_BIOS_OPENBIOS; + } else if (retail_ok) { + s.kind = NETPLAY_BIOS_RETAIL; + } else { + s.kind = NETPLAY_BIOS_NONE; + return s; + } + if (s.kind == NETPLAY_BIOS_RETAIL) + s.retail_crc = seats[first].retail_crc; + s.seat_a = -1; + s.seat_b = -1; + return s; +} + +const char *netplay_bios_token(int kind) +{ + switch (kind) { + case NETPLAY_BIOS_OPENBIOS: return "openbios"; + case NETPLAY_BIOS_RETAIL: return "scph1001"; + default: return ""; + } +} + +void netplay_bios_format_crc(uint32_t crc, char *out, size_t cap) +{ + if (!out || !cap) return; + if (!crc) { + out[0] = '\0'; + return; + } + snprintf(out, cap, "%08x", (unsigned)crc); +} + +uint32_t netplay_bios_parse_crc(const char *text) +{ + uint32_t v = 0; + int digits = 0; + if (!text) return 0; + while (*text == ' ' || *text == '\t') ++text; + for (; *text; ++text) { + const char c = *text; + uint32_t d; + if (c >= '0' && c <= '9') d = (uint32_t)(c - '0'); + else if (c >= 'a' && c <= 'f') d = (uint32_t)(c - 'a' + 10); + else if (c >= 'A' && c <= 'F') d = (uint32_t)(c - 'A' + 10); + else break; + if (++digits > 8) return 0; + v = (v << 4) | d; + } + while (*text == ' ' || *text == '\t' || *text == '\r' || *text == '\n') ++text; + if (digits != 8 || *text) return 0; + return v; +} + +void netplay_bios_describe_crc(uint32_t crc, char *out, size_t cap) +{ + size_t i; + if (!out || !cap) return; + for (i = 0; i < sizeof(psx_known_bios_images) / sizeof(psx_known_bios_images[0]); ++i) { + if (psx_known_bios_images[i].crc32 == crc) { + snprintf(out, cap, "%s", psx_known_bios_images[i].id); + return; + } + } + snprintf(out, cap, "BIOS %08x", (unsigned)crc); +} + +void netplay_bios_describe_refusal(const NetplayBiosSettle *s, + const NetplayBiosSeat *seats, int count, + char *out, size_t cap) +{ + char a[32], b[32]; + if (!out || !cap) return; + out[0] = '\0'; + if (!s || s->kind != NETPLAY_BIOS_NONE) return; + if (seats && s->seat_a >= 0 && s->seat_a < count && + s->seat_b >= 0 && s->seat_b < count) { + netplay_bios_describe_crc(seats[s->seat_a].retail_crc, a, sizeof(a)); + netplay_bios_describe_crc(seats[s->seat_b].retail_crc, b, sizeof(b)); + snprintf(out, cap, + "Players use different BIOS images (%s and %s), and not " + "everyone has OpenBIOS. Use the same BIOS dump.", a, b); + return; + } + snprintf(out, cap, + "A player has no usable BIOS dump for this game (or an older " + "build), and not everyone has OpenBIOS."); +} diff --git a/runtime/src/netplay_exit_reason.c b/runtime/src/netplay_exit_reason.c new file mode 100644 index 00000000..749be697 --- /dev/null +++ b/runtime/src/netplay_exit_reason.c @@ -0,0 +1,44 @@ +/* Why a netplay match ended, in words. See netplay_exit_reason.h. */ +#include "netplay_exit_reason.h" + +#include +#include + +typedef struct { + const char *origin; + const char *text; +} NetplayExitReason; + +static const NetplayExitReason k_reasons[] = { + { "netplay_boot_mismatch", + "The match ended: the players' consoles started differently " + "(different BIOS image or boot settings)." }, + { "netplay_peer_disconnect", + "The match ended: the other player left or stopped responding." }, + { "netplay_load_failed", + "The match ended: a player could not load the shared save state." }, + { "netplay_load_stall", + "The match ended: loading the shared save state timed out." }, + { "netplay_link_stall", + "The match ended: could not connect to the other player in time." }, + { "netplay_admit_stall", + "The match ended: the players' games stopped running in step." }, +}; + +const char *netplay_exit_reason_text(const char *origin) +{ + size_t i; + if (!origin) return NULL; + for (i = 0; i < sizeof(k_reasons) / sizeof(k_reasons[0]); ++i) { + if (strcmp(k_reasons[i].origin, origin) == 0) + return k_reasons[i].text; + } + return NULL; +} + +int netplay_boot_mismatch_final(uint32_t mismatch_since_ms, uint32_t now_ms, + uint32_t grace_ms) +{ + if (!mismatch_since_ms) return 0; + return (uint32_t)(now_ms - mismatch_since_ms) >= grace_ms; +} diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index 4d656440..58ba5d56 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -1,4 +1,5 @@ #include "psx_lobby_client.h" +#include "netplay_bios_settle.h" #include #include @@ -151,13 +152,17 @@ void psx_lobby_seat_swap_clear(void) {} int psx_lobby_chat_count(void) { return 0; } int psx_lobby_chat_get(int index, PsxLobbyChatMsg *out) { (void)index; (void)out; return 0; } void psx_lobby_chat_clear(void) {} -int psx_lobby_settle_session_bios(char *out, size_t out_cap) +int psx_lobby_settle_session_bios(char *out, size_t out_cap, uint32_t *out_crc, + char *why, size_t why_cap) { if (!out || out_cap < 9) return -1; strncpy(out, "openbios", out_cap - 1); out[out_cap - 1] = '\0'; + if (out_crc) *out_crc = 0; + if (why && why_cap) why[0] = '\0'; return 0; } +void psx_lobby_set_last_error(const char *text) { (void)text; } int psx_lobby_request_start(const PsxLobbyMatchCaps *c) { (void)c; return -1; } int psx_lobby_launch_pending(void) { return 0; } void psx_lobby_clear_launch_pending(void) {} @@ -1479,6 +1484,15 @@ static void parse_match_caps_object(const char *obj, PsxLobbyMatchCaps *out) strcmp(out->session_bios, "openbios") != 0 && strcmp(out->session_bios, "scph1001") != 0) out->session_bios[0] = '\0'; + /* Retail image every peer boots. Absent from an older host: + * 0, and peers accept any retail dump as they used to. */ + { + char crc[16]; + crc[0] = '\0'; + json_get_str(obj, "session_bios_crc", crc, sizeof(crc)); + out->session_bios_crc = strcmp(out->session_bios, "scph1001") == 0 + ? netplay_bios_parse_crc(crc) : 0; + } out->valid = 1; } @@ -1505,8 +1519,11 @@ static int append_match_caps_json(char *dst, size_t dst_cap, const PsxLobbyMatch if (!lang[0]) strncpy(lang, "en", sizeof(lang) - 1); { const char *sb = caps->session_bios; + char crc[16]; if (!sb[0] || (strcmp(sb, "openbios") != 0 && strcmp(sb, "scph1001") != 0)) sb = ""; + netplay_bios_format_crc(strcmp(sb, "scph1001") == 0 ? caps->session_bios_crc : 0, + crc, sizeof(crc)); return snprintf(dst, dst_cap, ",\"match_caps\":{\"v\":1,\"aspect_num\":%d,\"aspect_den\":%d," "\"turbo_loads\":%s,\"bios_hle\":%s,\"fast_boot\":%s," @@ -1514,7 +1531,8 @@ static int append_match_caps_json(char *dst, size_t dst_cap, const PsxLobbyMatch "\"force_input_relay\":%s,\"force_turn\":%s,\"rollback\":%s," "\"multitap_analog\":%s,\"guest_memcard\":%s," "\"guest_memcard_active\":%s," - "\"language\":\"%s\",\"session_bios\":\"%s\"}", + "\"language\":\"%s\",\"session_bios\":\"%s\"," + "\"session_bios_crc\":\"%s\"}", caps->aspect_num, caps->aspect_den, caps->turbo_loads ? "true" : "false", caps->bios_hle ? "true" : "false", @@ -1528,7 +1546,7 @@ static int append_match_caps_json(char *dst, size_t dst_cap, const PsxLobbyMatch caps->multitap_analog ? "true" : "false", caps->guest_memcard ? "true" : "false", caps->guest_memcard_active ? "true" : "false", - lang, sb); + lang, sb, crc); } } @@ -1699,9 +1717,14 @@ static int parse_seat_array(const char *json, const char *key, int is_spectator, sizeof(g_lc.members[n].display_name)); g_lc.members[n].ready = json_get_bool(chunk, "ready", 0); g_lc.members[n].is_spectator = is_spectator; + g_lc.members[n].bios_retail_crc = 0; if (json_extract_object(chunk, "bios_offer", offer, sizeof(offer))) { char prefer[24]; + char crc[16]; prefer[0] = '\0'; + crc[0] = '\0'; + json_get_str(offer, "retail_crc", crc, sizeof(crc)); + g_lc.members[n].bios_retail_crc = netplay_bios_parse_crc(crc); g_lc.members[n].bios_offer_valid = 1; g_lc.members[n].bios_can_openbios = json_get_bool(offer, "can_openbios", 1); @@ -4566,60 +4589,65 @@ int psx_lobby_chat_get(int index, PsxLobbyChatMsg *out) return 1; } -int psx_lobby_settle_session_bios(char *out, size_t out_cap) +int psx_lobby_settle_session_bios(char *out, size_t out_cap, uint32_t *out_crc, + char *why, size_t why_cap) { + NetplayBiosSeat seats[PSX_LOBBY_MAX_MEMBERS + 1]; + NetplayBiosSettle settle; + const char *me = psx_lobby_player_id(); + int n = 0; int i; - int any_prefer_open = 0; - int any_cannot_scph = 0; - int host_prefer_scph = 0; - int saw_peer = 0; if (!out || out_cap < 9) return -1; out[0] = '\0'; + if (out_crc) *out_crc = 0; + if (why && why_cap) why[0] = '\0'; - for (i = 0; i < g_lc.member_count; ++i) { + memset(seats, 0, sizeof(seats)); + for (i = 0; i < g_lc.member_count && n < PSX_LOBBY_MAX_MEMBERS; ++i) { const PsxLobbyMember *m = &g_lc.members[i]; if (!m->player_id[0] && !m->display_name[0]) continue; - saw_peer = 1; - if (!m->bios_offer_valid) { - /* Legacy client / not ready yet — cannot assume SCPH. */ - any_cannot_scph = 1; + /* This client's own row: the local offer below is newer than the + * server's echo of it (the player may have just changed BIOS). */ + if (g_lc.bios_offer.valid && me && me[0] && strcmp(m->player_id, me) == 0) continue; - } - if (m->bios_prefer_openbios) any_prefer_open = 1; - if (!m->bios_can_scph1001) any_cannot_scph = 1; - if (!m->bios_can_openbios && !m->bios_can_scph1001) - any_cannot_scph = 1; - if (psx_lobby_member_is_host(m) && !m->bios_prefer_openbios && - m->bios_can_scph1001) - host_prefer_scph = 1; + seats[n].offered = m->bios_offer_valid; + seats[n].can_openbios = m->bios_can_openbios; + seats[n].can_retail = m->bios_can_scph1001; + seats[n].prefer_openbios = m->bios_prefer_openbios; + seats[n].retail_crc = m->bios_retail_crc; + seats[n].is_host = psx_lobby_member_is_host(m); + ++n; } - - /* Include local offer even before lobby_update echoes it. */ + /* Include the local offer even before lobby_update echoes it. */ if (g_lc.bios_offer.valid) { - saw_peer = 1; - if (g_lc.bios_offer.prefer_openbios) any_prefer_open = 1; - if (!g_lc.bios_offer.can_scph1001) any_cannot_scph = 1; - if (g_lc.is_host && !g_lc.bios_offer.prefer_openbios && - g_lc.bios_offer.can_scph1001) - host_prefer_scph = 1; - } else if (!saw_peer) { - any_cannot_scph = 1; + seats[n].offered = 1; + seats[n].can_openbios = g_lc.bios_offer.can_openbios; + seats[n].can_retail = g_lc.bios_offer.can_scph1001; + seats[n].prefer_openbios = g_lc.bios_offer.prefer_openbios; + seats[n].retail_crc = g_lc.bios_offer.retail_crc; + seats[n].is_host = g_lc.is_host; + ++n; } - /* Capability first: without universal SCPH support, OpenBIOS is required. - * Otherwise the host's retail pick wins over guest OpenBIOS preferences. */ - if (any_cannot_scph || !saw_peer) - strncpy(out, "openbios", out_cap - 1); - else if (host_prefer_scph) - strncpy(out, "scph1001", out_cap - 1); - else if (any_prefer_open) - strncpy(out, "openbios", out_cap - 1); - else - strncpy(out, "scph1001", out_cap - 1); + settle = netplay_bios_settle(seats, n); + if (settle.kind == NETPLAY_BIOS_NONE) { + if (why && why_cap) + netplay_bios_describe_refusal(&settle, seats, n, why, why_cap); + return 1; + } + strncpy(out, netplay_bios_token(settle.kind), out_cap - 1); out[out_cap - 1] = '\0'; + if (out_crc) *out_crc = settle.retail_crc; return 0; } +void psx_lobby_set_last_error(const char *text) +{ + if (!text) text = ""; + strncpy(g_lc.join.last_error, text, sizeof(g_lc.join.last_error) - 1); + g_lc.join.last_error[sizeof(g_lc.join.last_error) - 1] = '\0'; +} + int psx_lobby_set_ready(int ready) { char msg[512]; @@ -4636,15 +4664,21 @@ int psx_lobby_set_ready(int ready) g_lc.memcard_offer.share ? "true" : "false"); } if (g_lc.bios_offer.valid) { + /* retail_crc names the image this peer would boot. Older + * peers ignore the field; the server relays bios_offer as is. */ + char crc[16]; + netplay_bios_format_crc(g_lc.bios_offer.can_scph1001 ? g_lc.bios_offer.retail_crc : 0, + crc, sizeof(crc)); n = snprintf(msg, sizeof(msg), "{\"op\":\"set_ready\",\"ready\":%s," "\"bios_offer\":{\"v\":1,\"prefer\":\"%s\"," - "\"can_openbios\":%s,\"can_scph1001\":%s}%s}", + "\"can_openbios\":%s,\"can_scph1001\":%s," + "\"retail_crc\":\"%s\"}%s}", ready ? "true" : "false", g_lc.bios_offer.prefer_openbios ? "openbios" : "scph1001", g_lc.bios_offer.can_openbios ? "true" : "false", g_lc.bios_offer.can_scph1001 ? "true" : "false", - memcard); + crc, memcard); } else { n = snprintf(msg, sizeof(msg), "{\"op\":\"set_ready\",\"ready\":%s%s}", ready ? "true" : "false", memcard); @@ -4667,9 +4701,16 @@ int psx_lobby_request_start(const PsxLobbyMatchCaps *match_caps) caps_json[0] = '\0'; if (match_caps && match_caps->valid) { caps_local = *match_caps; - if (!caps_local.session_bios[0]) - (void)psx_lobby_settle_session_bios(caps_local.session_bios, - sizeof(caps_local.session_bios)); + if (!caps_local.session_bios[0]) { + char why[192]; + if (psx_lobby_settle_session_bios(caps_local.session_bios, + sizeof(caps_local.session_bios), + &caps_local.session_bios_crc, + why, sizeof(why)) == 1) { + psx_lobby_set_last_error(why); + return -1; + } + } g_lc.match_caps = caps_local; append_match_caps_json(caps_json, sizeof(caps_json), &caps_local); } diff --git a/runtime/src/psx_netplay_rb.c b/runtime/src/psx_netplay_rb.c index 1da3dc7b..744f654d 100644 --- a/runtime/src/psx_netplay_rb.c +++ b/runtime/src/psx_netplay_rb.c @@ -61,6 +61,7 @@ int psx_netplay_rb_take_promote_sweep(void) { return 0; } int psx_netplay_rb_fmv_unlock_grace_active(void) { return 0; } void psx_netplay_rb_pump(void) {} int psx_netplay_rb_boot_dig0_gate(void) { return 0; } +uint32_t psx_netplay_rb_boot_dig0_mismatch_since_ms(void) { return 0; } void psx_netplay_rb_boot_dig0_note_local(uint32_t core) { (void)core; } void psx_netplay_rb_boot_dig0_note_peer(uint32_t core) { (void)core; } int psx_netplay_rb_active(void) { return 0; } @@ -340,6 +341,13 @@ static uint32_t g_boot_dig0_peer_core; static uint8_t g_boot_dig0_peer_valid; static uint32_t g_boot_dig0_last_rexmit_ms; static int g_boot_dig0_wait_kind_logged; +/* psx_host_mono_ms() since when the same pair of dig0s (below) has been known + * and different; 0 = no mismatch. A peer FC can replace the peer latch, so a + * new pair restarts the clock; a pair that holds for the watchdog's grace is + * final and ends the match with its own reason. */ +static uint32_t g_boot_dig0_mismatch_since_ms; +static uint32_t g_boot_dig0_mismatch_local; +static uint32_t g_boot_dig0_mismatch_peer; static int g_follow_nack_pending; static uint32_t g_follow_nack_epoch; static uint32_t g_follow_nack_mismatch; @@ -6277,6 +6285,7 @@ void psx_netplay_rb_cold_reset(void) g_boot_dig0_peer_valid = 0; g_boot_dig0_last_rexmit_ms = 0; g_boot_dig0_wait_kind_logged = 0; + g_boot_dig0_mismatch_since_ms = 0; g_follow_nack_pending = 0; g_follow_nack_epoch = 0; g_follow_nack_mismatch = 0; @@ -9410,6 +9419,13 @@ int psx_netplay_rb_boot_dig0_gate(void) } if (ld != pd) { boot_dig0_rexmit_local(); + if (!g_boot_dig0_mismatch_since_ms || + g_boot_dig0_mismatch_local != ld || g_boot_dig0_mismatch_peer != pd) { + const uint32_t now = (uint32_t)psx_host_mono_ms(); + g_boot_dig0_mismatch_since_ms = now ? now : 1u; + g_boot_dig0_mismatch_local = ld; + g_boot_dig0_mismatch_peer = pd; + } if (!g_boot_dig0_mismatch_logged) { g_boot_dig0_mismatch_logged = 1; fprintf(stderr, @@ -9422,6 +9438,7 @@ int psx_netplay_rb_boot_dig0_gate(void) } g_boot_dig0_mismatch_logged = 0; g_boot_dig0_wait_kind_logged = 0; + g_boot_dig0_mismatch_since_ms = 0; /* One more send after we saw peer dig0 — the slower peer may still be * waiting (guest often syncs first; host then needs this rexmit). */ g_boot_dig0_last_rexmit_ms = 0; @@ -9433,6 +9450,11 @@ int psx_netplay_rb_boot_dig0_gate(void) return 0; } +uint32_t psx_netplay_rb_boot_dig0_mismatch_since_ms(void) +{ + return g_boot_dig0_synced ? 0u : g_boot_dig0_mismatch_since_ms; +} + int psx_netplay_rb_active(void) { return g_rb && rnet_rb_is_active(g_rb); diff --git a/runtime/tests/test_netplay_bios_settle.c b/runtime/tests/test_netplay_bios_settle.c new file mode 100644 index 00000000..c15b9588 --- /dev/null +++ b/runtime/tests/test_netplay_bios_settle.c @@ -0,0 +1,156 @@ +/* Netplay BIOS settle rule and its wire helpers. + * Plain checks, not assert(): Release test builds define NDEBUG. */ +#include "netplay_bios_settle.h" + +#include +#include + +static int failures; +#define CHECK(cond) do { if (!(cond)) { \ + fprintf(stderr, "%s:%d: CHECK failed: %s\n", __FILE__, __LINE__, #cond); \ + ++failures; } } while (0) + +enum { SCPH1001 = 0x37157331u, SCPH5552 = 0xD786F0B9u }; + +/* A seat with a retail dump; OpenBIOS as given. */ +static NetplayBiosSeat retail(uint32_t crc, int can_open, int host) +{ + NetplayBiosSeat s; + memset(&s, 0, sizeof(s)); + s.offered = 1; + s.can_openbios = can_open; + s.can_retail = 1; + s.retail_crc = crc; + s.is_host = host; + return s; +} + +static NetplayBiosSeat openbios_only(int prefer, int host) +{ + NetplayBiosSeat s; + memset(&s, 0, sizeof(s)); + s.offered = 1; + s.can_openbios = 1; + s.prefer_openbios = prefer; + s.is_host = host; + return s; +} + +int main(void) +{ + NetplayBiosSeat seats[3]; + NetplayBiosSettle r; + char text[256]; + + /* The 09-16 defect: SCPH-5552 host, SCPH-1001 guest, both link OpenBIOS. + * Before the fix both booted their own dump. Now: OpenBIOS. */ + seats[0] = retail(SCPH5552, 1, 1); + seats[1] = retail(SCPH1001, 1, 0); + r = netplay_bios_settle(seats, 2); + CHECK(r.kind == NETPLAY_BIOS_OPENBIOS); + CHECK(strcmp(netplay_bios_token(r.kind), "openbios") == 0); + + /* Same pair, but builds that link one retail backend and no OpenBIOS: + * no BIOS both can boot, so the host must refuse, naming both images. */ + seats[0] = retail(SCPH5552, 0, 1); + seats[1] = retail(SCPH1001, 0, 0); + r = netplay_bios_settle(seats, 2); + CHECK(r.kind == NETPLAY_BIOS_NONE); + CHECK(r.seat_a == 0 && r.seat_b == 1); + netplay_bios_describe_refusal(&r, seats, 2, text, sizeof(text)); + CHECK(strstr(text, "SCPH-5552") != NULL); + CHECK(strstr(text, "SCPH-1001") != NULL); + + /* EU vs EU without OpenBIOS works today and must keep working. */ + seats[0] = retail(SCPH5552, 0, 1); + seats[1] = retail(SCPH5552, 0, 0); + r = netplay_bios_settle(seats, 2); + CHECK(r.kind == NETPLAY_BIOS_RETAIL); + CHECK(r.retail_crc == SCPH5552); + CHECK(strcmp(netplay_bios_token(r.kind), "scph1001") == 0); + + /* US vs US with OpenBIOS linked: retail, as before. */ + seats[0] = retail(SCPH1001, 1, 1); + seats[1] = retail(SCPH1001, 1, 0); + r = netplay_bios_settle(seats, 2); + CHECK(r.kind == NETPLAY_BIOS_RETAIL && r.retail_crc == SCPH1001); + + /* A guest prefers OpenBIOS; the host's retail pick still wins. */ + seats[1].prefer_openbios = 1; + r = netplay_bios_settle(seats, 2); + CHECK(r.kind == NETPLAY_BIOS_RETAIL && r.retail_crc == SCPH1001); + + /* The host prefers OpenBIOS: OpenBIOS. */ + seats[0].prefer_openbios = 1; + seats[1].prefer_openbios = 0; + r = netplay_bios_settle(seats, 2); + CHECK(r.kind == NETPLAY_BIOS_OPENBIOS); + + /* Three seats, nobody prefers OpenBIOS: retail when every image matches. */ + seats[0] = retail(SCPH1001, 1, 1); + seats[1] = retail(SCPH1001, 1, 0); + seats[2] = retail(SCPH1001, 1, 0); + r = netplay_bios_settle(seats, 3); + CHECK(r.kind == NETPLAY_BIOS_RETAIL); + + /* One seat has only OpenBIOS: OpenBIOS for everyone who links it. */ + seats[2] = openbios_only(1, 0); + r = netplay_bios_settle(seats, 3); + CHECK(r.kind == NETPLAY_BIOS_OPENBIOS); + + /* An older peer sends no CRC: retail is unsafe, OpenBIOS if all link it. */ + seats[0] = retail(SCPH1001, 1, 1); + seats[1] = retail(0, 1, 0); + r = netplay_bios_settle(seats, 2); + CHECK(r.kind == NETPLAY_BIOS_OPENBIOS); + /* ...and a refusal when the host cannot run OpenBIOS. */ + seats[0].can_openbios = 0; + r = netplay_bios_settle(seats, 2); + CHECK(r.kind == NETPLAY_BIOS_NONE); + CHECK(r.seat_a == 1 && r.seat_b == -1); + netplay_bios_describe_refusal(&r, seats, 2, text, sizeof(text)); + CHECK(strstr(text, "no usable BIOS") != NULL); + + /* A seat that sent no offer at all is an older client: OpenBIOS. */ + memset(&seats[1], 0, sizeof(seats[1])); + seats[0] = retail(SCPH1001, 1, 1); + r = netplay_bios_settle(seats, 2); + CHECK(r.kind == NETPLAY_BIOS_OPENBIOS); + + /* Every seat prefers OpenBIOS but one cannot run it: retail if possible. */ + seats[0] = retail(SCPH5552, 0, 1); + seats[1] = retail(SCPH5552, 1, 0); + seats[1].prefer_openbios = 1; + r = netplay_bios_settle(seats, 2); + CHECK(r.kind == NETPLAY_BIOS_RETAIL && r.retail_crc == SCPH5552); + + /* No seats: OpenBIOS, the old default. */ + r = netplay_bios_settle(seats, 0); + CHECK(r.kind == NETPLAY_BIOS_OPENBIOS); + CHECK(strcmp(netplay_bios_token(NETPLAY_BIOS_NONE), "") == 0); + + /* Wire helpers. */ + netplay_bios_format_crc(SCPH5552, text, sizeof(text)); + CHECK(strcmp(text, "d786f0b9") == 0); + CHECK(netplay_bios_parse_crc(text) == SCPH5552); + CHECK(netplay_bios_parse_crc("D786F0B9") == SCPH5552); + CHECK(netplay_bios_parse_crc(" 37157331\n") == SCPH1001); + netplay_bios_format_crc(0, text, sizeof(text)); + CHECK(text[0] == '\0'); + CHECK(netplay_bios_parse_crc("") == 0); + CHECK(netplay_bios_parse_crc(NULL) == 0); + CHECK(netplay_bios_parse_crc("1234567") == 0); /* too short */ + CHECK(netplay_bios_parse_crc("123456789") == 0); /* too long */ + CHECK(netplay_bios_parse_crc("scph1001") == 0); /* a token, not a CRC */ + netplay_bios_describe_crc(SCPH1001, text, sizeof(text)); + CHECK(strcmp(text, "SCPH-1001") == 0); + netplay_bios_describe_crc(0x12345678u, text, sizeof(text)); + CHECK(strcmp(text, "BIOS 12345678") == 0); + + if (failures) { + fprintf(stderr, "netplay_bios_settle: %d check(s) FAILED\n", failures); + return 1; + } + puts("netplay_bios_settle: PASS"); + return 0; +} diff --git a/runtime/tests/test_netplay_exit_reason.c b/runtime/tests/test_netplay_exit_reason.c new file mode 100644 index 00000000..7f48c1e1 --- /dev/null +++ b/runtime/tests/test_netplay_exit_reason.c @@ -0,0 +1,55 @@ +/* Netplay exit reasons and the boot-mismatch deadline. + * Plain checks, not assert(): Release test builds define NDEBUG. */ +#include "netplay_exit_reason.h" + +#include +#include + +static int failures; +#define CHECK(cond) do { if (!(cond)) { \ + fprintf(stderr, "%s:%d: CHECK failed: %s\n", __FILE__, __LINE__, #cond); \ + ++failures; } } while (0) + +int main(void) +{ + static const char *const ended[] = { + "netplay_boot_mismatch", "netplay_peer_disconnect", "netplay_load_failed", + "netplay_load_stall", "netplay_link_stall", "netplay_admit_stall", + }; + size_t i; + + /* Every origin netplay_soft_exit uses for a failure has a sentence, and it + * fits the lobby client's last_error (192 bytes). */ + for (i = 0; i < sizeof(ended) / sizeof(ended[0]); ++i) { + const char *t = netplay_exit_reason_text(ended[i]); + CHECK(t != NULL); + CHECK(t && strlen(t) > 20 && strlen(t) < 192); + } + CHECK(strstr(netplay_exit_reason_text("netplay_boot_mismatch"), "BIOS") != NULL); + + /* A player who closed the window or pressed Escape needs no reason. */ + CHECK(netplay_exit_reason_text("sdl_window_close") == NULL); + CHECK(netplay_exit_reason_text("netplay_escape") == NULL); + CHECK(netplay_exit_reason_text("netplay_barrier_escape") == NULL); + CHECK(netplay_exit_reason_text("") == NULL); + CHECK(netplay_exit_reason_text(NULL) == NULL); + + /* No mismatch seen: never final. */ + CHECK(!netplay_boot_mismatch_final(0u, 100000u, NETPLAY_BOOT_MISMATCH_GRACE_MS)); + /* Seen, grace not yet over, then over. */ + CHECK(!netplay_boot_mismatch_final(1000u, 3999u, NETPLAY_BOOT_MISMATCH_GRACE_MS)); + CHECK(netplay_boot_mismatch_final(1000u, 4000u, NETPLAY_BOOT_MISMATCH_GRACE_MS)); + /* The 32-bit millisecond clock wraps. */ + CHECK(!netplay_boot_mismatch_final(0xFFFFFF00u, 0x00000100u, NETPLAY_BOOT_MISMATCH_GRACE_MS)); + CHECK(netplay_boot_mismatch_final(0xFFFFFF00u, 0x00000C00u, NETPLAY_BOOT_MISMATCH_GRACE_MS)); + /* The old behaviour was a 20 s admit-stall watchdog; the grace is far + * shorter than that. */ + CHECK(NETPLAY_BOOT_MISMATCH_GRACE_MS < 20000u); + + if (failures) { + fprintf(stderr, "netplay_exit_reason: %d check(s) FAILED\n", failures); + return 1; + } + puts("netplay_exit_reason: PASS"); + return 0; +}