netplay: host relay for online rooms (amends §108 "online is always SFU")

An online room this build hosts asks the lobby server for transport "host"
(match_caps.relay): the host carries the match on its own UDP port and
guests dial it; the server's relay (SFU) is the fallback when a guest's
probe fails (recomp-net-server WS_LOBBY.md "Host relay"). The mechanics are
recomp-net's recomp_net/host_relay.h, pinned here (lib/recomp-net b0751b7);
this engine's own lobby-client copy gets the same glue as rnet_lobby_client:
the cap (emitted only when asked), the launch's transport ->
PsxLobbyJoinInfo.transport_host (force_input_relay 0; the host accepts
first / hubs instead of dialling the guest's LAN address), each seat's
published path / path_fresh, host_relay_step() from psx_lobby_pump, and
unmap on leave / disconnect.

psx_netplay: transport_host takes the LAN / hub path instead of the "MotK
online needs the SFU" rule. main.cpp: relay_host_get/set + relay_status
for recomp-ui (Network Settings / MATCH SETTINGS / ROOM "Match Relay"),
transport_host on the launch and into PsxNetplayConfig, and the
host==guest-advertise SFU inference skipped on a host launch; all under
RECOMP_LAUNCHER_HAS_HOST_RELAY so older recomp-ui pins still build.

Compile-checked against workspace recomp-ui 58604b0 (MOTK scratch
configure: full build and link).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Alex Vanderveen
2026-10-01 19:09:51 -04:00
parent 35d604ab80
commit 1982e7dcb5
6 changed files with 240 additions and 9 deletions
+26
View File
@@ -106,6 +106,11 @@ typedef struct PsxLobbyMember {
/* Account key behind this seat; "" for a guest. See
* PsxLobbyOnlinePlayer.account. */
char account[PSX_LOBBY_ID_LEN];
/* Host-relay room: this guest's latest path_report ("direct" | "fail" |
* "relay"), "" when none, and whether the server still trusts it at start
* (recomp-net-server WS_LOBBY.md "Host relay"). */
char path[8];
int path_fresh;
} PsxLobbyMember;
/*
@@ -174,6 +179,11 @@ typedef struct PsxLobbyMatchCaps {
int input_delay; /* recomp-net delay frames (D) */
int input_prediction; /* invent runway frames (P); rollback only */
int force_input_relay; /* 0/1 — server input relay (vs P2P) */
/* 0/1 -- host: ask for the HOST RELAY (match_caps.relay = "host"): the
* host carries the match on its own UDP port and guests dial it; the
* 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;
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. */
@@ -223,6 +233,11 @@ 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;
/* Launch: transport "host" -- no relay_endpoint; the host carries the
* match on host_endpoint (its advertised public port); guests dial it.
* force_input_relay is 0. The host binds bind_hostport and accepts the
* guest (2 seats) or hubs (3+). */
int transport_host;
/* 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];
@@ -348,6 +363,17 @@ const PsxLobbyMatchCaps *psx_lobby_match_caps(void);
/* Host: push updated caps while in lobby (clears ready via lobby_update). */
int psx_lobby_set_match_caps(const PsxLobbyMatchCaps *caps);
/* Host relay (recomp_net/host_relay.h), driven from psx_lobby_pump while
* seated online in a room whose caps ask for it: the host holds the game port
* (UPnP / NAT-PMP / STUN), advertises it with set_host_endpoint and answers
* probes; guests probe it and send path_report. The pref is the HOST's
* default for rooms it creates (recomp-ui owns the setting; default on). */
void psx_lobby_set_relay_host_pref(int on);
int psx_lobby_relay_host_pref(void);
struct RNetHostRelayStatus;
int psx_lobby_host_relay_status(struct RNetHostRelayStatus *out);
void psx_lobby_host_relay_release_port(void);
/* Live member table from lobby_update (and create/join). */
int psx_lobby_member_count(void);
int psx_lobby_member_get(int index, PsxLobbyMember *out);
+4
View File
@@ -79,6 +79,10 @@ typedef struct PsxNetplayConfig {
/* Rollback invent runway (phase_lock / P). Clamped 2..16. Unused in delay-sync. */
int input_prediction;
int force_input_relay; /* 1 = lobby UDP SFU star (from launch) */
/* 1 = the launch said transport "host": an online room whose host
* carries the match on bind_hostport; guests dial peer_hostport. Takes
* the LAN / hub transport path, not the server-relay one. */
int transport_host;
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. */
+90 -3
View File
@@ -62,6 +62,7 @@ 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 "recomp_net/host_relay.h" /* RNetHostRelayStatus for the launcher relay line */
#include "netplay_bios_settle.h"
#include "netplay_exit_reason.h"
#include "host_time.h"
@@ -8497,6 +8498,11 @@ namespace {
* relay_endpoint. force_turn is a rollback delay-floor hint only. */
int g_lnch_force_input_relay = 0;
int g_lnch_force_turn = 0;
/* Host relay (2026-10-01): online rooms this build hosts ask the server
* for transport "host" (match_caps.relay). recomp-ui owns the setting
* (Network Settings / Lobby Settings, persisted there) and pushes it
* through relay_host_set; default on. */
int g_lnch_relay_host = 1;
/* Lobby default on; host “Disable Rollback” clears this → delay_sync. */
int g_lnch_rollback = 1;
int g_lnch_multitap_analog = 1;
@@ -10419,6 +10425,7 @@ namespace {
if (caps.input_prediction < 2) caps.input_prediction = 2;
if (caps.input_prediction > 16) caps.input_prediction = 16;
caps.force_input_relay = g_lnch_force_input_relay != 0;
caps.relay_host = g_lnch_relay_host != 0;
caps.force_turn = g_lnch_force_turn != 0;
caps.rollback = g_lnch_rollback != 0;
caps.multitap_analog = g_lnch_multitap_analog != 0;
@@ -10461,6 +10468,7 @@ namespace {
if (caps.input_prediction < 2) caps.input_prediction = 2;
if (caps.input_prediction > 16) caps.input_prediction = 16;
caps.force_input_relay = g_lnch_force_input_relay != 0;
caps.relay_host = g_lnch_relay_host != 0;
caps.force_turn = g_lnch_force_turn != 0;
caps.rollback = g_lnch_rollback != 0;
caps.multitap_analog = g_lnch_multitap_analog != 0;
@@ -10914,6 +10922,69 @@ namespace {
}
return g_lnch_force_turn;
}
/* Host relay preference and live state (recomp_launcher.h relay_host_*). */
int ae_np_relay_host_get(void*) {
if (g_lnch_hosting_lan || g_lnch_joined_lan) return 0;
if (psx_lobby_in_lobby()) {
const PsxLobbyMatchCaps* caps = psx_lobby_match_caps();
if (caps && caps->valid) return caps->relay_host ? 1 : 0;
}
return g_lnch_relay_host;
}
int ae_np_relay_host_set(void*, int on) {
g_lnch_relay_host = on ? 1 : 0;
psx_lobby_set_relay_host_pref(g_lnch_relay_host);
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 (g_lnch_hosting_lan || g_lnch_joined_lan || !psx_lobby_in_lobby()) return 0;
RNetHostRelayStatus st;
if (!psx_lobby_host_relay_status(&st)) return 0;
if (st.role == 1) {
int guests = 0, proven = 0;
const int n = psx_lobby_member_count();
for (int i = 0; i < n; ++i) {
PsxLobbyMember mem;
if (!psx_lobby_member_get(i, &mem) || mem.is_spectator) continue;
if (std::strcmp(mem.player_id, psx_lobby_host_player_id()) == 0) continue;
guests++;
if (std::strcmp(mem.path, "direct") == 0 && mem.path_fresh) proven++;
}
if (!st.port.done)
std::snprintf(out, out_cap, "You carry the match. Opening UDP port %u (%s)...",
(unsigned)st.port.local_port,
st.port.stage[0] ? st.port.stage : "starting");
else if (!st.port.endpoint[0])
std::snprintf(out, out_cap, "%s", st.port.detail);
else
std::snprintf(out, out_cap,
"You carry the match at %s (%s). Guests who can reach you: %d of %d%s",
st.port.endpoint, st.port.how, proven, guests,
guests && proven < guests
? ". Any guest who cannot sends the match through the lobby "
"server's relay."
: ".");
return 1;
}
if (st.role == 2) {
if (st.probing || !st.last_report[0])
std::snprintf(out, out_cap, "The host carries the match. Checking you can reach %s...",
st.probed[0] ? st.probed : "the host");
else if (std::strcmp(st.last_report, "direct") == 0)
std::snprintf(out, out_cap, "The host carries the match; you reach it directly (%s).",
st.probed);
else
std::snprintf(out, out_cap,
"The host's port did not answer (%s): the lobby server will relay "
"this match. Retrying.", st.probed);
return 1;
}
return 0;
}
int ae_np_force_turn_set(void*, int force) {
if (g_lnch_hosting_lan || g_lnch_joined_lan)
return 0; /* LAN/Direct IP — no online delay-floor hint */
@@ -12830,13 +12901,18 @@ namespace {
for (int i = 0; i < RECOMP_LAUNCHER_NETPLAY_MAX_MEMBERS + 1; ++i)
out->slot_port[i] = plan.port[i];
}
/* §108: online launch always SFU. Prefer caps.force_input_relay from
/* Online launch: the server's relay (SFU) unless the launch said
* transport "host" (the host carries the match; 2026-10-01 amends
* §108's "always SFU"). Prefer caps.force_input_relay from the
* relay_endpoint rewrite; also infer when host==guest advertise. */
out->force_input_relay =
(g_lnch_hosting_lan || g_lnch_joined_lan)
(g_lnch_hosting_lan || g_lnch_joined_lan || ji->transport_host)
? 0
: (caps->force_input_relay ? 1 : 0);
if (!out->force_input_relay && !g_lnch_hosting_lan &&
#if defined(RECOMP_LAUNCHER_HAS_HOST_RELAY)
out->transport_host = ji->transport_host ? 1 : 0;
#endif
if (!out->force_input_relay && !ji->transport_host && !g_lnch_hosting_lan &&
!g_lnch_joined_lan && ji->host_endpoint[0] &&
ji->guest_endpoint[0] &&
std::strcmp(ji->host_endpoint, ji->guest_endpoint) == 0) {
@@ -13047,6 +13123,11 @@ namespace {
/* Append-only members past the positional initializer. */
#if defined(RECOMP_LAUNCHER_HAS_NETPLAY_HANDOFF)
g_lnch_netplay_callbacks.ingest_launch = ae_np_ingest_launch;
#endif
#if defined(RECOMP_LAUNCHER_HAS_HOST_RELAY)
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
g_lnch_netplay_callbacks.memcard_offer_set = ae_np_memcard_offer_set;
g_lnch_netplay_callbacks.guest_memcard_get = ae_np_guest_memcard_get;
@@ -15248,6 +15329,9 @@ int main(int argc, char** argv) {
net_cfg.input_delay = ls.netplay_launch.input_delay;
net_cfg.input_prediction = ls.netplay_launch.input_prediction;
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;
#endif
net_cfg.force_turn = ls.netplay_launch.force_turn ? 1 : 0;
net_cfg.rollback = ls.netplay_launch.rollback ? 1 : 0;
net_cfg.guest_memcard = ls.netplay_launch.guest_memcard ? 1 : 0;
@@ -17140,6 +17224,9 @@ soft_return_lobby:
net_cfg.input_delay = ls.netplay_launch.input_delay;
net_cfg.input_prediction = ls.netplay_launch.input_prediction;
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;
#endif
net_cfg.force_turn = ls.netplay_launch.force_turn ? 1 : 0;
net_cfg.rollback = ls.netplay_launch.rollback ? 1 : 0;
/* Same fold as the first-boot path: rematch must not lose
+108 -5
View File
@@ -44,6 +44,10 @@ int psx_lobby_create(const char *a, const char *b, const char *c, const char *d
int psx_lobby_join(const char *a, const char *b, const char *c)
{ (void)a; (void)b; (void)c; return -1; }
int psx_lobby_leave(void) { return -1; }
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) {}
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; }
@@ -183,6 +187,7 @@ void psx_lobby_clear_launch_pending(void) {}
#include "rnet_ws.h"
#include "rnet_sha1.h"
#include "recomp_net/address.h"
#include "recomp_net/host_relay.h"
#include "recomp_net/ice.h"
#include "recomp_net/ice_rtt.h"
#include "recomp_net/lan_beacon.h"
@@ -1098,6 +1103,74 @@ static void match_caps_clear(PsxLobbyMatchCaps *c)
c->guest_memcard = 1;
}
/* ---- host relay (recomp_net/host_relay.h) -------------------------------
* The same wiring as recomp-net's own lobby client (rnet_lobby_client.c):
* this file is psxrecomp's copy of that client, so the orchestration object
* is recomp-net's and only the glue lives here. */
static RNetHostRelay *g_host_relay;
static int g_relay_host_pref = 1;
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; }
static int host_relay_send(const char *json, void *ctx)
{
(void)ctx;
if (!psx_lobby_connected() || !g_lc.in_lobby) return -1;
queue_send(json);
flush_pending();
return 0;
}
static int lobby_bind_port(void)
{
const char *colon = strrchr(g_lc.my_bind, ':');
const int port = colon ? atoi(colon + 1) : 0;
return port > 0 && port < 65536 ? port : 0;
}
/* Every pump: active only while seated in an online room whose published
* caps ask for the host relay (the host's caps are echoed back to it, so one
* rule serves both roles) and before the launch, which releases the port for
* the game. A spectator is not on the relay's path and reports nothing. */
static void host_relay_step(void)
{
RNetHostRelayView v;
if (!g_host_relay) {
g_host_relay = rnet_host_relay_create();
if (!g_host_relay) return;
}
memset(&v, 0, sizeof(v));
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;
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;
v.send_json = host_relay_send;
rnet_host_relay_update(g_host_relay, &v);
}
int psx_lobby_host_relay_status(struct RNetHostRelayStatus *out)
{
RNetHostRelayStatus st;
if (!out) return 0;
rnet_host_relay_status(g_host_relay, &st);
*out = st;
return st.role != 0;
}
void psx_lobby_host_relay_release_port(void)
{
rnet_host_relay_release_port(g_host_relay);
}
static void host_relay_leave(void)
{
rnet_host_relay_leave(g_host_relay);
}
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);
@@ -1481,6 +1554,12 @@ static void parse_match_caps_object(const char *obj, PsxLobbyMatchCaps *out)
if (out->input_prediction < 2) out->input_prediction = 2;
if (out->input_prediction > 16) out->input_prediction = 16;
out->force_input_relay = json_get_bool(obj, "force_input_relay", 0);
{
char relay[12];
relay[0] = '\0';
json_get_str(obj, "relay", relay, sizeof(relay));
out->relay_host = strcmp(relay, "host") == 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);
@@ -1538,7 +1617,7 @@ static int append_match_caps_json(char *dst, size_t dst_cap, const PsxLobbyMatch
return snprintf(dst, dst_cap,
",\"match_caps\":{\"v\":1,\"aspect_num\":%d,\"aspect_den\":%d,"
"\"turbo_loads\":%s,\"bios_hle\":%s,\"fast_boot\":%s,"
"\"auto_skip_fmv\":%s,\"input_delay\":%d,\"input_prediction\":%d,"
"\"auto_skip_fmv\":%s,\"input_delay\":%d,\"input_prediction\":%d,%s"
"\"force_input_relay\":%s,\"force_turn\":%s,\"rollback\":%s,"
"\"multitap_analog\":%s,\"guest_memcard\":%s,"
"\"guest_memcard_active\":%s,"
@@ -1551,6 +1630,8 @@ 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,
/* only when asked: absent reads as "the SFU, as always" */
caps->relay_host ? "\"relay\":\"host\"," : "",
caps->force_input_relay ? "true" : "false",
caps->force_turn ? "true" : "false",
caps->rollback ? "true" : "false",
@@ -1629,7 +1710,7 @@ static int using_server_input_relay(const PsxLobbyJoinInfo *j)
static void fill_peer_bind_from_join(void)
{
PsxLobbyJoinInfo *j = &g_lc.join;
const int force_relay = using_server_input_relay(j);
const int force_relay = !j->transport_host && using_server_input_relay(j);
const int seats = j->player_count >= 2 ? j->player_count : j->max_slots;
const int host_hub = (g_lc.is_host && seats >= 3 && !force_relay) ? 1 : 0;
memset(j->bind_hostport, 0, sizeof(j->bind_hostport));
@@ -1642,9 +1723,12 @@ static void fill_peer_bind_from_join(void)
j->host_endpoint, j->guest_endpoint, NULL);
} else if (g_lc.is_host) {
strncpy(j->bind_hostport, g_lc.my_bind, sizeof(j->bind_hostport) - 1);
if (!host_hub) {
if (!host_hub && !j->transport_host) {
/* 2P P2P: dial guest when they advertised a fixed port. Online
* guests often join with :0 — leave peer empty (accept-first). */
* guests often join with :0 — leave peer empty (accept-first).
* Host relay: the guest's advertised address is its LAN bind,
* which this host cannot dial across the guest's NAT; the guest
* dials us (it proved it can), so accept-first always. */
if (j->guest_endpoint[0] && !endpoint_port_is_zero(j->guest_endpoint))
strncpy(j->peer_hostport, j->guest_endpoint, sizeof(j->peer_hostport) - 1);
}
@@ -1724,6 +1808,10 @@ static int parse_seat_array(const char *json, const char *key, int is_spectator,
g_lc.members[n].slot = json_get_int(chunk, "slot", n);
json_get_str(chunk, "player_id", g_lc.members[n].player_id,
sizeof(g_lc.members[n].player_id));
g_lc.members[n].path[0] = '\0';
json_get_str(chunk, "path", g_lc.members[n].path,
sizeof(g_lc.members[n].path));
g_lc.members[n].path_fresh = json_get_bool(chunk, "path_fresh", 0) ? 1 : 0;
json_get_str(chunk, "display_name", g_lc.members[n].display_name,
sizeof(g_lc.members[n].display_name));
g_lc.members[n].ready = json_get_bool(chunk, "ready", 0);
@@ -2960,10 +3048,19 @@ static void handle_server_json(const char *json)
}
if (strcmp(op, "launch") == 0) {
char relay_endpoint[PSX_LOBBY_ENDPOINT_LEN];
char transport_kind[16];
json_get_str(json, "host_endpoint", g_lc.join.host_endpoint, sizeof(g_lc.join.host_endpoint));
json_get_str(json, "guest_endpoint", g_lc.join.guest_endpoint, sizeof(g_lc.join.guest_endpoint));
relay_endpoint[0] = '\0';
json_get_str(json, "relay_endpoint", relay_endpoint, sizeof(relay_endpoint));
/* "host": the host carries the match (WS_LOBBY.md "Host relay"): no
* relay_endpoint, host_endpoint is the host's advertised port. The
* waiting-room socket on that port goes now so the game can bind it;
* the router mapping stays for the game. */
transport_kind[0] = '\0';
json_get_str(json, "transport", transport_kind, sizeof(transport_kind));
g_lc.join.transport_host = strcmp(transport_kind, "host") == 0 ? 1 : 0;
rnet_host_relay_release_port(g_host_relay);
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);
@@ -3000,7 +3097,8 @@ static void handle_server_json(const char *json)
* Legacy ice_p2p from an old server is refused (need SFU). */
{
char transport[24];
const int force_relay = using_server_input_relay(&g_lc.join);
const int force_relay =
!g_lc.join.transport_host && using_server_input_relay(&g_lc.join);
const int seats = g_lc.join.player_count >= 2 ? g_lc.join.player_count
: g_lc.join.max_slots;
const int host_hub =
@@ -3582,6 +3680,7 @@ int psx_lobby_connect(const char *ws_url)
void psx_lobby_disconnect(void)
{
host_relay_leave();
/* Never block the UI on DNS/connect — cancel and let pump reap. */
lobby_cancel_connect_async();
automatch_on_connection_reset();
@@ -3934,6 +4033,9 @@ void psx_lobby_pump(void)
{
char buf[4096];
automatch_probe_poll();
/* 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();
#if defined(_WIN32)
int n;
#else
@@ -4198,6 +4300,7 @@ 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 */
queue_send("{\"op\":\"leave\"}");
flush_pending();
g_lc.ice_rtt_suspended = 0;
+11
View File
@@ -3594,6 +3594,17 @@ static int resolve_use_ice(const PsxNetplayConfig *cfg)
* 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 (cfg->force_input_relay || in_motk_room) {
if (!cfg->peer_hostport || !cfg->peer_hostport[0]) {
fprintf(stderr,