From 005c1f56c2e7ea662c4d6135208925f6a0421e0c Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Sat, 3 Oct 2026 03:32:00 -0400 Subject: [PATCH] netplay: host relay over ICE (relay_via ice) for online rooms Port recomp-net's host-as-relay-over-ICE wiring into psxrecomp's forked lobby client: waiting-room RNetHostIce proof (one agent per guest), relay_via "ice" in published caps (pref default on, launcher toggle), launch handling for transport_ice_hub (no bind, no dial; ice_not_connected refuses the launch), and adoption of the connected agents into the session (hub on the host, 1:1 on a guest). Lobby seat -> session slot goes through the launch's port_of_slot table (host_spectates, moved seats); transport choice never sends an ICE-hub launch to the single-agent ICE path or the SFU. Legacy advertised-port relay and SFU paths are unchanged. ROM-free test: netplay_ice_hub_test. lib/recomp-net is pointed at the local recomp-net feat/ice-hub (748f053); that pin is real only once recomp-net is pushed. Co-Authored-By: Claude Sonnet 5.5 --- docs/NETPLAY.md | 47 +++- lib/recomp-net | 2 +- runtime/CMakeLists.txt | 6 + runtime/include/netplay_ice_hub.h | 79 ++++++ runtime/include/psx_lobby_client.h | 44 ++++ runtime/include/psx_netplay.h | 23 +- runtime/runtime.cmake | 1 + runtime/src/main.cpp | 104 ++++++++ runtime/src/netplay_ice_hub.c | 78 ++++++ runtime/src/psx_lobby_client.c | 359 ++++++++++++++++++++++++++- runtime/src/psx_netplay.c | 281 +++++++++++++++++---- runtime/tests/test_netplay_ice_hub.c | 147 +++++++++++ 12 files changed, 1112 insertions(+), 59 deletions(-) create mode 100644 runtime/include/netplay_ice_hub.h create mode 100644 runtime/src/netplay_ice_hub.c create mode 100644 runtime/tests/test_netplay_ice_hub.c diff --git a/docs/NETPLAY.md b/docs/NETPLAY.md index 84e88539..0b9be8c8 100644 --- a/docs/NETPLAY.md +++ b/docs/NETPLAY.md @@ -145,16 +145,47 @@ peer. Widescreen and frame-rate mods, by contrast, are cleared for netplay. ## 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 @@ -167,7 +198,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) diff --git a/lib/recomp-net b/lib/recomp-net index b0751b7b..748f0534 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit b0751b7b8f8d19a8b3629f46c6d9eb3672dc113b +Subproject commit 748f053439d8239abb512b2b5610725c96e7f99c diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index e0e17da8..73337614 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -35,6 +35,12 @@ if(BUILD_TESTING) 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) + # 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) diff --git a/runtime/include/netplay_ice_hub.h b/runtime/include/netplay_ice_hub.h new file mode 100644 index 00000000..d104ca7d --- /dev/null +++ b/runtime/include/netplay_ice_hub.h @@ -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 */ diff --git a/runtime/include/psx_lobby_client.h b/runtime/include/psx_lobby_client.h index 0831dbb3..e23402f1 100644 --- a/runtime/include/psx_lobby_client.h +++ b/runtime/include/psx_lobby_client.h @@ -184,6 +184,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. */ @@ -238,6 +243,14 @@ typedef struct PsxLobbyJoinInfo { * force_input_relay is 0. The host binds bind_hostport and accepts the * guest (2 seats) or hubs (3+). */ int transport_host; + /* 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]; @@ -374,6 +387,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); diff --git a/runtime/include/psx_netplay.h b/runtime/include/psx_netplay.h index 124d4542..21b23abb 100644 --- a/runtime/include/psx_netplay.h +++ b/runtime/include/psx_netplay.h @@ -83,6 +83,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. */ @@ -125,13 +135,24 @@ uint32_t psx_netplay_sim_tick(void); /* * Snapshot for diagnostic dumps (starvation_dump.jsonl meta, etc.). - * arch_out: "off" | "p2p" | "host_relay" | "server_relay" (never NULL when + * arch_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. diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 5463e147..7702d700 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -433,6 +433,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 diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 4078fdcd..ca8575ce 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -63,6 +63,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "psx_selfcheck.h" #include "psx_lobby_client.h" #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 "netplay_bios_settle.h" #include "netplay_exit_reason.h" #include "host_time.h" @@ -8503,6 +8504,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; @@ -10938,10 +10944,86 @@ 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 (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) { @@ -12770,6 +12852,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(); } @@ -12805,6 +12893,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) { @@ -13128,6 +13226,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; @@ -15331,6 +15433,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; @@ -17226,6 +17329,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; diff --git a/runtime/src/netplay_ice_hub.c b/runtime/src/netplay_ice_hub.c new file mode 100644 index 00000000..e3efa67e --- /dev/null +++ b/runtime/src/netplay_ice_hub.c @@ -0,0 +1,78 @@ +/* netplay_ice_hub.c -- see netplay_ice_hub.h. No recomp-net, no sockets. */ +#include "netplay_ice_hub.h" + +#include + +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; +} diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index b8e43a26..2f55eb4e 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -48,6 +49,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; } @@ -188,6 +198,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" @@ -1109,6 +1120,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; } @@ -1144,7 +1156,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; @@ -1171,6 +1184,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); @@ -1560,6 +1826,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); @@ -1631,7 +1903,11 @@ static int append_match_caps_json(char *dst, size_t dst_cap, const PsxLobbyMatch caps->input_delay, caps->input_prediction, /* only when asked: absent reads as "the SFU, as always" */ - caps->relay_host ? "\"relay\":\"host\"," : "", + 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", @@ -1715,7 +1991,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); @@ -2957,6 +3237,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; @@ -3008,6 +3290,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; @@ -3061,10 +3345,23 @@ static void handle_server_json(const char *json) 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); + 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. */ @@ -3093,6 +3390,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). */ { @@ -3215,6 +3538,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); @@ -3261,6 +3604,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(); @@ -3681,6 +4025,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(); @@ -3897,8 +4242,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; } @@ -4036,6 +4384,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 @@ -4301,6 +4650,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; diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index acbd8f45..3027d4a1 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -3,6 +3,7 @@ #endif #include "psx_netplay.h" +#include "netplay_ice_hub.h" #include "host_time.h" #include "memcard.h" @@ -47,10 +48,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 @@ -129,6 +130,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; @@ -455,6 +457,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; } @@ -603,6 +608,7 @@ typedef struct { int load_apply_failed; /* sticky: staged apply rejected — soft-exit */ /* 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; @@ -3286,6 +3292,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"; } @@ -3503,7 +3510,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"; @@ -3565,6 +3574,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) @@ -3581,53 +3594,187 @@ 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 (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); - fflush(stderr); - return 0; - } - #if defined(RNET_ENABLE_ICE) - /* Explicit ICE only outside MotK online (rare). */ - if (cfg->transport == 1) - return 1; + ice_built = 1; #endif - return 0; + /* §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; + } } +/* 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) { @@ -3637,6 +3784,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(); @@ -3733,9 +3885,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; @@ -3747,7 +3903,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) @@ -3914,6 +4078,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 @@ -3951,6 +4140,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; @@ -4112,10 +4302,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; } diff --git a/runtime/tests/test_netplay_ice_hub.c b/runtime/tests/test_netplay_ice_hub.c new file mode 100644 index 00000000..5f89751d --- /dev/null +++ b/runtime/tests/test_netplay_ice_hub.c @@ -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 + +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; +}