ghost multiplayer: beta hardening - version handshake, crash-proofing, logging, persistence

Production-readiness pass toward 1.0:

- Protocol handshake: JOIN carries kGhostVersion; a mismatched client is
  REFUSED by the relay (HELLO session 0) and its poses are never forwarded,
  so two builds can never silently corrupt each other's pose stream. The
  client surfaces the refusal in the menu and skips refused LAN hosts during
  discovery.
- Crash-proofing: every received payload is bounds-checked against its
  packet size before parsing (HELLO / PoseChunk / JOIN); both the client
  network thread and the relay thread are exception-guarded (a throw logs
  and keeps the loop alive instead of hitting std::terminate). The relay no
  longer creates client records for unknown-address POSE/LEAVE floods and
  enforces a hard client cap (--max-clients, default 64).
- Logging: rate-limited error logging throughout (send failures, resolve
  failures, refusals, thread exceptions, client-cap drops). The relay core
  uses a build-flag shim (REXLOG in-game, a tiny {} formatter in the
  dependency-free standalone), with --verbose join/leave lines for the
  server. Game crashes themselves are already handled by the SDK's crash
  handlers + file logs; this layer now avoids triggering them and explains
  itself when it can't connect.
- Settings persistence: server/room/name/port/rate save into the app's cvar
  config on change, so a player's multiplayer setup survives a restart.
- Menu: status now shows protocol refusals and the last error.

Tests updated for the handshake (version-refusal case added); all suites
pass.
This commit is contained in:
MiTHRAL 2026-08-26 22:07:09 -04:00
parent 36b5169e27
commit 7a48eb9f35
9 changed files with 294 additions and 39 deletions

View file

@ -404,6 +404,7 @@ add_executable(ghost_relay
) )
target_include_directories(ghost_relay PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/native) target_include_directories(ghost_relay PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/native)
target_compile_features(ghost_relay PRIVATE cxx_std_23) target_compile_features(ghost_relay PRIVATE cxx_std_23)
target_compile_definitions(ghost_relay PRIVATE SKATE3_RELAY_STANDALONE)
if(WIN32) if(WIN32)
target_link_libraries(ghost_relay PRIVATE ws2_32) target_link_libraries(ghost_relay PRIVATE ws2_32)
endif() endif()

View file

@ -211,11 +211,13 @@ std::unordered_map<uint32_t, size_t> g_ghost_index;
// Status snapshot. // Status snapshot.
bool g_connected = false; bool g_connected = false;
bool g_hosting = false; bool g_hosting = false;
bool g_refused = false;
uint32_t g_session = 0; uint32_t g_session = 0;
char g_room[kGhostMaxRoomLen] = {}; char g_room[kGhostMaxRoomLen] = {};
uint8_t g_room_len = 0; uint8_t g_room_len = 0;
size_t g_roster = 0; size_t g_roster = 0;
char g_peer[96] = {}; char g_peer[96] = {};
char g_last_error[160] = {};
// Display names of the room members, keyed by their session id (from the // 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. // 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); } 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<std::mutex> 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<uint64_t>& 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<uint64_t> g_log_join{0};
std::atomic<uint64_t> g_log_send{0};
std::atomic<uint64_t> g_log_resolve{0};
std::atomic<uint64_t> g_log_heval{0};
// This player's display name (cvar override or the OS user name). // This player's display name (cvar override or the OS user name).
void CurrentDisplayName(char out[kGhostMaxNameLen], uint8_t* out_len) { void CurrentDisplayName(char out[kGhostMaxNameLen], uint8_t* out_len) {
std::string name = REXCVAR_GET(skate3_ghost_name); std::string name = REXCVAR_GET(skate3_ghost_name);
@ -313,8 +333,20 @@ void SetStatusPeer(const GhostAddr& a) {
} }
// ---- packet send helpers --------------------------------------------------- // ---- 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) { void SendJoin(GhostSock sock, const GhostAddr& to, bool ping) {
JoinPayload jp = {}; JoinPayload jp = {};
jp.version = kGhostVersion;
jp.room_len = 0; jp.room_len = 0;
{ {
std::lock_guard<std::mutex> lock(g_mu); std::lock_guard<std::mutex> lock(g_mu);
@ -326,7 +358,7 @@ void SendJoin(GhostSock sock, const GhostAddr& to, bool ping) {
CurrentDisplayName(jp.name, &jp.name_len); CurrentDisplayName(jp.name, &jp.name_len);
PacketBuf p; PacketBuf p;
BuildPacket(p, ping ? kTypePing : kTypeJoin, &jp, sizeof(jp)); 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) { 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]; PacketBuf chunks[kGhostMaxChunks];
const uint8_t n = GhostPoseBuildChunks(0, pp.seq, pp, chunks); const uint8_t n = GhostPoseBuildChunks(0, pp.seq, pp, chunks);
for (uint8_t i = 0; i < n; ++i) { for (uint8_t i = 0; i < n; ++i) {
GhostSockSendTo(sock, to, &chunks[i], PacketSize(chunks[i])); GhostSend(sock, to, chunks[i]);
} }
} }
// ---- packet receive -------------------------------------------------------- // ---- packet receive --------------------------------------------------------
void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now) { void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now) {
if (p.h.type == kTypePoseChunk) { if (p.h.type == kTypePoseChunk) {
if (p.h.payload_size < sizeof(PoseChunkPayload)) {
return;
}
PoseChunkPayload cp = {}; PoseChunkPayload cp = {};
std::memcpy(&cp, p.payload, sizeof(cp)); std::memcpy(&cp, p.payload, sizeof(cp));
if (cp.session_id == 0 || cp.chunks == 0 || cp.chunks > kGhostMaxChunks) { 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.type == kTypeHello) {
if (p.h.payload_size < sizeof(HelloPayload)) {
return;
}
HelloPayload hp = {}; HelloPayload hp = {};
std::memcpy(&hp, p.payload, sizeof(hp)); std::memcpy(&hp, p.payload, sizeof(hp));
const bool from_public = const bool from_public =
g_peer_public.configured && GhostAddrEqual(from, g_peer_public.addr); g_peer_public.configured && GhostAddrEqual(from, g_peer_public.addr);
const bool from_lan = const bool from_lan =
g_peer_lan.configured && GhostAddrEqual(from, g_peer_lan.addr); 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<std::mutex> 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) { if (from_public || from_lan) {
{
std::lock_guard<std::mutex> lock(g_mu);
g_refused = false;
}
Peer& peer = from_public ? g_peer_public : g_peer_lan; Peer& peer = from_public ? g_peer_public : g_peer_lan;
peer.joined = true; peer.joined = true;
peer.last_hello = now; 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): // 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). // 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). // LAN hosts would otherwise steal our active peer away).
const bool room_match = 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) { if (room_match && !g_peer_public.joined && !g_hosting && !g_peer_lan.joined) {
g_peer_lan.addr = from; g_peer_lan.addr = from;
g_peer_lan.configured = true; g_peer_lan.configured = true;
@ -553,6 +612,7 @@ void PruneGhosts(double now) {
void ResetSession() { void ResetSession() {
std::lock_guard<std::mutex> lock(g_mu); std::lock_guard<std::mutex> lock(g_mu);
g_connected = false; g_connected = false;
g_refused = false;
g_session = 0; g_session = 0;
g_roster = 0; g_roster = 0;
g_ghosts.clear(); g_ghosts.clear();
@ -616,6 +676,7 @@ void GhostNetworkLoop() {
double last_tick = NowSec(); double last_tick = NowSec();
while (!g_stop.load(std::memory_order_relaxed)) { while (!g_stop.load(std::memory_order_relaxed)) {
try {
const double now = NowSec(); const double now = NowSec();
const double dt = std::min(now - last_tick, 0.25); const double dt = std::min(now - last_tick, 0.25);
last_tick = now; last_tick = now;
@ -658,6 +719,12 @@ void GhostNetworkLoop() {
GhostResolve(server + ":" + std::to_string(port), port, &a); GhostResolve(server + ":" + std::to_string(port), port, &a);
if (g_peer_public.configured) { if (g_peer_public.configured) {
g_peer_public.addr = a; 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_server = server;
last_port = port; last_port = port;
@ -740,6 +807,22 @@ void GhostNetworkLoop() {
DrainSocket(sock, now); DrainSocket(sock, now);
PruneGhosts(now); PruneGhosts(now);
std::this_thread::sleep_for(std::chrono::milliseconds(25)); 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) { if (sock != GHOST_INVALID_SOCKET) {
@ -852,6 +935,26 @@ void EnsureNetworkThread() {
g_thread = std::thread(GhostNetworkLoop); 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<const char*, 5> 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<std::string_view> 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() { void Shutdown() {
g_stop.store(true, std::memory_order_relaxed); g_stop.store(true, std::memory_order_relaxed);
if (g_thread.joinable()) { if (g_thread.joinable()) {
@ -867,12 +970,14 @@ bool GetStatus(Status* out) {
out->enabled = Enabled(); out->enabled = Enabled();
out->connected = g_connected; out->connected = g_connected;
out->hosting = g_hosting; out->hosting = g_hosting;
out->refused = g_refused;
out->session_id = g_session; out->session_id = g_session;
out->room_len = g_room_len; out->room_len = g_room_len;
std::memcpy(out->room, g_room, g_room_len); std::memcpy(out->room, g_room, g_room_len);
out->roster = g_roster; out->roster = g_roster;
out->ghosts = g_ghosts.size(); out->ghosts = g_ghosts.size();
std::snprintf(out->peer, sizeof(out->peer), "%s", g_peer); 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; return true;
} }

View file

@ -21,6 +21,7 @@
// network thread never reads guest memory. // network thread never reads guest memory.
#include <cstdint> #include <cstdint>
#include <filesystem>
#include "skate3_native_ghost_proto.h" #include "skate3_native_ghost_proto.h"
@ -43,17 +44,25 @@ void OnFrameSceneBuilt(uint8_t* base, const skate3::native_scene::FrameScene& sc
void EnsureNetworkThread(); void EnsureNetworkThread();
void Shutdown(); 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) -------------------------------------------------------- // ---- status (UI / debug) --------------------------------------------------------
struct Status { struct Status {
bool enabled = false; bool enabled = false;
bool connected = false; // joined to a relay (public or LAN) bool connected = false; // joined to a relay (public or LAN)
bool hosting = false; // this game is relaying a LAN room 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; uint32_t session_id = 0;
char room[kGhostMaxRoomLen] = {}; char room[kGhostMaxRoomLen] = {};
uint8_t room_len = 0; uint8_t room_len = 0;
size_t roster = 0; // other members in our room size_t roster = 0; // other members in our room
size_t ghosts = 0; // remote ghosts currently drawn size_t ghosts = 0; // remote ghosts currently drawn
char peer[96] = {}; // active relay "ip:port" char peer[96] = {}; // active relay "ip:port"
char last_error[160] = {}; // last non-fatal error, for the menu
}; };
bool GetStatus(Status* out); bool GetStatus(Status* out);

View file

@ -60,6 +60,7 @@ struct alignas(1) PacketHeader {
// carries the client's display name, which the relay echoes to room members // carries the client's display name, which the relay echoes to room members
// in the HELLO roster so nameplates can label every ghost. // in the HELLO roster so nameplates can label every ghost.
struct alignas(1) JoinPayload { struct alignas(1) JoinPayload {
uint8_t version; // protocol version (kGhostVersion); mismatches are refused
uint8_t room_len; uint8_t room_len;
char room[kGhostMaxRoomLen]; char room[kGhostMaxRoomLen];
uint8_t name_len; uint8_t name_len;
@ -75,6 +76,7 @@ struct alignas(1) RosterEntry {
struct alignas(1) HelloPayload { struct alignas(1) HelloPayload {
uint32_t session_id; // our assigned session id (0 = refused) uint32_t session_id; // our assigned session id (0 = refused)
uint8_t version; // the version we were accepted under
uint8_t room_len; uint8_t room_len;
char room[kGhostMaxRoomLen]; char room[kGhostMaxRoomLen];
uint8_t roster_count; // other members in this room uint8_t roster_count; // other members in this room

View file

@ -2,7 +2,58 @@
#include <algorithm> #include <algorithm>
#include <chrono> #include <chrono>
#include <cstdio>
#include <cstring> #include <cstring>
#include <string>
#include <vector>
// 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 <typename... Args>
void StandaloneLog(const char* level, const char* fmt, Args&&... args) {
const std::vector<std::string> 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 <rex/logging.h>
#define GHOST_RELAY_INFO(...) REXLOG_INFO(__VA_ARGS__)
#define GHOST_RELAY_WARN(...) REXLOG_WARN(__VA_ARGS__)
#endif
namespace skate3::native_ghost { namespace skate3::native_ghost {
@ -64,6 +115,7 @@ void GhostRelay::SetLockedRoom(const char* room) {
void GhostRelay::Run() { void GhostRelay::Run() {
while (!stop_.load(std::memory_order_relaxed)) { while (!stop_.load(std::memory_order_relaxed)) {
try {
GhostAddr from; GhostAddr from;
PacketBuf p; PacketBuf p;
const int n = GhostSockRecvFrom(sock_, &from, &p, sizeof(p)); const int n = GhostSockRecvFrom(sock_, &from, &p, sizeof(p));
@ -82,10 +134,16 @@ void GhostRelay::Run() {
p.h.payload_size > kGhostMaxPayload) { p.h.payload_size > kGhostMaxPayload) {
continue; continue;
} }
const double now = double(std::chrono::steady_clock::now().time_since_epoch().count()) / const double now =
double(std::chrono::steady_clock::now().time_since_epoch().count()) /
1e9; 1e9;
HandlePacket(from, p, now); HandlePacket(from, p, now);
Sweep(now); Sweep(now);
} catch (const std::exception& e) {
GHOST_RELAY_WARN("relay thread exception: {}", e.what());
} catch (...) {
GHOST_RELAY_WARN("relay thread unknown exception");
}
} }
} }
@ -93,7 +151,10 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p,
double now) { double now) {
std::lock_guard<std::mutex> lock(mu_); std::lock_guard<std::mutex> 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; Client* me = nullptr;
for (Client& c : clients_) { for (Client& c : clients_) {
if (GhostAddrEqual(c.addr, from)) { if (GhostAddrEqual(c.addr, from)) {
@ -102,6 +163,14 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p,
} }
} }
if (me == nullptr) { 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{}); clients_.push_back(Client{});
me = &clients_.back(); me = &clients_.back();
me->addr = from; me->addr = from;
@ -117,8 +186,29 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p,
switch (p.h.type) { switch (p.h.type) {
case kTypeJoin: case kTypeJoin:
case kTypePing: { case kTypePing: {
if (p.h.payload_size < sizeof(JoinPayload)) {
return;
}
JoinPayload jp = {}; JoinPayload jp = {};
std::memcpy(&jp, p.payload, sizeof(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<uint8_t>(jp.name_len, kGhostMaxNameLen));
me->name_len = std::min<uint8_t>(jp.name_len, kGhostMaxNameLen);
}
HelloPayload hp = {};
hp.session_id = 0; // refused
hp.version = kGhostVersion;
BuildPacket(out, kTypeHello, &hp, static_cast<uint16_t>(sizeof(hp)));
send(out);
GHOST_RELAY_WARN("refused {} (client v{}, relay v{})", GhostAddrStr(from),
jp.version, kGhostVersion);
return;
}
if (locked_) { if (locked_) {
std::memcpy(me->room, locked_room_, locked_room_len_); std::memcpy(me->room, locked_room_, locked_room_len_);
me->room_len = 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. // HELLO: our session id, room and the roster of the OTHER members.
HelloPayload hp = {}; HelloPayload hp = {};
hp.session_id = me->session_id; hp.session_id = me->session_id;
hp.version = kGhostVersion;
hp.room_len = me->room_len; hp.room_len = me->room_len;
std::memcpy(hp.room, me->room, me->room_len); std::memcpy(hp.room, me->room, me->room_len);
for (const Client& c : clients_) { for (const Client& c : clients_) {
@ -166,11 +257,16 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p,
} }
BuildPacket(out, kTypeHello, &hp, static_cast<uint16_t>(sizeof(hp))); BuildPacket(out, kTypeHello, &hp, static_cast<uint16_t>(sizeof(hp)));
send(out); 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 { } else {
// PING probe: HELLO with the host's room/roster so probing clients // PING probe: HELLO with the host's room/roster so probing clients
// can decide whether to join this LAN host. // can decide whether to join this LAN host.
HelloPayload hp = {}; HelloPayload hp = {};
hp.session_id = me->session_id; hp.session_id = me->session_id;
hp.version = kGhostVersion;
hp.room_len = me->room_len; hp.room_len = me->room_len;
std::memcpy(hp.room, me->room, me->room_len); std::memcpy(hp.room, me->room, me->room_len);
for (const Client& c : clients_) { for (const Client& c : clients_) {
@ -191,6 +287,10 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p,
case kTypePose: case kTypePose:
case kTypePoseChunk: { 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 // Clients join before posing; a pose with no room is auto-joined to
// the lobby so the relay stays resilient to a missed HELLO. // the lobby so the relay stays resilient to a missed HELLO.
if (me->room_len == 0) { if (me->room_len == 0) {
@ -221,11 +321,14 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p,
} }
case kTypeLeave: { case kTypeLeave: {
clients_.erase(std::remove_if(clients_.begin(), clients_.end(), const auto it = std::remove_if(clients_.begin(), clients_.end(),
[&from](const Client& c) { [&from](const Client& c) {
return GhostAddrEqual(c.addr, from); return GhostAddrEqual(c.addr, from);
}), });
clients_.end()); if (it != clients_.end() && verbose_.load(std::memory_order_relaxed)) {
GHOST_RELAY_INFO("{} left", GhostAddrStr(from));
}
clients_.erase(it, clients_.end());
break; break;
} }

View file

@ -43,6 +43,14 @@ class GhostRelay {
// binary's --room lock); client JOINs and PINGs cannot override it. // binary's --room lock); client JOINs and PINGs cannot override it.
void SetLockedRoom(const char* room); 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: private:
void Run(); void Run();
void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now); void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now);
@ -53,6 +61,8 @@ class GhostRelay {
std::thread thread_; std::thread thread_;
std::atomic<bool> running_{false}; std::atomic<bool> running_{false};
std::atomic<bool> stop_{false}; std::atomic<bool> stop_{false};
uint32_t max_clients_ = 64;
std::atomic<bool> verbose_{false};
struct Client { struct Client {
GhostAddr addr; GhostAddr addr;
@ -61,6 +71,7 @@ class GhostRelay {
uint8_t room_len = 0; uint8_t room_len = 0;
char name[kGhostMaxNameLen] = {}; char name[kGhostMaxNameLen] = {};
uint8_t name_len = 0; uint8_t name_len = 0;
bool refused = false; // version mismatch: poses from this client dropped
double last_seen = 0.0; double last_seen = 0.0;
}; };
mutable std::mutex mu_; mutable std::mutex mu_;

View file

@ -801,8 +801,10 @@ void Skate3BaseApp::OnPostSetup() {
auto* dispatcher = runtime()->function_dispatcher(); auto* dispatcher = runtime()->function_dispatcher();
skate3::native_render::Install(); skate3::native_render::Install();
skate3::demo_path::InstallHooks(dispatcher); 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::EnsureNetworkThread();
skate3::native_ghost::InstallSettingsPersistence(config_path_);
// User-facing intro-movie skip (independent of the demo path): the movie // User-facing intro-movie skip (independent of the demo path): the movie
// completion override polls the merged UI pad state through this provider. // completion override polls the merged UI pad state through this provider.
skate3::demo_path::SetUiInputProvider([this]() { skate3::demo_path::SetUiInputProvider([this]() {

View file

@ -153,6 +153,13 @@ void GhostDialog::OnDraw(ImGuiIO& io) {
if (st.peer[0] != '\0') { if (st.peer[0] != '\0') {
ImGui::Text(" Relay: %s", st.peer); 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(); ImGui::End();
if (!open) { if (!open) {

View file

@ -31,6 +31,8 @@ void OnSignal(int) { g_stop = 1; }
int main(int argc, char** argv) { int main(int argc, char** argv) {
uint16_t port = skate3::native_ghost::kGhostDefaultPort; uint16_t port = skate3::native_ghost::kGhostDefaultPort;
std::string locked_room; std::string locked_room;
uint32_t max_clients = 64;
bool verbose = false;
for (int i = 1; i < argc; ++i) { for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i]; const std::string arg = argv[i];
auto next = [&](const char* name) -> std::string { auto next = [&](const char* name) -> std::string {
@ -44,13 +46,21 @@ int main(int argc, char** argv) {
port = uint16_t(std::stoi(next("--port"))); port = uint16_t(std::stoi(next("--port")));
} else if (arg == "--room") { } else if (arg == "--room") {
locked_room = next("--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") { } else if (arg == "--help" || arg == "-h") {
std::printf( std::printf(
"ghost_relay - ghost multiplayer UDP relay\n" "ghost_relay - ghost multiplayer UDP relay\n"
"usage: ghost_relay [--port 40001] [--room CODE]\n" "usage: ghost_relay [--port 40001] [--room CODE] [--max-clients 64] "
" --port UDP port to listen on (clients connect here; allow this\n" "[--verbose]\n"
" port through any firewall, e.g. `ufw allow 40001/udp`)\n" " --port UDP port to listen on (clients connect here; allow\n"
" --room optional: force every client into one room code\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; return 0;
} else { } else {
std::fprintf(stderr, "ghost_relay: unknown argument '%s'\n", arg.c_str()); 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()) { if (!locked_room.empty()) {
relay.SetLockedRoom(locked_room.c_str()); relay.SetLockedRoom(locked_room.c_str());
} }
relay.SetMaxClients(max_clients);
relay.SetVerbose(verbose);
if (!relay.Start()) { if (!relay.Start()) {
std::fprintf(stderr, "ghost_relay: failed to bind UDP port %u\n", port); std::fprintf(stderr, "ghost_relay: failed to bind UDP port %u\n", port);
return 1; return 1;
@ -74,6 +86,9 @@ int main(int argc, char** argv) {
const std::string lock_note = const std::string lock_note =
locked_room.empty() ? "" : (" (locked room: " + locked_room + ")"); 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: 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 " std::printf("ghost_relay: clients connect outbound; no client-side port "
"forwarding needed\n"); "forwarding needed\n");