Merge branch 'review/alex-pr445-head-20260930' into review/alex-netplay-bundle-20260930

This commit is contained in:
Matthew Stanley
2026-09-30 13:56:08 -07:00
15 changed files with 934 additions and 151 deletions
+26 -14
View File
@@ -135,25 +135,37 @@ netplay guest sandbox remains `<memcard_dir>/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
+19 -6
View File
@@ -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
+14 -1
View File
@@ -23,6 +23,18 @@ psxrecomp_add_runtime_target(psx-runtime
# (docs/ENHANCEMENTS.md G1.10). Titles get their <exe>_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()
+83
View File
@@ -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 <stddef.h>
#include <stdint.h>
#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 */
+37
View File
@@ -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 <stdint.h>
#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 */
+29 -9
View File
@@ -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: <text>"). 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,
+5
View File
@@ -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);
+2
View File
@@ -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
+213 -75
View File
@@ -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<session>\n[<delay>\n<prediction>\n<rollback>\n
* [<session_bios>\n]]
* [<session_bios>\n[<guest_memcard>\n[<session_bios_crc>\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());
}
}
+142
View File
@@ -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 <stdio.h>
#include <string.h>
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.");
}
+44
View File
@@ -0,0 +1,44 @@
/* Why a netplay match ended, in words. See netplay_exit_reason.h. */
#include "netplay_exit_reason.h"
#include <stddef.h>
#include <string.h>
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;
}
+87 -46
View File
@@ -1,4 +1,5 @@
#include "psx_lobby_client.h"
#include "netplay_bios_settle.h"
#include <ctype.h>
#include <errno.h>
@@ -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);
}
+22
View File
@@ -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);
+156
View File
@@ -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 <stdio.h>
#include <string.h>
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;
}
+55
View File
@@ -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 <stdio.h>
#include <string.h>
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;
}