The M menu now has a room browser: a Refresh button asks the relay for its room list, and the list (room code, player count, up to 4 member names) is shown as clickable rows - clicking one sets the room code and joins on the next heartbeat. - Protocol: kTypeList (client -> relay, empty payload) and kTypeRooms (relay -> client, RoomsPayload: up to 16 rooms, code + count + names). LIST works from unjoined addresses so the browser can query before joining; a joined client's LIST is answered too. Version bumped to 4. - Relay: BuildRoomList aggregates the client table by room (refused clients excluded), most-populated first, capped at kGhostMaxRooms. - Client: RequestRooms()/GetRooms() - the network thread sends LIST to the best current peer (public relay, LAN peer, or the local host when hosting) and stores the reply under the shared lock; ROOMS replies from any other address are ignored. The list is cleared on disable. - Dialog: CollapsingHeader 'Browse rooms (lobby)' with refresh + selectable rows. New relay test (ghost_relay_test6) covers the lobby path; all suites pass.
414 lines
No EOL
14 KiB
C++
414 lines
No EOL
14 KiB
C++
#include "skate3_native_ghost_relay.h"
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <string>
|
|
#include <unordered_map>
|
|
#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 {
|
|
constexpr double kClientTimeoutSec = 8.0;
|
|
} // namespace
|
|
|
|
GhostRelay::~GhostRelay() {
|
|
Stop();
|
|
}
|
|
|
|
bool GhostRelay::Start() {
|
|
if (running_.load(std::memory_order_relaxed)) {
|
|
return true;
|
|
}
|
|
sock_ = GhostSocketCreate(/*allow_broadcast=*/true);
|
|
if (sock_ == GHOST_INVALID_SOCKET) {
|
|
return false;
|
|
}
|
|
if (!GhostSockBind(sock_, port_)) {
|
|
GhostSockClose(sock_);
|
|
sock_ = GHOST_INVALID_SOCKET;
|
|
return false;
|
|
}
|
|
GhostSockSetRecvTimeout(sock_, 200);
|
|
running_.store(true, std::memory_order_relaxed);
|
|
stop_.store(false, std::memory_order_relaxed);
|
|
thread_ = std::thread(&GhostRelay::Run, this);
|
|
return true;
|
|
}
|
|
|
|
void GhostRelay::Stop() {
|
|
if (!running_.load(std::memory_order_relaxed)) {
|
|
return;
|
|
}
|
|
stop_.store(true, std::memory_order_relaxed);
|
|
if (thread_.joinable()) {
|
|
thread_.join();
|
|
}
|
|
if (sock_ != GHOST_INVALID_SOCKET) {
|
|
GhostSockClose(sock_);
|
|
sock_ = GHOST_INVALID_SOCKET;
|
|
}
|
|
running_.store(false, std::memory_order_relaxed);
|
|
std::lock_guard<std::mutex> lock(mu_);
|
|
clients_.clear();
|
|
}
|
|
|
|
size_t GhostRelay::ClientCount() const {
|
|
std::lock_guard<std::mutex> lock(mu_);
|
|
return clients_.size();
|
|
}
|
|
|
|
void GhostRelay::SetLockedRoom(const char* room) {
|
|
std::lock_guard<std::mutex> lock(mu_);
|
|
RoomSet(locked_room_, locked_room_len_, room);
|
|
locked_ = locked_room_len_ != 0;
|
|
}
|
|
|
|
void GhostRelay::Run() {
|
|
while (!stop_.load(std::memory_order_relaxed)) {
|
|
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<int>(sizeof(PacketHeader))) {
|
|
continue;
|
|
}
|
|
if (p.h.magic != kGhostMagic) {
|
|
continue;
|
|
}
|
|
const uint16_t expect = static_cast<uint16_t>(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");
|
|
}
|
|
}
|
|
}
|
|
|
|
void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p,
|
|
double now) {
|
|
std::lock_guard<std::mutex> lock(mu_);
|
|
|
|
// 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. LIST is the exception: a lobby browser can
|
|
// ask for the room list before joining anything.
|
|
Client* me = nullptr;
|
|
for (Client& c : clients_) {
|
|
if (GhostAddrEqual(c.addr, from)) {
|
|
me = &c;
|
|
break;
|
|
}
|
|
}
|
|
if (me == nullptr) {
|
|
if (p.h.type == kTypeList) {
|
|
BuildRoomList(from);
|
|
return;
|
|
}
|
|
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;
|
|
me->session_id = next_session_++;
|
|
RoomClear(me->room, me->room_len);
|
|
}
|
|
|
|
const auto send = [this, &from](const PacketBuf& out) {
|
|
GhostSockSendTo(sock_, from, &out, PacketSize(out));
|
|
};
|
|
PacketBuf out = {};
|
|
|
|
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<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_) {
|
|
std::memcpy(me->room, locked_room_, locked_room_len_);
|
|
me->room_len = locked_room_len_;
|
|
} else if (RoomValid(jp.room, jp.room_len)) {
|
|
std::memcpy(me->room, jp.room, jp.room_len);
|
|
me->room_len = jp.room_len;
|
|
}
|
|
// Keep the client's display name (JOIN carries it; PINGs from probes
|
|
// may omit it, so only overwrite with a non-empty name).
|
|
if (jp.name_len != 0 && jp.name_len <= kGhostMaxNameLen) {
|
|
std::memcpy(me->name, jp.name, jp.name_len);
|
|
me->name_len = jp.name_len;
|
|
}
|
|
// Empty room: assign one (uppercase alnum, 4 chars, seeded by the
|
|
// session id and the wall of the receiving thread).
|
|
if (me->room_len == 0) {
|
|
uint32_t state = (uint32_t(now * 1e3) * 2654435761u) ^
|
|
(me->session_id * 40503u) ^ 0x9E3779B9u;
|
|
char room[kGhostMaxRoomLen] = {};
|
|
for (uint8_t i = 0; i < 4; ++i) {
|
|
state = state * 1664525u + 1013904223u;
|
|
room[i] = (state & 1u) != 0
|
|
? static_cast<char>('A' + (state >> 1) % 26u)
|
|
: static_cast<char>('0' + (state >> 2) % 10u);
|
|
}
|
|
std::memcpy(me->room, room, 4);
|
|
me->room_len = 4;
|
|
}
|
|
me->last_seen = now;
|
|
if (p.h.type == kTypeJoin) {
|
|
// 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_) {
|
|
if (&c != me && c.room_len == me->room_len &&
|
|
RoomEquals(c.room, c.room_len, me->room, me->room_len) &&
|
|
hp.roster_count < kGhostMaxRoster) {
|
|
RosterEntry& entry = hp.roster[hp.roster_count++];
|
|
entry.session_id = c.session_id;
|
|
entry.name_len = c.name_len;
|
|
std::memcpy(entry.name, c.name, c.name_len);
|
|
}
|
|
}
|
|
BuildPacket(out, kTypeHello, &hp, static_cast<uint16_t>(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_) {
|
|
if (&c != me && c.room_len == me->room_len &&
|
|
RoomEquals(c.room, c.room_len, me->room, me->room_len) &&
|
|
hp.roster_count < kGhostMaxRoster) {
|
|
RosterEntry& entry = hp.roster[hp.roster_count++];
|
|
entry.session_id = c.session_id;
|
|
entry.name_len = c.name_len;
|
|
std::memcpy(entry.name, c.name, c.name_len);
|
|
}
|
|
}
|
|
BuildPacket(out, kTypeHello, &hp, static_cast<uint16_t>(sizeof(hp)));
|
|
send(out);
|
|
}
|
|
break;
|
|
}
|
|
|
|
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) {
|
|
RoomSet(me->room, me->room_len, kGhostLobbyRoom);
|
|
}
|
|
me->last_seen = now;
|
|
// Forward the payload VERBATIM to the other room members, re-stamping
|
|
// only the sender session id at payload offset 0 (little-endian). The
|
|
// relay never parses the pose body (a full skeleton pose is a
|
|
// multi-chunk kTypePoseChunk stream; each chunk is forwarded the same
|
|
// way), so the protocol can grow without a relay update.
|
|
PacketBuf out = p;
|
|
out.h.magic = kGhostMagic;
|
|
out.h.type = p.h.type;
|
|
std::memcpy(out.payload, &me->session_id, sizeof(me->session_id));
|
|
const uint16_t size = PacketSize(out);
|
|
for (const Client& c : clients_) {
|
|
if (&c == me) {
|
|
continue;
|
|
}
|
|
if (c.room_len != me->room_len ||
|
|
!RoomEquals(c.room, c.room_len, me->room, me->room_len)) {
|
|
continue;
|
|
}
|
|
GhostSockSendTo(sock_, c.addr, &out, size);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case kTypeLeave: {
|
|
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;
|
|
}
|
|
|
|
case kTypeList: {
|
|
BuildRoomList(from);
|
|
break;
|
|
}
|
|
|
|
default:
|
|
break; // PONG etc. are client-side only; ignore at the relay.
|
|
}
|
|
}
|
|
|
|
void GhostRelay::BuildRoomList(const GhostAddr& to) {
|
|
// Aggregate the client table by room (the mu_ lock is already held by the
|
|
// caller), most-populated first, capped at kGhostMaxRooms with up to
|
|
// kGhostMaxRoomMembers names per room.
|
|
struct RoomAgg {
|
|
uint32_t count = 0;
|
|
uint32_t shown = 0;
|
|
uint32_t names[kGhostMaxRoomMembers] = {}; // indices into clients_
|
|
};
|
|
std::unordered_map<std::string, RoomAgg> agg;
|
|
for (size_t i = 0; i < clients_.size(); ++i) {
|
|
const Client& c = clients_[i];
|
|
if (c.room_len == 0 || c.refused) {
|
|
continue;
|
|
}
|
|
const std::string key(c.room, c.room_len);
|
|
RoomAgg& a = agg[key];
|
|
++a.count;
|
|
if (a.shown < kGhostMaxRoomMembers && c.name_len != 0) {
|
|
a.names[a.shown++] = uint32_t(i);
|
|
}
|
|
}
|
|
|
|
RoomsPayload rp = {};
|
|
rp.version = kGhostVersion;
|
|
uint8_t listed = 0;
|
|
for (const auto& kv : agg) {
|
|
if (listed >= kGhostMaxRooms) {
|
|
break;
|
|
}
|
|
const std::string& key = kv.first;
|
|
const RoomAgg& a = kv.second;
|
|
RoomEntry& e = rp.rooms[listed];
|
|
e.room_len = uint8_t(std::min<size_t>(key.size(), kGhostMaxRoomLen));
|
|
std::memcpy(e.room, key.data(), e.room_len);
|
|
e.member_count = uint8_t(std::min<uint32_t>(a.count, 255));
|
|
e.shown_members = uint8_t(a.shown);
|
|
for (uint32_t n = 0; n < a.shown; ++n) {
|
|
const Client& c = clients_[a.names[n]];
|
|
e.member_name_len[n] = c.name_len;
|
|
std::memcpy(e.member_names[n], c.name, c.name_len);
|
|
}
|
|
++listed;
|
|
}
|
|
rp.room_count = listed;
|
|
|
|
PacketBuf out;
|
|
BuildPacket(out, kTypeRooms, &rp, static_cast<uint16_t>(sizeof(rp)));
|
|
GhostSockSendTo(sock_, to, &out, PacketSize(out));
|
|
}
|
|
|
|
void GhostRelay::Sweep(double now) {
|
|
std::lock_guard<std::mutex> lock(mu_);
|
|
if (now <= 0.0) {
|
|
return;
|
|
}
|
|
clients_.erase(std::remove_if(clients_.begin(), clients_.end(),
|
|
[now](const Client& c) {
|
|
return now - c.last_seen > kClientTimeoutSec;
|
|
}),
|
|
clients_.end());
|
|
}
|
|
|
|
} // namespace skate3::native_ghost
|