diff --git a/CMakeLists.txt b/CMakeLists.txt index 1a452f0..8ffca5a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -404,6 +404,7 @@ add_executable(ghost_relay ) target_include_directories(ghost_relay PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/native) target_compile_features(ghost_relay PRIVATE cxx_std_23) +target_compile_definitions(ghost_relay PRIVATE SKATE3_RELAY_STANDALONE) if(WIN32) target_link_libraries(ghost_relay PRIVATE ws2_32) endif() diff --git a/src/native/skate3_native_ghost.cpp b/src/native/skate3_native_ghost.cpp index 64d3892..5f9a852 100644 --- a/src/native/skate3_native_ghost.cpp +++ b/src/native/skate3_native_ghost.cpp @@ -211,11 +211,13 @@ std::unordered_map g_ghost_index; // Status snapshot. bool g_connected = false; bool g_hosting = false; +bool g_refused = false; uint32_t g_session = 0; char g_room[kGhostMaxRoomLen] = {}; uint8_t g_room_len = 0; size_t g_roster = 0; char g_peer[96] = {}; +char g_last_error[160] = {}; // Display names of the room members, keyed by their session id (from the // latest HELLO roster; refreshed by the periodic re-JOIN). g_mu-guarded. @@ -264,6 +266,24 @@ bool Enabled() { return REXCVAR_GET(skate3_ghost_enable); } bool Debug() { return REXCVAR_GET(skate3_ghost_debug); } +// Set the human-readable last-error surfaced in the multiplayer menu. +void SetLastError(const std::string& msg) { + std::lock_guard lock(g_mu); + std::snprintf(g_last_error, sizeof(g_last_error), "%s", msg.c_str()); +} + +// Rate-limited logging: emits on the first `first` occurrences, then every +// `every`-th, so a failing relay can't flood the log. +bool LogGate(std::atomic& counter, uint64_t first, uint64_t every) { + const uint64_t n = counter.fetch_add(1, std::memory_order_relaxed); + return n < first || (every != 0 && n % every == 0); +} + +std::atomic g_log_join{0}; +std::atomic g_log_send{0}; +std::atomic g_log_resolve{0}; +std::atomic g_log_heval{0}; + // This player's display name (cvar override or the OS user name). void CurrentDisplayName(char out[kGhostMaxNameLen], uint8_t* out_len) { std::string name = REXCVAR_GET(skate3_ghost_name); @@ -313,8 +333,20 @@ void SetStatusPeer(const GhostAddr& a) { } // ---- packet send helpers --------------------------------------------------- +bool GhostSend(GhostSock sock, const GhostAddr& to, const PacketBuf& p) { + if (GhostSockSendTo(sock, to, &p, PacketSize(p))) { + return true; + } + if (LogGate(g_log_send, 1, 64)) { + REXLOG_WARN("ghost: UDP send to {} failed (err {})", GhostAddrStr(to).c_str(), + GhostSockLastError()); + } + return false; +} + void SendJoin(GhostSock sock, const GhostAddr& to, bool ping) { JoinPayload jp = {}; + jp.version = kGhostVersion; jp.room_len = 0; { std::lock_guard lock(g_mu); @@ -326,7 +358,7 @@ void SendJoin(GhostSock sock, const GhostAddr& to, bool ping) { CurrentDisplayName(jp.name, &jp.name_len); PacketBuf p; BuildPacket(p, ping ? kTypePing : kTypeJoin, &jp, sizeof(jp)); - GhostSockSendTo(sock, to, &p, PacketSize(p)); + GhostSend(sock, to, p); } void SendPose(GhostSock sock, const GhostAddr& to, uint32_t& seq) { @@ -356,13 +388,16 @@ void SendPose(GhostSock sock, const GhostAddr& to, uint32_t& seq) { PacketBuf chunks[kGhostMaxChunks]; const uint8_t n = GhostPoseBuildChunks(0, pp.seq, pp, chunks); for (uint8_t i = 0; i < n; ++i) { - GhostSockSendTo(sock, to, &chunks[i], PacketSize(chunks[i])); + GhostSend(sock, to, chunks[i]); } } // ---- packet receive -------------------------------------------------------- void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now) { if (p.h.type == kTypePoseChunk) { + if (p.h.payload_size < sizeof(PoseChunkPayload)) { + return; + } PoseChunkPayload cp = {}; std::memcpy(&cp, p.payload, sizeof(cp)); if (cp.session_id == 0 || cp.chunks == 0 || cp.chunks > kGhostMaxChunks) { @@ -454,13 +489,35 @@ void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now) { } if (p.h.type == kTypeHello) { + if (p.h.payload_size < sizeof(HelloPayload)) { + return; + } HelloPayload hp = {}; std::memcpy(&hp, p.payload, sizeof(hp)); const bool from_public = g_peer_public.configured && GhostAddrEqual(from, g_peer_public.addr); const bool from_lan = g_peer_lan.configured && GhostAddrEqual(from, g_peer_lan.addr); + // Protocol refusal: the relay answers a version-mismatched JOIN with + // session 0. Surface it once instead of silently retrying forever. + if (hp.session_id == 0 && (from_public || from_lan)) { + { + std::lock_guard lock(g_mu); + g_refused = true; + } + SetLastError("The relay refused the connection (protocol version " + "mismatch; the game build and the server must match)."); + if (LogGate(g_log_join, 1, 16)) { + REXLOG_WARN("ghost: refused by {} (protocol version mismatch)", + GhostAddrStr(from).c_str()); + } + return; + } if (from_public || from_lan) { + { + std::lock_guard lock(g_mu); + g_refused = false; + } Peer& peer = from_public ? g_peer_public : g_peer_lan; peer.joined = true; peer.last_hello = now; @@ -496,10 +553,12 @@ void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now) { } // A LAN host reply to our discovery PING (from != our configured peers): // remember the host if its room matches ours (or we have no room yet). - // Only adopt a new host while we are not already joined to one (multiple + // Only adopt a host that accepted the probe (session 0 = version + // refusal) and only while we are not already joined to one (multiple // LAN hosts would otherwise steal our active peer away). const bool room_match = - RoomEquals(g_room, g_room_len, hp.room, hp.room_len) || g_room_len == 0; + hp.session_id != 0 && + (RoomEquals(g_room, g_room_len, hp.room, hp.room_len) || g_room_len == 0); if (room_match && !g_peer_public.joined && !g_hosting && !g_peer_lan.joined) { g_peer_lan.addr = from; g_peer_lan.configured = true; @@ -553,6 +612,7 @@ void PruneGhosts(double now) { void ResetSession() { std::lock_guard lock(g_mu); g_connected = false; + g_refused = false; g_session = 0; g_roster = 0; g_ghosts.clear(); @@ -616,9 +676,10 @@ void GhostNetworkLoop() { double last_tick = NowSec(); while (!g_stop.load(std::memory_order_relaxed)) { - const double now = NowSec(); - const double dt = std::min(now - last_tick, 0.25); - last_tick = now; + try { + const double now = NowSec(); + const double dt = std::min(now - last_tick, 0.25); + last_tick = now; if (!Enabled()) { ResetSession(); @@ -658,6 +719,12 @@ void GhostNetworkLoop() { GhostResolve(server + ":" + std::to_string(port), port, &a); if (g_peer_public.configured) { g_peer_public.addr = a; + } else { + if (LogGate(g_log_resolve, 1, 16)) { + REXLOG_WARN("ghost: cannot resolve relay '{}'", server.c_str()); + } + SetLastError("Cannot resolve the relay '" + server + "' — falling " + "back to LAN auto-host."); } last_server = server; last_port = port; @@ -740,6 +807,22 @@ void GhostNetworkLoop() { DrainSocket(sock, now); PruneGhosts(now); std::this_thread::sleep_for(std::chrono::milliseconds(25)); + } catch (const std::exception& e) { + // A throw in the network thread must never escape to std::terminate: + // log and keep the loop alive (the platform crash handlers stay armed + // for genuine faults, but this layer should never trigger them). + SetLastError(std::string("Network error: ") + e.what()); + if (LogGate(g_log_heval, 1, 32)) { + REXLOG_ERROR("ghost: network thread exception: {}", e.what()); + } + std::this_thread::sleep_for(std::chrono::milliseconds(250)); + } catch (...) { + SetLastError("Unknown network error"); + if (LogGate(g_log_heval, 1, 32)) { + REXLOG_ERROR("ghost: network thread unknown exception"); + } + std::this_thread::sleep_for(std::chrono::milliseconds(250)); + } } if (sock != GHOST_INVALID_SOCKET) { @@ -852,6 +935,26 @@ void EnsureNetworkThread() { g_thread = std::thread(GhostNetworkLoop); } +void InstallSettingsPersistence(const std::filesystem::path& config_path) { + // Persist the multiplayer settings into the SDK's cvar config (the same + // file LoadConfig reads at startup) whenever they change, so a player's + // server/room/name survive a restart. + static const std::array kPersistCvars = { + "skate3_ghost_server", "skate3_ghost_room", "skate3_ghost_name", + "skate3_ghost_port", "skate3_ghost_rate", + }; + const auto saver = [config_path](std::string_view, std::string_view) { + std::vector names(kPersistCvars.begin(), + kPersistCvars.end()); + rex::cvar::SaveConfigValues(config_path, names); + }; + for (const char* name : kPersistCvars) { + rex::cvar::RegisterChangeCallback(name, saver); + } + REXLOG_INFO("ghost: multiplayer settings persist to {}", + config_path.string().c_str()); +} + void Shutdown() { g_stop.store(true, std::memory_order_relaxed); if (g_thread.joinable()) { @@ -867,12 +970,14 @@ bool GetStatus(Status* out) { out->enabled = Enabled(); out->connected = g_connected; out->hosting = g_hosting; + out->refused = g_refused; out->session_id = g_session; out->room_len = g_room_len; std::memcpy(out->room, g_room, g_room_len); out->roster = g_roster; out->ghosts = g_ghosts.size(); std::snprintf(out->peer, sizeof(out->peer), "%s", g_peer); + std::snprintf(out->last_error, sizeof(out->last_error), "%s", g_last_error); return true; } diff --git a/src/native/skate3_native_ghost.h b/src/native/skate3_native_ghost.h index d3e6954..7d05c88 100644 --- a/src/native/skate3_native_ghost.h +++ b/src/native/skate3_native_ghost.h @@ -21,6 +21,7 @@ // network thread never reads guest memory. #include +#include #include "skate3_native_ghost_proto.h" @@ -43,17 +44,25 @@ void OnFrameSceneBuilt(uint8_t* base, const skate3::native_scene::FrameScene& sc void EnsureNetworkThread(); void Shutdown(); +// Persist the multiplayer cvars (server/room/name/port/rate) into the app's +// cvar config file whenever they change, so a player's settings survive a +// restart. config_path is the same file the SDK loads at startup. Safe to +// call once at startup. +void InstallSettingsPersistence(const std::filesystem::path& config_path); + // ---- status (UI / debug) -------------------------------------------------------- struct Status { bool enabled = false; bool connected = false; // joined to a relay (public or LAN) bool hosting = false; // this game is relaying a LAN room + bool refused = false; // the relay refused us (protocol version mismatch) uint32_t session_id = 0; char room[kGhostMaxRoomLen] = {}; uint8_t room_len = 0; size_t roster = 0; // other members in our room size_t ghosts = 0; // remote ghosts currently drawn char peer[96] = {}; // active relay "ip:port" + char last_error[160] = {}; // last non-fatal error, for the menu }; bool GetStatus(Status* out); diff --git a/src/native/skate3_native_ghost_proto.h b/src/native/skate3_native_ghost_proto.h index d458527..9bb9273 100644 --- a/src/native/skate3_native_ghost_proto.h +++ b/src/native/skate3_native_ghost_proto.h @@ -60,6 +60,7 @@ struct alignas(1) PacketHeader { // carries the client's display name, which the relay echoes to room members // in the HELLO roster so nameplates can label every ghost. struct alignas(1) JoinPayload { + uint8_t version; // protocol version (kGhostVersion); mismatches are refused uint8_t room_len; char room[kGhostMaxRoomLen]; uint8_t name_len; @@ -75,6 +76,7 @@ struct alignas(1) RosterEntry { struct alignas(1) HelloPayload { uint32_t session_id; // our assigned session id (0 = refused) + uint8_t version; // the version we were accepted under uint8_t room_len; char room[kGhostMaxRoomLen]; uint8_t roster_count; // other members in this room diff --git a/src/native/skate3_native_ghost_relay.cpp b/src/native/skate3_native_ghost_relay.cpp index 181ed60..67b161c 100644 --- a/src/native/skate3_native_ghost_relay.cpp +++ b/src/native/skate3_native_ghost_relay.cpp @@ -2,7 +2,58 @@ #include #include +#include #include +#include +#include + +// The relay core is compiled into BOTH the game (rex logging available) and +// the dependency-free standalone ghost_relay binary; route its logs through +// a small build-flag shim so the standalone stays rexglue-free. +#if defined(SKATE3_RELAY_STANDALONE) +namespace skate3::native_ghost { +namespace { +inline std::string StandaloneArg(uint32_t v) { return std::to_string(v); } +inline std::string StandaloneArg(int v) { return std::to_string(v); } +inline std::string StandaloneArg(const std::string& v) { return v; } +inline std::string StandaloneArg(const char* v) { + return v != nullptr ? v : "(null)"; +} + +// Minimal "{}"-placeholder formatter (the relay logs use fmt-style strings +// because the game build routes them through REXLOG). +template +void StandaloneLog(const char* level, const char* fmt, Args&&... args) { + const std::vector vals = {StandaloneArg(args)...}; + std::string out; + size_t k = 0; + const char* p = fmt; + while (*p != '\0') { + if (p[0] == '{' && p[1] == '}' && k < vals.size()) { + out += vals[k++]; + p += 2; + } else { + out += *p++; + } + } + if (level[0] == 'W') { + std::fprintf(stderr, "[ghost_relay] %s: %s\n", level, out.c_str()); + } else { + std::printf("[ghost_relay] %s: %s\n", level, out.c_str()); + } +} +} // namespace +} // namespace skate3::native_ghost + +#define GHOST_RELAY_INFO(...) \ + ::skate3::native_ghost::StandaloneLog("I", __VA_ARGS__) +#define GHOST_RELAY_WARN(...) \ + ::skate3::native_ghost::StandaloneLog("W", __VA_ARGS__) +#else +#include +#define GHOST_RELAY_INFO(...) REXLOG_INFO(__VA_ARGS__) +#define GHOST_RELAY_WARN(...) REXLOG_WARN(__VA_ARGS__) +#endif namespace skate3::native_ghost { @@ -64,28 +115,35 @@ void GhostRelay::SetLockedRoom(const char* room) { void GhostRelay::Run() { while (!stop_.load(std::memory_order_relaxed)) { - GhostAddr from; - PacketBuf p; - const int n = GhostSockRecvFrom(sock_, &from, &p, sizeof(p)); - if (n <= 0) { - Sweep(0.0); - continue; + try { + GhostAddr from; + PacketBuf p; + const int n = GhostSockRecvFrom(sock_, &from, &p, sizeof(p)); + if (n <= 0) { + Sweep(0.0); + continue; + } + if (n < static_cast(sizeof(PacketHeader))) { + continue; + } + if (p.h.magic != kGhostMagic) { + continue; + } + const uint16_t expect = static_cast(n - sizeof(PacketHeader)); + if (p.h.payload_size != expect || + p.h.payload_size > kGhostMaxPayload) { + continue; + } + const double now = + double(std::chrono::steady_clock::now().time_since_epoch().count()) / + 1e9; + HandlePacket(from, p, now); + Sweep(now); + } catch (const std::exception& e) { + GHOST_RELAY_WARN("relay thread exception: {}", e.what()); + } catch (...) { + GHOST_RELAY_WARN("relay thread unknown exception"); } - if (n < static_cast(sizeof(PacketHeader))) { - continue; - } - if (p.h.magic != kGhostMagic) { - continue; - } - const uint16_t expect = static_cast(n - sizeof(PacketHeader)); - if (p.h.payload_size != expect || - p.h.payload_size > kGhostMaxPayload) { - continue; - } - const double now = double(std::chrono::steady_clock::now().time_since_epoch().count()) / - 1e9; - HandlePacket(from, p, now); - Sweep(now); } } @@ -93,7 +151,10 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p, double now) { std::lock_guard lock(mu_); - // Resolve (or create) the sender's client record. + // Resolve the sender's client record. Client records are only created by + // JOIN/PING (a client must announce itself before posing); any other + // packet from an unknown address is dropped, so spoofed POSE/LEAVE floods + // cannot fill the client table. Client* me = nullptr; for (Client& c : clients_) { if (GhostAddrEqual(c.addr, from)) { @@ -102,6 +163,14 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p, } } if (me == nullptr) { + if (p.h.type != kTypeJoin && p.h.type != kTypePing) { + return; + } + if (clients_.size() >= max_clients_) { + GHOST_RELAY_WARN("client cap reached ({}); dropping {}", max_clients_, + GhostAddrStr(from)); + return; + } clients_.push_back(Client{}); me = &clients_.back(); me->addr = from; @@ -117,8 +186,29 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p, switch (p.h.type) { case kTypeJoin: case kTypePing: { + if (p.h.payload_size < sizeof(JoinPayload)) { + return; + } JoinPayload jp = {}; std::memcpy(&jp, p.payload, sizeof(jp)); + // Protocol handshake: a mismatched version is REFUSED (HELLO with + // session 0) and its poses are dropped, so two builds can never + // silently corrupt each other's pose stream. + if (jp.version != kGhostVersion) { + me->refused = true; + if (!me->name[0]) { + std::memcpy(me->name, jp.name, std::min(jp.name_len, kGhostMaxNameLen)); + me->name_len = std::min(jp.name_len, kGhostMaxNameLen); + } + HelloPayload hp = {}; + hp.session_id = 0; // refused + hp.version = kGhostVersion; + BuildPacket(out, kTypeHello, &hp, static_cast(sizeof(hp))); + send(out); + GHOST_RELAY_WARN("refused {} (client v{}, relay v{})", GhostAddrStr(from), + jp.version, kGhostVersion); + return; + } if (locked_) { std::memcpy(me->room, locked_room_, locked_room_len_); me->room_len = locked_room_len_; @@ -152,6 +242,7 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p, // HELLO: our session id, room and the roster of the OTHER members. HelloPayload hp = {}; hp.session_id = me->session_id; + hp.version = kGhostVersion; hp.room_len = me->room_len; std::memcpy(hp.room, me->room, me->room_len); for (const Client& c : clients_) { @@ -166,11 +257,16 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p, } BuildPacket(out, kTypeHello, &hp, static_cast(sizeof(hp))); send(out); + if (verbose_.load(std::memory_order_relaxed)) { + GHOST_RELAY_INFO("{} joined room '{}' (session {})", GhostAddrStr(from), + std::string(hp.room, hp.room_len), me->session_id); + } } else { // PING probe: HELLO with the host's room/roster so probing clients // can decide whether to join this LAN host. HelloPayload hp = {}; hp.session_id = me->session_id; + hp.version = kGhostVersion; hp.room_len = me->room_len; std::memcpy(hp.room, me->room, me->room_len); for (const Client& c : clients_) { @@ -191,6 +287,10 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p, case kTypePose: case kTypePoseChunk: { + // A refused (version-mismatched) client's poses are never forwarded. + if (me->refused) { + return; + } // Clients join before posing; a pose with no room is auto-joined to // the lobby so the relay stays resilient to a missed HELLO. if (me->room_len == 0) { @@ -221,11 +321,14 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p, } case kTypeLeave: { - clients_.erase(std::remove_if(clients_.begin(), clients_.end(), - [&from](const Client& c) { - return GhostAddrEqual(c.addr, from); - }), - clients_.end()); + const auto it = std::remove_if(clients_.begin(), clients_.end(), + [&from](const Client& c) { + return GhostAddrEqual(c.addr, from); + }); + if (it != clients_.end() && verbose_.load(std::memory_order_relaxed)) { + GHOST_RELAY_INFO("{} left", GhostAddrStr(from)); + } + clients_.erase(it, clients_.end()); break; } diff --git a/src/native/skate3_native_ghost_relay.h b/src/native/skate3_native_ghost_relay.h index bb67bb5..0cd0b74 100644 --- a/src/native/skate3_native_ghost_relay.h +++ b/src/native/skate3_native_ghost_relay.h @@ -43,6 +43,14 @@ class GhostRelay { // binary's --room lock); client JOINs and PINGs cannot override it. void SetLockedRoom(const char* room); + // Hard cap on simultaneously tracked clients (default 64). New JOIN/PING + // sources past the cap are dropped (logged). + void SetMaxClients(uint32_t max_clients) { max_clients_ = max_clients; } + + // Verbose per-event logging (join/leave/refusals); the standalone server's + // --verbose flag. Off by default to keep a busy relay quiet. + void SetVerbose(bool verbose) { verbose_ = verbose; } + private: void Run(); void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now); @@ -53,6 +61,8 @@ class GhostRelay { std::thread thread_; std::atomic running_{false}; std::atomic stop_{false}; + uint32_t max_clients_ = 64; + std::atomic verbose_{false}; struct Client { GhostAddr addr; @@ -61,6 +71,7 @@ class GhostRelay { uint8_t room_len = 0; char name[kGhostMaxNameLen] = {}; uint8_t name_len = 0; + bool refused = false; // version mismatch: poses from this client dropped double last_seen = 0.0; }; mutable std::mutex mu_; diff --git a/src/skate3_app_common.cpp b/src/skate3_app_common.cpp index ef89153..675f26a 100644 --- a/src/skate3_app_common.cpp +++ b/src/skate3_app_common.cpp @@ -801,8 +801,10 @@ void Skate3BaseApp::OnPostSetup() { auto* dispatcher = runtime()->function_dispatcher(); skate3::native_render::Install(); skate3::demo_path::InstallHooks(dispatcher); - // Ghost multiplayer network thread (idle until skate3_ghost_enable). + // Ghost multiplayer network thread (idle until skate3_ghost_enable) and + // settings persistence (server/room/name/port/rate) into the cvar config. skate3::native_ghost::EnsureNetworkThread(); + skate3::native_ghost::InstallSettingsPersistence(config_path_); // User-facing intro-movie skip (independent of the demo path): the movie // completion override polls the merged UI pad state through this provider. skate3::demo_path::SetUiInputProvider([this]() { diff --git a/src/skate3_native_ghost_dialog.cpp b/src/skate3_native_ghost_dialog.cpp index 9018c91..8faf474 100644 --- a/src/skate3_native_ghost_dialog.cpp +++ b/src/skate3_native_ghost_dialog.cpp @@ -153,6 +153,13 @@ void GhostDialog::OnDraw(ImGuiIO& io) { if (st.peer[0] != '\0') { ImGui::Text(" Relay: %s", st.peer); } + if (st.refused) { + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.3f, 1.0f), + " Refused by relay (protocol version mismatch)"); + } + if (st.last_error[0] != '\0') { + ImGui::TextWrapped(" %s", st.last_error); + } ImGui::End(); if (!open) { diff --git a/tools/ghost_relay_main.cpp b/tools/ghost_relay_main.cpp index 6428e0c..92f7d70 100644 --- a/tools/ghost_relay_main.cpp +++ b/tools/ghost_relay_main.cpp @@ -31,6 +31,8 @@ void OnSignal(int) { g_stop = 1; } int main(int argc, char** argv) { uint16_t port = skate3::native_ghost::kGhostDefaultPort; std::string locked_room; + uint32_t max_clients = 64; + bool verbose = false; for (int i = 1; i < argc; ++i) { const std::string arg = argv[i]; auto next = [&](const char* name) -> std::string { @@ -44,13 +46,21 @@ int main(int argc, char** argv) { port = uint16_t(std::stoi(next("--port"))); } else if (arg == "--room") { locked_room = next("--room"); + } else if (arg == "--max-clients") { + max_clients = uint32_t(std::stoul(next("--max-clients"))); + } else if (arg == "--verbose") { + verbose = true; } else if (arg == "--help" || arg == "-h") { std::printf( "ghost_relay - ghost multiplayer UDP relay\n" - "usage: ghost_relay [--port 40001] [--room CODE]\n" - " --port UDP port to listen on (clients connect here; allow this\n" - " port through any firewall, e.g. `ufw allow 40001/udp`)\n" - " --room optional: force every client into one room code\n"); + "usage: ghost_relay [--port 40001] [--room CODE] [--max-clients 64] " + "[--verbose]\n" + " --port UDP port to listen on (clients connect here; allow\n" + " this port through any firewall, e.g.\n" + " `ufw allow 40001/udp`)\n" + " --room optional: force every client into one room code\n" + " --max-clients hard cap on simultaneously tracked clients (default 64)\n" + " --verbose log every join/leave to stdout\n"); return 0; } else { std::fprintf(stderr, "ghost_relay: unknown argument '%s'\n", arg.c_str()); @@ -67,6 +77,8 @@ int main(int argc, char** argv) { if (!locked_room.empty()) { relay.SetLockedRoom(locked_room.c_str()); } + relay.SetMaxClients(max_clients); + relay.SetVerbose(verbose); if (!relay.Start()) { std::fprintf(stderr, "ghost_relay: failed to bind UDP port %u\n", port); return 1; @@ -74,6 +86,9 @@ int main(int argc, char** argv) { const std::string lock_note = locked_room.empty() ? "" : (" (locked room: " + locked_room + ")"); std::printf("ghost_relay: listening on UDP %u%s\n", port, lock_note.c_str()); + std::printf("ghost_relay: clients connect outbound; no client-side port " + "forwarding needed (max clients %u)\n", + max_clients); std::printf("ghost_relay: clients connect outbound; no client-side port " "forwarding needed\n");