Merge feat/ice-hub-relay: host relay over ICE for online rooms

Resolved against master: resolve_transport keeps master's single-agent
transport=ice launch (2-player rooms) ahead of psx_transport_decide; match_caps
JSON keeps the mod-plan fields and adds relay_via=ice; recomp-net bumped to
a48d355 (host_ice).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Alex Vanderveen
2026-10-10 04:20:24 -04:00
11 changed files with 1115 additions and 59 deletions
+39 -8
View File
@@ -211,16 +211,47 @@ that identical frame; only what its window shows changes:
## Connectivity: ICE, TURN, SFU, LAN
### Online lobbies (WebSocket + SFU)
### Online lobbies (WebSocket + host relay, SFU fallback)
Online matches go through the lobby at **netplay.retcomm.net**
(WebSocket control plane). Match **UDP pad traffic** for online rooms uses an
**SFU star** (selective forwarding unit on the lobby server): every peer sends
to the SFU; the SFU fans out to other seats. Peers do **not** mesh each other
for game data online.
(WebSocket control plane). Match **UDP pad traffic** is a star; who is the
hub depends on the launch the server sends (`transport`, `relay_via`):
Current lobby policy prefers **always SFU** for online starts so CGNAT /
asymmetric NAT does not strand players on failed ICE attempts.
| Launch | Hub | Transport in the engine |
|--------|-----|-------------------------|
| `transport "host"` + `relay_via "ice"` (default when the host's build has ICE) | **the host** | **ICE hub** — the ICE agents the waiting room already connected are adopted by the session (`rnet_session_start_ice_hub_adopt` on the host, `rnet_session_adopt_ice_agent` on a guest). No UDP port is bound or forwarded, nothing is dialled, no TURN. |
| `transport "host"`, no `relay_via` (older server / host without ICE) | **the host** | LAN path on the host's advertised UDP port (UPnP / NAT-PMP / STUN, probed by guests). |
| otherwise | the lobby server's **SFU** | LAN path to the server's relay endpoint. |
Host relay is a preference of the hosting player (launcher toggles, default
on): `psx_lobby_set_relay_host_pref` publishes `match_caps.relay = "host"`;
`psx_lobby_set_relay_via_ice_pref` adds `relay_via = "ice"` (only when the
build can run ICE). The server falls back to its SFU unless every guest proved
its path to the host.
**Host relay over ICE.** In the waiting room the host runs one ICE agent per
seated guest (host = answerer, guests offer), signalled over the lobby
`signal` op in its own type range (`130 + RNetSignalType`); a guest's proof is
`path_report direct`. `psx_lobby_client.c` carries that glue (the proof logic
is recomp-net's `RNetHostIce`). At launch the client captures the COMPLETED
agents; `ae_np_clear_launch_pending` takes them (before the lobby launch is
cleared) into `psx_netplay`'s stash and `psx_netplay_start` adopts them. A
seat that is not connected, or a spectator in the room, **refuses the launch**:
`join.last_error == "ice_not_connected"`, reason in
`psx_lobby_ice_launch_error()`; a smaller room is never started.
Slot mapping: the lobby seat of each agent is mapped to its **session slot**
from the launch's `port_of_slot` table (`psx_ice_hub_map_seats`,
`netplay_ice_hub.c`), which already folds in `host_spectates` (slot 0 is the
silent host, players start at slot 1) and moved seats (slots follow ascending
lobby seat, the host is always slot 0). Every non-host session slot must get
exactly one agent. `psx_transport_decide` guarantees an ICE-hub launch is
never routed to the single-agent ICE path or the SFU, whatever else the config
carries. Covered ROM-free by `netplay_ice_hub_test`.
Not on this path: spectators (gallery seats), and the waiting-room latency
column in ICE mode (the per-pair RTT probe is disabled there so it cannot race
the proof's `path_report`).
### ICE + TURN
@@ -233,7 +264,7 @@ libjuice is pulled as a **pinned URL tarball** (not `git clone`) so Retro
AppImage / mismatched-libcurl hosts can still configure; offline builds can
vendor `lib/recomp-net/third_party/libjuice` or set `-DRNET_LIBJUICE_ROOT`.
“Force TURN” in the UI can raise delay floors for relay-heavy paths; it
does not replace the SFU online architecture above.
does not change which transport the launch selects (see Online lobbies).
### LAN / Direct IP (P2P star)
+6
View File
@@ -62,6 +62,12 @@ if(BUILD_TESTING)
add_executable(netplay_local_view_test tests/test_netplay_local_view.c)
target_include_directories(netplay_local_view_test PRIVATE include)
add_test(NAME netplay_local_view_test COMMAND netplay_local_view_test)
# Host relay over ICE: transport choice + lobby seat -> session slot map.
add_executable(netplay_ice_hub_test
tests/test_netplay_ice_hub.c
src/netplay_ice_hub.c)
target_include_directories(netplay_ice_hub_test PRIVATE include)
add_test(NAME netplay_ice_hub_test COMMAND netplay_ice_hub_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)
+79
View File
@@ -0,0 +1,79 @@
/* netplay_ice_hub.h -- host relay over ICE: the pure decisions.
*
* Two questions the engine must answer identically on every peer, kept free of
* recomp-net and of the lobby client so they can be tested without a ROM, a
* socket, or an ICE agent:
*
* 1. Which transport does a launch use? psx_transport_decide
* 2. Which SESSION slot does each connected guest agent serve?
* psx_ice_hub_map_seats
*
* Doctrine: a launch with transport_ice_hub binds no UDP port and dials
* nothing. It must never fall through to the single-agent ICE path (that is a
* different negotiation, signalled over the legacy ICE types) or to the
* server's SFU, even when the config also carries force_input_relay or an
* explicit transport=ice. A seat that cannot be mapped refuses the launch
* rather than starting a smaller room.
*/
#ifndef NETPLAY_ICE_HUB_H
#define NETPLAY_ICE_HUB_H
#ifdef __cplusplus
extern "C" {
#endif
typedef enum PsxTransportChoice {
PSX_TRANSPORT_ERROR_NO_PEER = -1, /* online / SFU launch with no endpoint */
PSX_TRANSPORT_LAN_UDP = 0, /* bind + (hub | dial): LAN, host relay, SFU */
PSX_TRANSPORT_SINGLE_ICE = 1, /* one negotiated agent (explicit ice only) */
PSX_TRANSPORT_ICE_HUB = 2 /* adopt the waiting room's connected agents */
} PsxTransportChoice;
/* cfg_transport: PsxNetplayConfig.transport (0 auto, 1 force ICE, 2 force LAN).
* in_online_room: connected to the lobby and seated (a MotK online room).
* ice_built: this build can run ICE agents.
* An ICE hub launch under force-LAN is an error: the launch carries no
* endpoint to bind or dial. */
PsxTransportChoice psx_transport_decide(int cfg_transport, int transport_ice_hub,
int transport_host, int force_input_relay,
int in_online_room, int has_peer_endpoint,
int ice_built);
#define PSX_ICE_HUB_MAX_SEATS 8
/* Why a mapping was refused (for the log / the launch error). */
typedef enum PsxIceHubMapError {
PSX_ICE_HUB_MAP_OK = 0,
PSX_ICE_HUB_MAP_BAD_ARGS,
PSX_ICE_HUB_MAP_SEAT_UNMAPPED, /* a lobby seat holds no session slot */
PSX_ICE_HUB_MAP_SEAT_DUPLICATE, /* two agents for one lobby seat / slot */
PSX_ICE_HUB_MAP_SEAT_MISSING /* a non-host session slot has no agent */
} PsxIceHubMapError;
/* Map each connected guest's LOBBY seat to the SESSION slot it serves.
*
* port_of_slot[s] (s in 0..slot_count-1): the lobby seat session slot s
* drives, -1 for none. Slot 0 is the host and is never an agent seat, so
* the table is searched from slot 1. This is the table the launch already
* carries (PsxNetplayConfig.port_of_slot): it folds in the host-in-gallery
* offset (host_spectates: slot 0 has port -1, players start at slot 1) and
* moved seats (slots follow ascending lobby seat, not lobby seat itself).
* port_map_valid == 0: no table; identity, or lobby seat + 1 with
* host_spectates (players sit one slot above their seat).
*
* Requires exactly slot_count - 1 distinct guest seats, every one mapped to a
* distinct session slot in 1..slot_count-1: a session slot with no agent would
* be a smaller room. Returns 0 and fills session_slots_out[i] (parallel to
* lobby_slots[i]), else -1 with *err_out set. */
int psx_ice_hub_map_seats(const int *port_of_slot, int port_map_valid,
int slot_count, int host_spectates,
const int *lobby_slots, int n,
int *session_slots_out, PsxIceHubMapError *err_out);
const char *psx_ice_hub_map_error_text(PsxIceHubMapError e);
#ifdef __cplusplus
}
#endif
#endif /* NETPLAY_ICE_HUB_H */
+44
View File
@@ -208,6 +208,11 @@ typedef struct PsxLobbyMatchCaps {
* server falls back to its relay unless every guest proved the path
* (recomp_net/host_relay.h). Guests read it to know the room asks. */
int relay_host;
/* 0/1 -- with relay_host: match_caps.relay_via = "ice". The host carries
* the match over the ICE agents the waiting room already connected (one
* per guest); no port is forwarded or advertised. Published by the host
* when psx_lobby_relay_via_ice() is on and this build has ICE. */
int relay_via_ice;
int force_turn; /* 0/1 — ICE relay-only (Force TURN for UDP) */
int rollback; /* 0/1 — invent/rollback netplay (default on) */
/* DualShock-on-multitap-tap hack (0/1). Host-authoritative for the match. */
@@ -276,6 +281,14 @@ typedef struct PsxLobbyJoinInfo {
* (peer-to-peer, signalled over the lobby). No endpoint is dialled and
* force_input_relay is 0 (psx_lobby_launch_transport.h). */
int transport_ice;
/* Launch: transport "host" AND relay_via "ice". transport_host is ALSO 1
* then, so test this first. Nothing is bound or dialled (bind_hostport is
* the placeholder 0.0.0.0:0, peer_hostport empty): the match rides the
* agents psx_lobby_ice_take_* hands over. A launch whose seats are not all
* connected is refused in the client: launch_pending stays 0 and
* last_error is "ice_not_connected" (psx_lobby_ice_launch_error() says
* which seat). */
int transport_ice_hub;
/* 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];
@@ -423,6 +436,37 @@ struct RNetHostRelayStatus;
int psx_lobby_host_relay_status(struct RNetHostRelayStatus *out);
void psx_lobby_host_relay_release_port(void);
/* Host relay over ICE (recomp_net/host_ice.h). The pref is the HOST's, default
* on; it only publishes relay_via "ice" when the build can run ICE. */
void psx_lobby_set_relay_via_ice_pref(int on);
int psx_lobby_relay_via_ice_pref(void);
/* 1 when this build can run ICE agents at all. */
int psx_lobby_ice_available(void);
struct RNetHostIceStatus;
/* Waiting-room per-seat state; 1 while the ICE proof is running. */
int psx_lobby_host_ice_status(struct RNetHostIceStatus *out);
/* Launch handover (transport_ice_hub). The agents are COMPLETED and owned by
* the caller on success: give them to the session or release them with
* psx_lobby_ice_destroy_agent. TAKE BEFORE psx_lobby_clear_launch_pending's
* consumer finishes with the launch -- an untaken bundle is destroyed after
* 60 s, at the next launch, or on leave. lobby_slot is the lobby's seat
* namespace; the engine maps it to its own session slot. */
struct RNetIceAgent;
typedef struct PsxLobbyIceSeat {
int lobby_slot;
char player_id[PSX_LOBBY_ID_LEN];
struct RNetIceAgent *agent;
} PsxLobbyIceSeat;
/* Host: n >= 1 (ownership moves to the caller) or -1. */
int psx_lobby_ice_take_hub(PsxLobbyIceSeat *out, int max);
/* Guest: the one agent connected to the host, or NULL. */
struct RNetIceAgent *psx_lobby_ice_take_guest_agent(void);
/* Why the last launch was refused / a take failed ("" when none). */
const char *psx_lobby_ice_launch_error(void);
void psx_lobby_ice_discard(void);
void psx_lobby_ice_destroy_agent(struct RNetIceAgent *agent);
/* Live member table from lobby_update (and create/join). */
int psx_lobby_member_count(void);
int psx_lobby_member_get(int index, PsxLobbyMember *out);
+22 -1
View File
@@ -98,6 +98,16 @@ typedef struct PsxNetplayConfig {
* carries the match on bind_hostport; guests dial peer_hostport. Takes
* the LAN / hub transport path, not the server-relay one. */
int transport_host;
/* 1 = the launch said transport "host" AND relay_via "ice" (host relay
* over ICE). transport_host is ALSO 1 then; this is tested first. No UDP
* port is bound and nothing is dialled (bind_hostport is a placeholder,
* peer_hostport empty): the match runs over the ICE agents the waiting
* room connected -- host: a hub over one agent per guest; guest: a 1:1
* agent to the host. Neither the single-agent ICE path nor the SFU is
* ever chosen for it. The agents come from psx_netplay_ice_stash_from_lobby
* (taken when the launch was consumed) or, failing that, straight from the
* lobby client. */
int transport_ice_hub;
int force_turn; /* 1 = ICE relay-only (Force TURN for UDP) */
/* 0 = auto (MotK room → ICE, else LAN), 1 = force ICE, 2 = force LAN.
* Env PSX_NET_TRANSPORT=lan|ice overrides. */
@@ -186,13 +196,24 @@ int psx_netplay_local_view(uint32_t display_w, uint32_t display_h,
/*
* Snapshot for diagnostic dumps (starvation_dump.jsonl meta, etc.).
* arch_out: "off" | "p2p" | "host_relay" | "server_relay" (never NULL when
* arch_out: "off" | "p2p" | "host_relay" | "ice_host_relay" | "server_relay" (never NULL when
* arch_cap > 0). Returns 1 when netplay is/was configured this run.
*/
int psx_netplay_diag_snapshot(char *arch_out, size_t arch_cap,
int *max_players_out, int *player_count_out);
int psx_netplay_start(const PsxNetplayConfig *cfg);
/* Host relay over ICE: take the launch's connected agents from the lobby
* client and hold them until psx_netplay_start adopts them. Call where the
* launch is consumed, BEFORE the lobby launch is cleared (the lobby destroys an
* untaken bundle after 60 s). No-op unless the lobby's launch is an ICE hub
* launch with a bundle pending. Returns 1 when agents are now held. */
int psx_netplay_ice_stash_from_lobby(void);
/* Release held agents (a launch that never started). Safe at any time. */
void psx_netplay_ice_stash_discard(void);
/* 1 when held agents await psx_netplay_start. */
int psx_netplay_ice_stash_held(void);
void psx_netplay_shutdown(void);
/* Soft-return rematch / new lobby opponent: make host sim state match a cold
* process peer. Call from session_reboot (rematch) and after BYE teardown.
+1
View File
@@ -495,6 +495,7 @@ set(PSXRECOMP_RUNTIME_SOURCES
${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}/runtime/src/netplay_ice_hub.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
+104
View File
@@ -85,6 +85,7 @@ extern "C" void psx_event_step_conservative_env_init(void);
#include "recomp_net/auth.h"
#include "recomp_netplay_account.h" /* shared recomp-ui account sync */
#include "recomp_net/host_relay.h" /* RNetHostRelayStatus for the launcher relay line */
#include "recomp_net/host_ice.h" /* RNetHostIceStatus: host relay over ICE waiting-room line */
#include "recomp_net/chat_filter.h" /* chat profanity mask, LAN rooms too */
#endif
#include "spu.h"
@@ -10433,6 +10434,11 @@ namespace {
* (Network Settings / Lobby Settings, persisted there) and pushes it
* through relay_host_set; default on. */
int g_lnch_relay_host = 1;
/* Host relay over ICE (2026-10-03): with the host relay on, publish
* relay_via "ice" so guests reach the host through ICE and nobody forwards
* a port. Default on; the lobby client mirrors it (psx_lobby_set_
* relay_via_ice_pref) and only publishes it when the build can run ICE. */
int g_lnch_relay_via_ice = 1;
/* Lobby default on; host “Disable Rollback” clears this → delay_sync. */
int g_lnch_rollback = 1;
int g_lnch_multitap_analog = 1;
@@ -13104,11 +13110,87 @@ namespace {
ae_np_push_match_caps(nullptr); /* no-op unless hosting online */
return 0;
}
/* ICE-mode waiting-room text, from the agents' own state. Never says
* "port": no port is held in this mode. 1 = `out` holds the line. */
static const char* ae_np_ice_state_word(int state) {
switch (state) {
case RNET_ICE_STATE_COMPLETED: return "connected";
case RNET_ICE_STATE_FAILED: return "failed";
default: return "connecting";
}
}
static void ae_np_ice_peer_text(const RNetHostIcePeerStatus& p, char* out, size_t cap) {
if (p.state == RNET_ICE_STATE_COMPLETED && p.path[0])
std::snprintf(out, cap, "connected (%s)", p.path);
else
std::snprintf(out, cap, "%s", ae_np_ice_state_word(p.state));
}
static int ae_np_ice_status_line(char* out, size_t out_cap) {
RNetHostIceStatus st;
std::memset(&st, 0, sizeof(st));
if (!psx_lobby_host_ice_status(&st)) return 0;
if (st.role == 2) {
char w[40];
if (st.peer_count < 1) {
std::snprintf(out, out_cap, "Connecting to the host...");
return 1;
}
ae_np_ice_peer_text(st.peer[0], w, sizeof(w));
if (st.peer[0].state == RNET_ICE_STATE_FAILED)
std::snprintf(out, out_cap,
"Connection to the host failed. Check that your firewall "
"allows the game, then rejoin.");
else
std::snprintf(out, out_cap, "Connection to the host: %s.", w);
return 1;
}
if (st.peer_count < 1) {
std::snprintf(out, out_cap, "Waiting for guests to connect through you...");
return 1;
}
size_t n = 0;
for (int i = 0; i < st.peer_count && i < RNET_HOST_ICE_MAX_PEERS; ++i) {
char w[40];
ae_np_ice_peer_text(st.peer[i], w, sizeof(w));
const int k = std::snprintf(out + n, out_cap - n, "%sSeat %d: %s",
i ? "\n" : "", st.peer[i].slot + 1, w);
if (k < 0 || (size_t)k >= out_cap - n) break;
n += (size_t)k;
}
if (st.completed < st.peer_count && n < out_cap)
std::snprintf(out + n, out_cap - n,
"\nPlay starts once every seat is connected. A seat that stays "
"on failed can't reach you; check its firewall.");
return 1;
}
int ae_np_relay_via_ice_get(void*) {
if (g_lnch_hosting_lan || g_lnch_joined_lan || !psx_lobby_ice_available()) return 0;
if (psx_lobby_in_lobby()) {
const PsxLobbyMatchCaps* caps = psx_lobby_match_caps();
if (caps && caps->valid) return (caps->relay_host && caps->relay_via_ice) ? 1 : 0;
}
return g_lnch_relay_via_ice;
}
int ae_np_relay_via_ice_set(void*, int on) {
g_lnch_relay_via_ice = on ? 1 : 0;
psx_lobby_set_relay_via_ice_pref(g_lnch_relay_via_ice);
if (!g_lnch_hosting_lan && !g_lnch_joined_lan)
ae_np_push_match_caps(nullptr); /* no-op unless hosting online */
return 0;
}
int ae_np_relay_status(void*, char* out, size_t out_cap) {
if (!out || !out_cap) return 0;
out[0] = '\0';
#if defined(PSX_HAS_RECOMP_NET)
if (g_lnch_hosting_lan || g_lnch_joined_lan || !psx_lobby_in_lobby()) return 0;
/* ICE mode replaces the port/reachability guidance entirely: the
* legacy status would talk about a forwarded port this room never opens. */
if (ae_np_ice_status_line(out, out_cap)) return 1;
{
const PsxLobbyMatchCaps* c = psx_lobby_match_caps();
if (c && c->valid && c->relay_host && c->relay_via_ice)
return 0; /* ICE room, agents not up yet: say nothing */
}
RNetHostRelayStatus st;
if (!psx_lobby_host_relay_status(&st)) return 0;
if (st.role == 1) {
@@ -15138,6 +15220,12 @@ namespace {
return g_lnch_pending_direct_launch.enabled || psx_lobby_launch_pending();
}
void ae_np_clear_launch_pending(void*) {
/* Host relay over ICE: the connected agents must be TAKEN before the
* launch is cleared (the lobby destroys an untaken bundle after 60 s
* and the game boots well after this). Held by psx_netplay until
* psx_netplay_start adopts them. No-op for every other launch. */
if (!g_lnch_pending_direct_launch.enabled && psx_lobby_launch_pending())
(void)psx_netplay_ice_stash_from_lobby();
g_lnch_pending_direct_launch = {};
psx_lobby_clear_launch_pending();
}
@@ -15173,6 +15261,16 @@ namespace {
return g_lnch_lan_endpoint.empty() ? nullptr : g_lnch_lan_endpoint.c_str();
}
/* Host relay over ICE: transport "host" + relay_via "ice". Decided from the
* lobby's launch (the same join info fill_launch read), and only for a
* launch the lobby filled: a LAN / direct launch is never an ICE hub, and
* transport_host is ALSO set for an ICE hub, so it gates the lobby read. */
static int ae_np_launch_is_ice_hub(const RecompLauncherCNetplayLaunch& l) {
if (!l.transport_host || g_lnch_hosting_lan || g_lnch_joined_lan) return 0;
const PsxLobbyJoinInfo* ji = psx_lobby_join_info();
return (ji && ji->ok && ji->transport_ice_hub) ? 1 : 0;
}
int ae_np_fill_launch(void*, RecompLauncherCNetplayLaunch* out) {
if (!out) return 0;
if (g_lnch_pending_direct_launch.enabled) {
@@ -15526,6 +15624,10 @@ namespace {
g_lnch_netplay_callbacks.relay_host_get = ae_np_relay_host_get;
g_lnch_netplay_callbacks.relay_host_set = ae_np_relay_host_set;
g_lnch_netplay_callbacks.relay_status = ae_np_relay_status;
#endif
#if defined(RECOMP_LAUNCHER_HAS_HOST_RELAY_ICE)
g_lnch_netplay_callbacks.relay_via_ice_get = ae_np_relay_via_ice_get;
g_lnch_netplay_callbacks.relay_via_ice_set = ae_np_relay_via_ice_set;
#endif
g_lnch_netplay_callbacks.memcard_offer_set = ae_np_memcard_offer_set;
g_lnch_netplay_callbacks.guest_memcard_get = ae_np_guest_memcard_get;
@@ -17973,6 +18075,7 @@ int main(int argc, char** argv) {
net_cfg.force_input_relay = ls.netplay_launch.force_input_relay ? 1 : 0;
#if defined(RECOMP_LAUNCHER_HAS_HOST_RELAY)
net_cfg.transport_host = ls.netplay_launch.transport_host ? 1 : 0;
net_cfg.transport_ice_hub = ae_np_launch_is_ice_hub(ls.netplay_launch);
#endif
net_cfg.force_turn = ls.netplay_launch.force_turn ? 1 : 0;
net_cfg.rollback = ls.netplay_launch.rollback ? 1 : 0;
@@ -20108,6 +20211,7 @@ soft_return_lobby:
net_cfg.force_input_relay = ls.netplay_launch.force_input_relay ? 1 : 0;
#if defined(RECOMP_LAUNCHER_HAS_HOST_RELAY)
net_cfg.transport_host = ls.netplay_launch.transport_host ? 1 : 0;
net_cfg.transport_ice_hub = ae_np_launch_is_ice_hub(ls.netplay_launch);
#endif
net_cfg.force_turn = ls.netplay_launch.force_turn ? 1 : 0;
net_cfg.rollback = ls.netplay_launch.rollback ? 1 : 0;
+78
View File
@@ -0,0 +1,78 @@
/* netplay_ice_hub.c -- see netplay_ice_hub.h. No recomp-net, no sockets. */
#include "netplay_ice_hub.h"
#include <stddef.h>
PsxTransportChoice psx_transport_decide(int cfg_transport, int transport_ice_hub,
int transport_host, int force_input_relay,
int in_online_room, int has_peer_endpoint,
int ice_built)
{
/* Hub first: it implies transport_host, and it is decided by the launch
* alone. Neither force_input_relay nor an explicit transport=ice may
* redirect it onto the SFU or the single-agent path. */
if (transport_ice_hub) {
if (cfg_transport == 2 || !ice_built) return PSX_TRANSPORT_ERROR_NO_PEER;
return PSX_TRANSPORT_ICE_HUB;
}
if (cfg_transport == 2) return PSX_TRANSPORT_LAN_UDP;
/* Legacy host relay (advertised UDP port): the LAN path, not the SFU. */
if (transport_host) return PSX_TRANSPORT_LAN_UDP;
if (force_input_relay || in_online_room)
return has_peer_endpoint ? PSX_TRANSPORT_LAN_UDP : PSX_TRANSPORT_ERROR_NO_PEER;
if (cfg_transport == 1 && ice_built) return PSX_TRANSPORT_SINGLE_ICE;
return PSX_TRANSPORT_LAN_UDP;
}
const char *psx_ice_hub_map_error_text(PsxIceHubMapError e)
{
switch (e) {
case PSX_ICE_HUB_MAP_OK: return "ok";
case PSX_ICE_HUB_MAP_BAD_ARGS: return "bad arguments";
case PSX_ICE_HUB_MAP_SEAT_UNMAPPED: return "a connected seat holds no session slot";
case PSX_ICE_HUB_MAP_SEAT_DUPLICATE: return "two connections for one seat";
case PSX_ICE_HUB_MAP_SEAT_MISSING: return "a session slot has no connection";
}
return "unknown";
}
static int fail(PsxIceHubMapError *err_out, PsxIceHubMapError e)
{
if (err_out) *err_out = e;
return -1;
}
int psx_ice_hub_map_seats(const int *port_of_slot, int port_map_valid,
int slot_count, int host_spectates,
const int *lobby_slots, int n,
int *session_slots_out, PsxIceHubMapError *err_out)
{
int i, s;
unsigned used = 0;
if (err_out) *err_out = PSX_ICE_HUB_MAP_OK;
if (!lobby_slots || !session_slots_out || n < 1 || n > PSX_ICE_HUB_MAX_SEATS ||
slot_count < 2 || slot_count > 32 || (port_map_valid && !port_of_slot))
return fail(err_out, PSX_ICE_HUB_MAP_BAD_ARGS);
for (i = 0; i < n; ++i) {
int found = -1;
if (lobby_slots[i] < 0) return fail(err_out, PSX_ICE_HUB_MAP_SEAT_UNMAPPED);
for (s = 0; s < i; ++s)
if (lobby_slots[s] == lobby_slots[i])
return fail(err_out, PSX_ICE_HUB_MAP_SEAT_DUPLICATE);
if (port_map_valid) {
for (s = 1; s < slot_count; ++s)
if (port_of_slot[s] == lobby_slots[i]) { found = s; break; }
} else {
found = lobby_slots[i] + (host_spectates ? 1 : 0);
if (found < 1 || found >= slot_count) found = -1;
}
if (found < 0) return fail(err_out, PSX_ICE_HUB_MAP_SEAT_UNMAPPED);
if (used & (1u << found)) return fail(err_out, PSX_ICE_HUB_MAP_SEAT_DUPLICATE);
used |= 1u << found;
session_slots_out[i] = found;
}
/* Every non-host session slot needs its agent. */
for (s = 1; s < slot_count; ++s)
if (!(used & (1u << s))) return fail(err_out, PSX_ICE_HUB_MAP_SEAT_MISSING);
return 0;
}
+356 -4
View File
@@ -7,6 +7,7 @@
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <stddef.h>
#include <string.h>
#include <time.h>
@@ -51,6 +52,15 @@ void psx_lobby_set_relay_host_pref(int on) { (void)on; }
int psx_lobby_relay_host_pref(void) { return 0; }
int psx_lobby_host_relay_status(struct RNetHostRelayStatus *out) { (void)out; return 0; }
void psx_lobby_host_relay_release_port(void) {}
void psx_lobby_set_relay_via_ice_pref(int on) { (void)on; }
int psx_lobby_relay_via_ice_pref(void) { return 0; }
int psx_lobby_ice_available(void) { return 0; }
int psx_lobby_host_ice_status(struct RNetHostIceStatus *out) { (void)out; return 0; }
int psx_lobby_ice_take_hub(PsxLobbyIceSeat *out, int max) { (void)out; (void)max; return -1; }
struct RNetIceAgent *psx_lobby_ice_take_guest_agent(void) { return NULL; }
const char *psx_lobby_ice_launch_error(void) { return ""; }
void psx_lobby_ice_discard(void) {}
void psx_lobby_ice_destroy_agent(struct RNetIceAgent *agent) { (void)agent; }
int psx_lobby_kick(int slot) { (void)slot; return -1; }
void psx_lobby_set_allow_spectators(int allow) { (void)allow; }
int psx_lobby_allow_spectators_pref(void) { return 0; }
@@ -209,6 +219,7 @@ void psx_lobby_clear_launch_pending(void) {}
#include "rnet_sha1.h"
#include "recomp_net/address.h"
#include "recomp_net/host_relay.h"
#include "recomp_net/host_ice.h"
#include "recomp_net/ice.h"
#include "recomp_net/ice_rtt.h"
#include "recomp_net/lan_beacon.h"
@@ -1134,6 +1145,7 @@ static void match_caps_clear(PsxLobbyMatchCaps *c)
* is recomp-net's and only the glue lives here. */
static RNetHostRelay *g_host_relay;
static int g_relay_host_pref = 1;
static int room_relays_via_ice(void);
void psx_lobby_set_relay_host_pref(int on) { g_relay_host_pref = on ? 1 : 0; }
int psx_lobby_relay_host_pref(void) { return g_relay_host_pref; }
@@ -1169,7 +1181,8 @@ static void host_relay_step(void)
v.is_host = g_lc.is_host ? 1 : 0;
v.active = psx_lobby_connected() && g_lc.in_lobby && !g_lc.launch_pending &&
!g_lc.join.local_is_spectator &&
g_lc.match_caps.valid && g_lc.match_caps.relay_host;
g_lc.match_caps.valid && g_lc.match_caps.relay_host &&
!room_relays_via_ice(); /* ICE mode: no port, no set_host_endpoint */
v.bind_port = (unsigned short)lobby_bind_port();
if (v.is_host && !v.bind_port) v.active = 0;
v.host_endpoint = g_lc.join.host_endpoint;
@@ -1196,6 +1209,259 @@ static void host_relay_leave(void)
rnet_host_relay_leave(g_host_relay);
}
/* ---- host relay over ICE (recomp_net/host_ice.h) -------------------------
* psxrecomp's copy of recomp-net's rnet_lobby_client.c wiring (decision: the
* fork is not a thin copy -- own member table, spectator gallery, BIOS /
* memcard offers, signal queue -- so the glue is ported here and the proof
* logic is recomp-net's RNetHostIce, exactly as RNetHostRelay is above).
*
* The pref is the HOST's, default ON. In ICE mode the waiting-room proof is
* one agent pair per guest instead of a probe of an advertised UDP port, so
* host_relay_step stands down (no UPnP / STUN port, no set_host_endpoint). */
static RNetHostIce *g_host_ice;
static int g_relay_via_ice_pref = 1;
void psx_lobby_set_relay_via_ice_pref(int on) { g_relay_via_ice_pref = on ? 1 : 0; }
int psx_lobby_relay_via_ice_pref(void) { return g_relay_via_ice_pref; }
int psx_lobby_ice_available(void) { return rnet_host_ice_available(); }
/* The room's published caps ask for ICE as the host relay's path. */
static int room_relays_via_ice(void)
{
return g_lc.match_caps.valid && g_lc.match_caps.relay_host &&
g_lc.match_caps.relay_via_ice;
}
/* Agents handed over at launch until the engine takes them (or the room /
* launch is abandoned). Kept apart from g_lc so a refused launch can still say
* why after the lobby state is reset. */
typedef struct {
int valid; /* a bundle awaits take */
int is_host;
int n;
PsxLobbyIceSeat seat[RNET_HOST_ICE_MAX_PEERS]; /* host */
RNetIceAgent *guest; /* guest: connected to the host */
uint64_t born_ms;
char error[200];
} PsxIceLaunch;
static PsxIceLaunch g_il;
static uint64_t ice_now_ms(void)
{
return (uint64_t)psx_host_mono_ms();
}
static void ice_launch_discard(void)
{
int i;
for (i = 0; i < g_il.n; ++i)
if (g_il.seat[i].agent) rnet_host_ice_destroy_agent(g_il.seat[i].agent);
if (g_il.guest) rnet_host_ice_destroy_agent(g_il.guest);
memset(&g_il, 0, offsetof(PsxIceLaunch, error));
}
static void host_ice_leave(void)
{
ice_launch_discard();
g_il.error[0] = '\0';
rnet_host_ice_destroy(&g_host_ice);
}
/* Lobby `signal` addressed to one player (the legacy sender broadcasts). */
static int send_signal_to(const char *to_player_id, int type, int flag, const char *text)
{
char esc[4096];
char to_esc[JSON_ESC_CAP(PSX_LOBBY_ID_LEN)];
char lid_esc[JSON_ESC_CAP(PSX_LOBBY_ID_LEN)];
char msg[4608];
const char *lid;
if (!psx_lobby_connected() || !g_lc.in_lobby) return -1;
lid = g_lc.join.lobby_id[0] ? g_lc.join.lobby_id : "";
json_escape(text ? text : "", esc, sizeof(esc));
json_escape(to_player_id ? to_player_id : "", to_esc, sizeof(to_esc));
json_escape(lid, lid_esc, sizeof(lid_esc));
snprintf(msg, sizeof(msg),
"{\"op\":\"signal\",\"lobby_id\":\"%s\",\"to_player_id\":\"%s\","
"\"type\":%d,\"flag\":%d,\"text\":\"%s\"}",
lid_esc, to_esc, type, flag, esc);
if (g_lc.handshake_done && g_lc.fd >= 0)
return rnet_ws_write_text(g_lc.fd, msg, 1) < 0 ? -1 : 0;
queue_send(msg);
return 0;
}
static int host_ice_send_signal(const char *to, int type, int flag, const char *text,
void *ctx)
{
(void)ctx;
return send_signal_to(to, type, flag, text);
}
/* Every pump, beside host_relay_step: the room decides which agents exist. */
static void host_ice_step(void)
{
RNetHostIceView v;
RNetHostIcePeer peers[RNET_HOST_ICE_MAX_PEERS];
int n = 0, i;
if (!g_host_ice) {
g_host_ice = rnet_host_ice_create();
if (!g_host_ice) return;
}
/* A bundle nobody took (the engine declined the launch) must not hold
* sockets for the rest of the session. */
if (g_il.valid && ice_now_ms() - g_il.born_ms > 60000u)
ice_launch_discard();
memset(&v, 0, sizeof(v));
v.is_host = g_lc.is_host ? 1 : 0;
v.local_slot = local_member_slot();
v.active = psx_lobby_connected() && g_lc.in_lobby && !g_lc.launch_pending &&
!g_lc.join.local_is_spectator && room_relays_via_ice() &&
v.local_slot >= 0;
if (v.active) {
if (v.is_host) {
for (i = 0; i < g_lc.member_count && n < RNET_HOST_ICE_MAX_PEERS; ++i) {
const PsxLobbyMember *m = &g_lc.members[i];
if (m->is_spectator || !m->player_id[0] ||
strcmp(m->player_id, g_lc.player_id) == 0)
continue;
peers[n].player_id = m->player_id;
peers[n].slot = m->slot;
n++;
}
} else {
const int hs = member_slot_for_player(g_lc.host_player_id);
if (g_lc.host_player_id[0] && hs >= 0) {
peers[0].player_id = g_lc.host_player_id;
peers[0].slot = hs;
n = 1;
} else {
v.active = 0;
}
}
}
v.peers = peers;
v.peer_count = n;
/* STUN only: hub agents never use TURN, so the Coturn mint is not used. */
if (g_lc.turn.valid && g_lc.turn.stun_host[0]) {
v.stun_host = g_lc.turn.stun_host;
v.stun_port = (unsigned short)(g_lc.turn.stun_port > 0 ? g_lc.turn.stun_port : 3478);
}
v.send_signal = host_ice_send_signal;
v.send_json = host_relay_send;
rnet_host_ice_update(g_host_ice, &v);
}
int psx_lobby_host_ice_status(struct RNetHostIceStatus *out)
{
if (!out) return 0;
rnet_host_ice_status(g_host_ice, out);
return out->role != 0;
}
/* Build the handover from the agents the waiting room already connected. A
* seat that is not COMPLETED is a refused launch with a reason, never a
* smaller room. Spectators are not on the ICE path: any gallery seat refuses
* the launch (host), and a spectating guest has no agent (it refuses itself). */
static void ice_launch_capture(void)
{
int i;
ice_launch_discard();
g_il.error[0] = '\0';
g_il.is_host = g_lc.is_host ? 1 : 0;
g_il.born_ms = ice_now_ms();
if (!g_host_ice) {
snprintf(g_il.error, sizeof(g_il.error),
"no ICE agents exist (this build cannot run ICE, or the room was "
"not seated)");
return;
}
if (g_il.is_host) {
for (i = 0; i < g_lc.member_count; ++i) {
const PsxLobbyMember *m = &g_lc.members[i];
RNetIceAgent *a;
if (!m->player_id[0] || strcmp(m->player_id, g_lc.player_id) == 0)
continue;
if (m->is_spectator) {
snprintf(g_il.error, sizeof(g_il.error),
"\"%s\" is a spectator; spectators are not on the ICE "
"host-relay path",
m->display_name[0] ? m->display_name : m->player_id);
break;
}
if (g_il.n >= RNET_HOST_ICE_MAX_PEERS) {
snprintf(g_il.error, sizeof(g_il.error), "too many guests for the ICE hub");
break;
}
a = rnet_host_ice_take_completed(g_host_ice, m->player_id);
if (!a) {
snprintf(g_il.error, sizeof(g_il.error),
"\"%s\" (seat %d) is not connected over ICE",
m->display_name[0] ? m->display_name : m->player_id, m->slot);
break;
}
g_il.seat[g_il.n].lobby_slot = m->slot;
snprintf(g_il.seat[g_il.n].player_id, sizeof(g_il.seat[g_il.n].player_id),
"%s", m->player_id);
g_il.seat[g_il.n].agent = a;
g_il.n++;
}
if (!g_il.error[0] && g_il.n == 0)
snprintf(g_il.error, sizeof(g_il.error), "no guest is seated");
} else {
g_il.guest = rnet_host_ice_take_completed(g_host_ice, g_lc.host_player_id);
if (!g_il.guest)
snprintf(g_il.error, sizeof(g_il.error),
"this client is not connected to the host over ICE");
}
if (g_il.error[0]) {
char why[sizeof(g_il.error)];
snprintf(why, sizeof(why), "%s", g_il.error);
ice_launch_discard();
snprintf(g_il.error, sizeof(g_il.error), "%s", why);
return;
}
g_il.valid = 1;
}
int psx_lobby_ice_take_hub(PsxLobbyIceSeat *out, int max)
{
int n;
if (!out || max < 1) return -1;
if (!g_il.valid || !g_il.is_host) {
if (!g_il.error[0])
snprintf(g_il.error, sizeof(g_il.error), "no ICE hub handover is pending");
return -1;
}
if (max < g_il.n) {
snprintf(g_il.error, sizeof(g_il.error), "output array too small for %d seats", g_il.n);
return -1;
}
n = g_il.n;
memcpy(out, g_il.seat, sizeof(out[0]) * (size_t)n);
memset(&g_il, 0, offsetof(PsxIceLaunch, error)); /* ownership is the caller's */
return n;
}
RNetIceAgent *psx_lobby_ice_take_guest_agent(void)
{
RNetIceAgent *a;
if (!g_il.valid || g_il.is_host || !g_il.guest) {
if (!g_il.error[0])
snprintf(g_il.error, sizeof(g_il.error), "no ICE guest handover is pending");
return NULL;
}
a = g_il.guest;
memset(&g_il, 0, offsetof(PsxIceLaunch, error));
return a;
}
const char *psx_lobby_ice_launch_error(void) { return g_il.error; }
void psx_lobby_ice_discard(void) { ice_launch_discard(); }
void psx_lobby_ice_destroy_agent(RNetIceAgent *agent)
{
if (agent) rnet_host_ice_destroy_agent(agent);
}
static int json_extract_object(const char *json, const char *key, char *out, size_t out_cap);
static void parse_match_caps_object(const char *obj, PsxLobbyMatchCaps *out);
static void ingest_match_caps_from_json(const char *json);
@@ -1732,6 +1998,12 @@ static void parse_match_caps_object(const char *obj, PsxLobbyMatchCaps *out)
json_get_str(obj, "relay", relay, sizeof(relay));
out->relay_host = strcmp(relay, "host") == 0 ? 1 : 0;
}
{
char via[12];
via[0] = '\0';
json_get_str(obj, "relay_via", via, sizeof(via));
out->relay_via_ice = strcmp(via, "ice") == 0 ? 1 : 0;
}
out->force_turn = json_get_bool(obj, "force_turn", 0);
/* Absent field → delay-sync (older hosts). New hosts always publish explicit. */
out->rollback = json_get_bool(obj, "rollback", 0);
@@ -1818,7 +2090,12 @@ static int append_match_caps_json(char *dst, size_t dst_cap, const PsxLobbyMatch
caps->auto_skip_fmv ? "true" : "false",
caps->input_delay,
caps->input_prediction,
caps->relay_host ? "\"relay\":\"host\"," : "",
/* only when asked: absent reads as "the SFU, as always" */
caps->relay_host
? ((g_relay_via_ice_pref && rnet_host_ice_available())
? "\"relay\":\"host\",\"relay_via\":\"ice\","
: "\"relay\":\"host\",")
: "",
caps->force_input_relay ? "true" : "false",
caps->force_turn ? "true" : "false",
caps->rollback ? "true" : "false",
@@ -1927,7 +2204,11 @@ static void fill_peer_bind_from_join(void)
const int host_hub = (g_lc.is_host && seats >= 3 && !force_relay) ? 1 : 0;
memset(j->bind_hostport, 0, sizeof(j->bind_hostport));
memset(j->peer_hostport, 0, sizeof(j->peer_hostport));
if (force_relay) {
if (j->transport_ice_hub) {
/* Nothing to bind or dial: the agents are already connected. Kept
* non-empty so a consumer's "usable bind" check passes. */
strncpy(j->bind_hostport, "0.0.0.0:0", sizeof(j->bind_hostport) - 1);
} else if (force_relay) {
/* Everyone dials the lobby-server UDP relay — ephemeral local bind
* (same as LAN guests) so same-PC multi-instance doesn't collide. */
strncpy(j->bind_hostport, "0.0.0.0:0", sizeof(j->bind_hostport) - 1);
@@ -3169,6 +3450,8 @@ static void handle_server_json(const char *json)
}
if (strcmp(op, "created") == 0) {
psx_lobby_chat_clear();
ice_launch_discard();
g_lc.join.transport_ice_hub = 0;
g_lc.in_lobby = 1;
g_lc.is_host = 1;
g_lc.join.ok = 1;
@@ -3220,6 +3503,8 @@ static void handle_server_json(const char *json)
(g_am.state == PSX_LOBBY_AUTOMATCH_ACCEPTED ||
g_am.state == PSX_LOBBY_AUTOMATCH_FOUND);
automatch_reset_queue_state();
ice_launch_discard();
g_lc.join.transport_ice_hub = 0;
g_lc.in_lobby = 1;
g_lc.is_host = 0;
g_lc.join.ok = 1;
@@ -3274,10 +3559,23 @@ static void handle_server_json(const char *json)
g_lc.join.transport_host = strcmp(transport_kind, "host") == 0 ? 1 : 0;
g_lc.join.transport_ice = strcmp(transport_kind, "ice") == 0 ? 1 : 0;
rnet_host_relay_release_port(g_host_relay);
ice_launch_discard(); /* a previous launch's agents are never reused */
g_il.error[0] = '\0';
g_lc.join.player_count = json_get_int(json, "player_count", g_lc.join.player_count);
g_lc.join.max_slots = json_get_int(json, "max_slots", g_lc.join.max_slots);
g_lc.join.session_id = (uint32_t)json_get_int(json, "session_id", (int)g_lc.join.session_id);
ingest_match_caps_from_json(json);
/* relay_via is decided by THIS launch alone (top level, or its
* match_caps), never by caps carried over from an earlier message. */
{
char via[12];
via[0] = '\0';
json_get_str(json, "relay_via", via, sizeof(via));
g_lc.match_caps.relay_via_ice = strcmp(via, "ice") == 0 ? 1 : 0;
if (g_lc.match_caps.relay_via_ice) g_lc.match_caps.valid = 1;
}
g_lc.join.transport_ice_hub =
(g_lc.join.transport_host && g_lc.match_caps.relay_via_ice) ? 1 : 0;
/* Prefer explicit relay_endpoint when the server opened input relay.
* Apply after caps ingest: omitted force_input_relay must not leave
* hosts on the hub path while guests dial the relay. */
@@ -3309,6 +3607,32 @@ static void handle_server_json(const char *json)
}
fill_peer_bind_from_join();
parse_slots_array(json);
if (g_lc.join.transport_ice_hub) {
/* Host relay over ICE: no endpoints. Capture the connected agents
* NOW, before the next pump's view (launch_pending) tears them
* down; the engine takes them. A seat that is not connected is a
* refused launch with a reason, never a smaller room. */
ice_launch_capture();
if (g_il.error[0]) {
strncpy(g_lc.join.last_error, "ice_not_connected",
sizeof(g_lc.join.last_error) - 1);
g_lc.join.last_error[sizeof(g_lc.join.last_error) - 1] = '\0';
g_lc.launch_pending = 0;
fprintf(stderr, "psx_lobby: launch refused - %s\n", g_il.error);
fflush(stderr);
return;
}
g_lc.join.last_error[0] = '\0';
g_lc.launch_pending = 1;
g_lc.ice_rtt_suspended = 1;
lobby_ice_rtt_close();
lobby_rtt_close();
psx_lobby_clear_signals();
g_lc.ice_signal_accept = 1;
rnet_lan_beacon_close(&g_lan_beacon_pub);
lobby_host_advertise_reset();
return;
}
/* §108: online start always SFU — every peer dials relay_endpoint.
* Legacy ice_p2p from an old server is refused (need SFU). */
{
@@ -3435,6 +3759,26 @@ static void handle_server_json(const char *json)
* rnet_rtt_probe (peer path). REPORT still accepted from peers. */
if (type == PSX_LOBBY_SIG_RTT_PING || type == PSX_LOBBY_SIG_RTT_PONG)
return;
if (rnet_host_ice_sig_is_ours(type)) {
/* Host-relay-over-ICE waiting-room agents. Dispatched by the
* sender's player id, which the SERVER stamps (a client cannot
* choose it); only a seated player is a peer, and the seat is
* looked up here, never read from the payload. Never queued for
* the legacy session-ICE drain. */
const int from_slot = member_slot_for_player(from);
int from_spec = 0, mi;
for (mi = 0; mi < g_lc.member_count; ++mi)
if (strcmp(g_lc.members[mi].player_id, from) == 0)
from_spec = g_lc.members[mi].is_spectator ? 1 : 0;
if (!g_host_ice || !from[0] || from_slot < 0 || from_spec) {
fprintf(stderr, "psx_lobby: host-ice signal from \"%s\" refused "
"(not a seated player)\n", from);
return;
}
(void)rnet_host_ice_push_signal(g_host_ice, from, from_slot, type, flag,
text_buf);
return;
}
if (type == PSX_LOBBY_SIG_RTT_REPORT) {
int slot = rtt_index_for_slot(member_slot_for_player(from));
int ms = (int)strtol(text_buf, NULL, 10);
@@ -3491,6 +3835,7 @@ static void handle_server_json(const char *json)
if (strcmp(op, "lobby_closed") == 0 || strcmp(op, "left") == 0 ||
strcmp(op, "kicked") == 0) {
psx_lobby_chat_clear();
ice_launch_discard();
/* Leaving a room must never leave a queue ticket looking live, and
* no seat means no automatch room either. */
automatch_reset_queue_state();
@@ -3911,6 +4256,7 @@ int psx_lobby_connect(const char *ws_url)
void psx_lobby_disconnect(void)
{
host_relay_leave();
host_ice_leave(); /* agents + any launch bundle */
/* Never block the UI on DNS/connect — cancel and let pump reap. */
lobby_cancel_connect_async();
automatch_on_connection_reset();
@@ -4127,8 +4473,11 @@ static void lobby_ice_rtt_tick(void)
RNetIceState st;
char path[16];
/* Host relay over ICE: the host-ice agents ARE the waiting-room ICE
* session and own the path_report; a second per-pair probe would race it
* (and its "relay"/"fail" reports would undo the proof). */
if (!g_lc.in_lobby || g_lc.launch_pending || g_lc.ice_rtt_suspended ||
g_lc.member_count < 2) {
g_lc.member_count < 2 || room_relays_via_ice()) {
lobby_ice_rtt_close();
return;
}
@@ -4266,6 +4615,7 @@ void psx_lobby_pump(void)
/* Host relay: hold / probe / report to match the room. Before the
* connected() check so a dropped WS releases the port the same pump. */
host_relay_step();
host_ice_step();
#if defined(_WIN32)
int n;
#else
@@ -4544,6 +4894,8 @@ int psx_lobby_join(const char *lobby_id, const char *password, const char *guest
int psx_lobby_leave(void)
{
host_relay_leave(); /* unmap the router, close the port, forget reports */
ice_launch_discard();
g_il.error[0] = '\0';
queue_send("{\"op\":\"leave\"}");
flush_pending();
g_lc.ice_rtt_suspended = 0;
+239 -46
View File
@@ -6,6 +6,7 @@
#include "netplay_load_probe.h"
#include "netplay_sim_pad_cache.h"
#include "netplay_local_view.h"
#include "netplay_ice_hub.h"
#include "host_time.h"
#include "memcard.h"
@@ -51,10 +52,10 @@
#include "psx_cycles.h"
#include "psx_scheduler.h"
#include "spu.h"
#if defined(PSX_HAS_LOBBY_CLIENT)
/* Unconditional: the lobby client TU defines no-op stubs when it is off, and
* the host-relay-over-ICE handover below calls it either way. */
#include "psx_lobby_client.h"
#endif
#endif
#ifndef PSX_MAX_PLAYERS
#define PSX_MAX_PLAYERS 2
@@ -140,6 +141,7 @@ void psx_netplay_config_defaults(PsxNetplayConfig *cfg)
cfg->force_input_relay = 0;
cfg->force_turn = 0;
cfg->transport = 0;
cfg->transport_ice_hub = 0;
cfg->guest_memcard = 0;
cfg->host_spectates = 0;
cfg->session_id = 1;
@@ -485,6 +487,9 @@ int psx_netplay_start(const PsxNetplayConfig *cfg)
(void)cfg;
return -1;
}
int psx_netplay_ice_stash_from_lobby(void) { return 0; }
void psx_netplay_ice_stash_discard(void) {}
int psx_netplay_ice_stash_held(void) { return 0; }
void psx_netplay_shutdown(void) {}
void psx_netplay_cold_reset(void) {}
void psx_netplay_stage_local(const PsxNetPad *pad) { (void)pad; }
@@ -634,6 +639,7 @@ typedef struct {
PsxNetplayLoadProbe load_probe; /* guest's verified local LOAD probe/apply */
/* Transport / ICE / diag (MotK online path). */
int use_ice;
int ice_hub; /* match runs over adopted waiting-room agents */
int ice_has_turn;
int force_input_relay;
int is_host;
@@ -3415,6 +3421,7 @@ int psx_netplay_is_running(void)
const char *psx_netplay_transport_name(void)
{
if (!psx_netplay_active()) return "none";
if (g_np.ice_hub) return "ice_hub";
return g_np.use_ice ? "ice" : "lan";
}
@@ -3729,7 +3736,9 @@ static void np_diag_capture(const PsxNetplayConfig *cfg, int slots)
const char *arch = "p2p";
int players;
if (!cfg) return;
if (cfg->force_input_relay)
if (cfg->transport_ice_hub)
arch = "ice_host_relay";
else if (cfg->force_input_relay)
arch = "server_relay";
else if (slots >= 3)
arch = "host_relay";
@@ -3791,6 +3800,10 @@ static void drain_lobby_signals(void)
if (!g_np.session) return;
while (psx_lobby_poll_signal(&type, &flag, text, sizeof(text))) {
RNetSignal sig;
/* Host relay over ICE: the adopted agents are frozen at COMPLETED.
* Nothing may be pushed into them; a stray legacy ICE signal is
* consumed and dropped. */
if (g_np.ice_hub) continue;
memset(&sig, 0, sizeof(sig));
/* Peers emit LOCAL_*; push_signal expects REMOTE_* for SDP/candidates. */
if (type == (int)RNET_SIGNAL_LOCAL_SDP)
@@ -3807,35 +3820,29 @@ static void drain_lobby_signals(void)
static void drain_lobby_signals(void) {}
#endif
static int resolve_use_ice(const PsxNetplayConfig *cfg)
/* Transport for this launch: 0 = LAN UDP (bind + hub / dial), 1 = one
* negotiated ICE agent, 2 = the waiting room's connected agents (host relay
* over ICE), -1 = refuse. The decision itself is netplay_ice_hub.c (tested
* without a ROM); this adds the room lookup and the log lines. */
static int resolve_transport(const PsxNetplayConfig *cfg)
{
int in_motk_room = 0;
int ice_built = 0;
PsxTransportChoice c;
if (cfg->transport == 2) return 0; /* force LAN */
#if defined(PSX_HAS_LOBBY_CLIENT)
in_motk_room = psx_lobby_connected() && psx_lobby_in_lobby();
#endif
/* §108: MotK/BPE online lobbies always use lobby UDP SFU — LAN dial to
* relay_endpoint (or equal host/guest advertise). Match ICE / ice_p2p
* selection was removed; waiting-room ICE RTT may still run for delay
* hints only. Direct IP / LAN file lobby (no MotK seat) stays LAN UDP. */
/* Host relay (2026-10-01): an online room whose host carries the match
* launches transport "host" -- no server relay. Host: bind the advertised
* port, peer empty (accept-first / hub); guest: dial host_endpoint. That
* is the LAN path below, so it is excluded from the SFU rule here. */
if (cfg->transport_host) {
fprintf(stderr,
"psx_netplay: host relay — LAN transport (%s)\n",
cfg->peer_hostport && cfg->peer_hostport[0] ? "dial host" : "host binds");
fflush(stderr);
return 0;
}
#if defined(RNET_ENABLE_ICE)
ice_built = 1;
#endif
#if defined(PSX_HAS_LOBBY_CLIENT) && defined(RNET_ENABLE_ICE)
/* The server started this room on ICE (2 players the host relay cannot
* carry): peer-to-peer over STUN/TURN, signalled over the lobby socket.
* Same-NAT peers advertise equal endpoints; that is not a relay. */
if (in_motk_room && !cfg->force_input_relay) {
* Same-NAT peers advertise equal endpoints; that is not a relay. A host
* relay over ICE launch (hub) or a legacy host relay is decided below. */
if (in_motk_room && !cfg->force_input_relay && !cfg->transport_ice_hub &&
!cfg->transport_host) {
const PsxLobbyJoinInfo *ji = psx_lobby_join_info();
if (ji && ji->transport_ice) {
fprintf(stderr, "psx_netplay: lobby launch transport=ice — ICE peer-to-peer\n");
@@ -3844,29 +3851,171 @@ static int resolve_use_ice(const PsxNetplayConfig *cfg)
}
}
#endif
if (cfg->force_input_relay || in_motk_room) {
if (!cfg->peer_hostport || !cfg->peer_hostport[0]) {
fprintf(stderr,
"psx_netplay: MotK online / force_input_relay needs a "
"peer or SFU endpoint (empty peer)\n");
fflush(stderr);
return -1;
}
fprintf(stderr,
"psx_netplay: server input relay — LAN transport to %s\n",
cfg->peer_hostport);
/* §108: MotK/BPE online lobbies use the lobby UDP SFU unless the launch
* says otherwise. Host relay (2026-10-01): transport "host" -- the host
* carries the match on its advertised UDP port (the LAN path). Host relay
* over ICE (2026-10-03): transport "host" + relay_via "ice" -- no port, the
* match rides the waiting room's connected agents (psx_transport_decide
* keeps it off the SFU and off the single-agent ICE path). Direct IP / LAN
* file lobby (no MotK seat) stays LAN UDP. */
c = psx_transport_decide(cfg->transport, cfg->transport_ice_hub,
cfg->transport_host, cfg->force_input_relay,
in_motk_room,
cfg->peer_hostport && cfg->peer_hostport[0],
ice_built);
switch (c) {
case PSX_TRANSPORT_ICE_HUB:
fprintf(stderr, "psx_netplay: host relay over ICE - adopting the "
"waiting room's connected agents (no UDP bind, no dial)\n");
fflush(stderr);
return 2;
case PSX_TRANSPORT_SINGLE_ICE:
return 1;
case PSX_TRANSPORT_ERROR_NO_PEER:
if (cfg->transport_ice_hub)
fprintf(stderr, "psx_netplay: host relay over ICE refused (%s)\n",
cfg->transport == 2 ? "PSX_NET_TRANSPORT=lan forces UDP"
: "this build has no ICE");
else
fprintf(stderr, "psx_netplay: MotK online / force_input_relay needs a "
"peer or SFU endpoint (empty peer)\n");
fflush(stderr);
return -1;
case PSX_TRANSPORT_LAN_UDP:
default:
if (cfg->transport_host)
fprintf(stderr, "psx_netplay: host relay - LAN transport (%s)\n",
cfg->peer_hostport && cfg->peer_hostport[0] ? "dial host"
: "host binds");
else if ((cfg->force_input_relay || in_motk_room) && cfg->transport != 2)
fprintf(stderr, "psx_netplay: server input relay - LAN transport to %s\n",
cfg->peer_hostport);
fflush(stderr);
return 0;
}
#if defined(RNET_ENABLE_ICE)
/* Explicit ICE only outside MotK online (rare). */
if (cfg->transport == 1)
return 1;
#endif
return 0;
}
/* Agents taken from the lobby when the launch was consumed, until start()
* adopts them. Outside g_np: shutdown wipes g_np, and this must survive the
* gap between the launcher closing and the session being created. */
typedef struct {
int n; /* host: seats held */
PsxLobbyIceSeat seat[PSX_ICE_HUB_MAX_SEATS];
struct RNetIceAgent *guest; /* guest: agent held */
int is_host;
} NpIceStash;
static NpIceStash g_np_ice_stash;
void psx_netplay_ice_stash_discard(void)
{
#if defined(PSX_HAS_LOBBY_CLIENT)
int i;
for (i = 0; i < g_np_ice_stash.n; ++i)
psx_lobby_ice_destroy_agent(g_np_ice_stash.seat[i].agent);
if (g_np_ice_stash.guest)
psx_lobby_ice_destroy_agent(g_np_ice_stash.guest);
#endif
memset(&g_np_ice_stash, 0, sizeof(g_np_ice_stash));
}
int psx_netplay_ice_stash_held(void)
{
return g_np_ice_stash.n > 0 || g_np_ice_stash.guest != NULL;
}
int psx_netplay_ice_stash_from_lobby(void)
{
#if defined(PSX_HAS_LOBBY_CLIENT)
const PsxLobbyJoinInfo *ji = psx_lobby_join_info();
if (!ji || !ji->transport_ice_hub || !psx_lobby_launch_pending())
return 0;
psx_netplay_ice_stash_discard();
g_np_ice_stash.is_host = psx_lobby_is_host() ? 1 : 0;
if (g_np_ice_stash.is_host) {
const int n = psx_lobby_ice_take_hub(g_np_ice_stash.seat,
PSX_ICE_HUB_MAX_SEATS);
if (n < 1) {
fprintf(stderr, "psx_netplay: ICE hub handover failed: %s\n",
psx_lobby_ice_launch_error());
fflush(stderr);
return 0;
}
g_np_ice_stash.n = n;
} else {
g_np_ice_stash.guest = psx_lobby_ice_take_guest_agent();
if (!g_np_ice_stash.guest) {
fprintf(stderr, "psx_netplay: ICE guest handover failed: %s\n",
psx_lobby_ice_launch_error());
fflush(stderr);
return 0;
}
}
return 1;
#else
return 0;
#endif
}
/* Host relay over ICE: take ownership of the connected agents and bind each to
* the SESSION slot it serves. Failure destroys everything held and leaves one
* sentence for the lobby's status line; never a smaller room. On success the
* caller owns the agents (adopt, or rnet_host_ice_destroy_agent them). */
static int ice_hub_acquire(const PsxNetplayConfig *cfg, int slots, int local,
RNetIceAdoptSeat *seats_out, int *n_out,
struct RNetIceAgent **guest_out)
{
char why[192];
why[0] = '\0';
*n_out = 0;
*guest_out = NULL;
if (cfg->spectator) {
snprintf(why, sizeof(why),
"spectators are not on the ICE host-relay path");
} else if (!psx_netplay_ice_stash_held() && !psx_netplay_ice_stash_from_lobby()) {
snprintf(why, sizeof(why), "no ICE connection was handed over for this launch");
} else if (g_np_ice_stash.is_host) {
int lobby_slots[PSX_ICE_HUB_MAX_SEATS];
int session_slots[PSX_ICE_HUB_MAX_SEATS];
PsxIceHubMapError err = PSX_ICE_HUB_MAP_OK;
int i;
if (local != 0) {
snprintf(why, sizeof(why), "host launch with a non-host seat (%d)", local);
} else {
for (i = 0; i < g_np_ice_stash.n; ++i)
lobby_slots[i] = g_np_ice_stash.seat[i].lobby_slot;
if (psx_ice_hub_map_seats(cfg->port_of_slot, cfg->port_map_valid, slots,
cfg->host_spectates, lobby_slots,
g_np_ice_stash.n, session_slots, &err) != 0) {
snprintf(why, sizeof(why), "cannot seat the connected players: %s",
psx_ice_hub_map_error_text(err));
} else {
for (i = 0; i < g_np_ice_stash.n; ++i) {
seats_out[i].slot = session_slots[i];
seats_out[i].agent = g_np_ice_stash.seat[i].agent;
fprintf(stderr, "psx_netplay: ICE hub lobby seat %d -> session slot %d\n",
lobby_slots[i], session_slots[i]);
}
*n_out = g_np_ice_stash.n;
memset(&g_np_ice_stash, 0, sizeof(g_np_ice_stash)); /* caller owns */
fflush(stderr);
return 1;
}
}
} else {
if (local < 1)
snprintf(why, sizeof(why), "guest launch holding the host seat");
else {
*guest_out = g_np_ice_stash.guest;
memset(&g_np_ice_stash, 0, sizeof(g_np_ice_stash));
return 1;
}
}
psx_netplay_ice_stash_discard();
fprintf(stderr, "psx_netplay: host relay over ICE refused: %s\n", why);
fflush(stderr);
psx_lobby_set_last_error(why);
return 0;
}
int psx_netplay_start(const PsxNetplayConfig *cfg)
{
@@ -3876,6 +4025,11 @@ int psx_netplay_start(const PsxNetplayConfig *cfg)
int slots;
int local;
int use_ice;
int transport;
int ice_hub;
RNetIceAdoptSeat hub_agents[PSX_ICE_HUB_MAX_SEATS];
int hub_seats = 0;
struct RNetIceAgent *guest_agent = NULL;
if (!cfg || !cfg->enabled) return -1;
if (g_np.session) psx_netplay_shutdown();
@@ -3977,9 +4131,13 @@ int psx_netplay_start(const PsxNetplayConfig *cfg)
in_player = cfg->input_player;
if (in_player < 0 || in_player >= PSX_MAX_PLAYERS) in_player = 0;
use_ice = resolve_use_ice(cfg);
if (use_ice < 0)
transport = resolve_transport(cfg);
if (transport < 0)
return -4;
use_ice = (transport == 1);
ice_hub = (transport == 2);
if (ice_hub && !ice_hub_acquire(cfg, slots, local, hub_agents, &hub_seats, &guest_agent))
return -5;
memset(&host, 0, sizeof(host));
host.sample_local = host_sample_local;
@@ -3991,7 +4149,15 @@ int psx_netplay_start(const PsxNetplayConfig *cfg)
#endif
g_np.session = rnet_session_create(&rcfg, &host);
if (!g_np.session) return -2;
if (!g_np.session) {
if (ice_hub) {
int i;
for (i = 0; i < hub_seats; ++i)
psx_lobby_ice_destroy_agent(hub_agents[i].agent);
if (guest_agent) psx_lobby_ice_destroy_agent(guest_agent);
}
return -2;
}
if (use_ice) {
#if defined(RNET_ENABLE_ICE)
@@ -4158,6 +4324,31 @@ int psx_netplay_start(const PsxNetplayConfig *cfg)
#endif
}
if (ice_hub) {
/* No socket, no dial, no signals: the agents are already COMPLETED and
* are frozen on adoption (never push signals to them). */
int rc;
if (hub_seats > 0) {
rc = rnet_session_start_ice_hub_adopt(g_np.session, hub_agents, hub_seats);
} else {
rc = rnet_session_adopt_ice_agent(g_np.session, guest_agent);
}
if (rc != 0) {
int i;
/* -1: nothing adopted, the agents are still ours. */
for (i = 0; i < hub_seats; ++i)
psx_lobby_ice_destroy_agent(hub_agents[i].agent);
if (guest_agent) psx_lobby_ice_destroy_agent(guest_agent);
fprintf(stderr, "psx_netplay: ICE adopt failed; the match is not started\n");
fflush(stderr);
psx_lobby_set_last_error("The connection to the host was lost before the "
"match started.");
rnet_session_destroy(g_np.session);
g_np.session = NULL;
return -6;
}
}
if (!use_ice) {
/* Host-as-relay: slot 0 with 3+ seats and no dial peer. */
#if PSX_MAX_PLAYERS >= 3
@@ -4195,6 +4386,7 @@ int psx_netplay_start(const PsxNetplayConfig *cfg)
* freezes local_slot + slot_count at create. Starting with zeroed g_np made
* every peer seal as slot 0 and export the wrong seat (VS-select hang). */
g_np.use_ice = use_ice ? 1 : 0;
g_np.ice_hub = ice_hub ? 1 : 0;
g_np.slot_count = (int)rcfg.slot_count;
g_np_slot_count = g_np.slot_count;
g_np.local_slot = (int)rcfg.local_slot;
@@ -4370,10 +4562,11 @@ int psx_netplay_start(const PsxNetplayConfig *cfg)
fprintf(stderr,
"psx_netplay: started transport=%s slot=%d input_player=%d session=%u "
"delay=%u force_input_relay=%d force_turn=%d bind=%s peer=%s\n",
use_ice ? "ice" : "lan", g_np.local_slot, g_np.input_player,
ice_hub ? "ice_hub" : (use_ice ? "ice" : "lan"), g_np.local_slot,
g_np.input_player,
(unsigned)rcfg.session_id, (unsigned)rcfg.input_delay,
g_np.force_input_relay, cfg->force_turn ? 1 : 0, cfg->bind_hostport,
use_ice ? "(ice)" : cfg->peer_hostport);
(use_ice || ice_hub) ? "(ice)" : cfg->peer_hostport);
return 0;
}
+147
View File
@@ -0,0 +1,147 @@
/* Host relay over ICE: transport choice and lobby-seat -> session-slot map.
* ROM-free, socket-free. Plain checks, not assert(): Release builds define
* NDEBUG. */
#include "netplay_ice_hub.h"
#include <stdio.h>
static int failures;
#define CHECK(cond) do { if (!(cond)) { \
fprintf(stderr, "%s:%d: CHECK failed: %s\n", __FILE__, __LINE__, #cond); \
++failures; } } while (0)
/* psx_transport_decide(cfg_transport, hub, host, force_input_relay,
* in_online_room, has_peer, ice_built) */
static void test_transport(void)
{
/* The hub is decided by the launch alone: neither force_input_relay, an
* online room, nor an explicit transport=ice may move it to the SFU or the
* single-agent ICE path. transport_host is ALSO 1 for a hub launch. */
CHECK(psx_transport_decide(0, 1, 1, 0, 1, 0, 1) == PSX_TRANSPORT_ICE_HUB);
CHECK(psx_transport_decide(0, 1, 1, 1, 1, 1, 1) == PSX_TRANSPORT_ICE_HUB);
CHECK(psx_transport_decide(1, 1, 1, 0, 1, 0, 1) == PSX_TRANSPORT_ICE_HUB);
CHECK(psx_transport_decide(0, 1, 1, 0, 0, 0, 1) == PSX_TRANSPORT_ICE_HUB);
/* Forced LAN, or a build without ICE: refuse, never fall back to a UDP
* path the launch has no endpoint for. */
CHECK(psx_transport_decide(2, 1, 1, 0, 1, 0, 1) == PSX_TRANSPORT_ERROR_NO_PEER);
CHECK(psx_transport_decide(0, 1, 1, 0, 1, 0, 0) == PSX_TRANSPORT_ERROR_NO_PEER);
/* Legacy paths are unchanged. */
CHECK(psx_transport_decide(0, 0, 1, 0, 1, 0, 1) == PSX_TRANSPORT_LAN_UDP); /* host relay, host binds */
CHECK(psx_transport_decide(0, 0, 1, 0, 1, 1, 1) == PSX_TRANSPORT_LAN_UDP); /* host relay, guest dials */
CHECK(psx_transport_decide(0, 0, 0, 1, 1, 1, 1) == PSX_TRANSPORT_LAN_UDP); /* SFU */
CHECK(psx_transport_decide(0, 0, 0, 1, 1, 0, 1) == PSX_TRANSPORT_ERROR_NO_PEER); /* SFU, no endpoint */
CHECK(psx_transport_decide(0, 0, 0, 0, 1, 0, 1) == PSX_TRANSPORT_ERROR_NO_PEER); /* online, no endpoint */
CHECK(psx_transport_decide(2, 0, 0, 1, 1, 0, 1) == PSX_TRANSPORT_LAN_UDP); /* forced LAN */
CHECK(psx_transport_decide(0, 0, 0, 0, 0, 1, 1) == PSX_TRANSPORT_LAN_UDP); /* direct IP */
CHECK(psx_transport_decide(1, 0, 0, 0, 0, 0, 1) == PSX_TRANSPORT_SINGLE_ICE);
CHECK(psx_transport_decide(1, 0, 0, 0, 0, 0, 0) == PSX_TRANSPORT_LAN_UDP);
}
static int map(const int *port, int valid, int slots, int hs, const int *lobby, int n,
int *out, PsxIceHubMapError *err)
{
return psx_ice_hub_map_seats(port, valid, slots, hs, lobby, n, out, err);
}
static void test_mapping(void)
{
int out[PSX_ICE_HUB_MAX_SEATS];
PsxIceHubMapError e;
/* 2P: host seat 0, guest seat 1. */
{
const int port[] = { 0, 1 };
const int lobby[] = { 1 };
CHECK(map(port, 1, 2, 0, lobby, 1, out, &e) == 0 && out[0] == 1);
}
/* 3P in any arrival order: parallel output. */
{
const int port[] = { 0, 1, 2 };
const int lobby[] = { 2, 1 };
CHECK(map(port, 1, 3, 0, lobby, 2, out, &e) == 0);
CHECK(out[0] == 2 && out[1] == 1);
}
/* Host holds lobby seat 2 (moved): it is still session slot 0, and the
* guests follow in ascending seat order -- slot != seat. */
{
const int port[] = { 2, 0, 1 };
const int lobby[] = { 0, 1 };
CHECK(map(port, 1, 3, 0, lobby, 2, out, &e) == 0);
CHECK(out[0] == 1 && out[1] == 2);
}
/* Host in the gallery (host_spectates): slot 0 drives no seat, players
* start at slot 1. */
{
const int port[] = { -1, 0, 1 };
const int lobby[] = { 0, 1 };
CHECK(map(port, 1, 3, 1, lobby, 2, out, &e) == 0);
CHECK(out[0] == 1 && out[1] == 2);
}
/* Sparse room (seats 0 and 3 occupied). */
{
const int port[] = { 0, 3 };
const int lobby[] = { 3 };
CHECK(map(port, 1, 2, 0, lobby, 1, out, &e) == 0 && out[0] == 1);
}
/* The host's own seat is never an agent seat (search starts at slot 1). */
{
const int port[] = { 0, 1 };
const int lobby[] = { 0 };
CHECK(map(port, 1, 2, 0, lobby, 1, out, &e) == -1);
CHECK(e == PSX_ICE_HUB_MAP_SEAT_UNMAPPED);
}
/* A session slot with no agent is a smaller room: refused. */
{
const int port[] = { 0, 1, 2 };
const int lobby[] = { 1 };
CHECK(map(port, 1, 3, 0, lobby, 1, out, &e) == -1);
CHECK(e == PSX_ICE_HUB_MAP_SEAT_MISSING);
}
/* A connected seat the launch does not seat. */
{
const int port[] = { 0, 1 };
const int lobby[] = { 5 };
CHECK(map(port, 1, 2, 0, lobby, 1, out, &e) == -1);
CHECK(e == PSX_ICE_HUB_MAP_SEAT_UNMAPPED);
}
/* Two agents for one seat. */
{
const int port[] = { 0, 1, 2 };
const int lobby[] = { 1, 1 };
CHECK(map(port, 1, 3, 0, lobby, 2, out, &e) == -1);
CHECK(e == PSX_ICE_HUB_MAP_SEAT_DUPLICATE);
}
/* Bad arguments never write a mapping the caller could adopt. */
{
const int lobby[] = { 1 };
CHECK(map(NULL, 1, 2, 0, lobby, 1, out, &e) == -1);
CHECK(e == PSX_ICE_HUB_MAP_BAD_ARGS);
CHECK(map(NULL, 0, 2, 0, lobby, 0, out, &e) == -1);
CHECK(map(NULL, 0, 1, 0, lobby, 1, out, &e) == -1);
}
/* No table: identity, or seat + 1 with the host in the gallery. */
{
const int lobby[] = { 1, 2 };
CHECK(map(NULL, 0, 3, 0, lobby, 2, out, &e) == 0);
CHECK(out[0] == 1 && out[1] == 2);
}
{
const int lobby[] = { 0, 1 };
CHECK(map(NULL, 0, 3, 1, lobby, 2, out, &e) == 0);
CHECK(out[0] == 1 && out[1] == 2);
}
CHECK(psx_ice_hub_map_error_text(PSX_ICE_HUB_MAP_SEAT_MISSING) != NULL);
}
int main(void)
{
test_transport();
test_mapping();
if (failures) {
fprintf(stderr, "netplay_ice_hub_test: %d failure(s)\n", failures);
return 1;
}
printf("netplay_ice_hub_test: ok\n");
return 0;
}