ghost multiplayer: lobby browser (LIST/ROOMS)

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.
This commit is contained in:
MiTHRAL 2026-08-26 22:19:34 -04:00
parent 7a48eb9f35
commit d7c9cbcb70
6 changed files with 199 additions and 2 deletions

View file

@ -223,6 +223,11 @@ char g_last_error[160] = {};
// latest HELLO roster; refreshed by the periodic re-JOIN). g_mu-guarded. // latest HELLO roster; refreshed by the periodic re-JOIN). g_mu-guarded.
std::unordered_map<uint32_t, std::array<char, kGhostMaxNameLen>> g_roster_names; std::unordered_map<uint32_t, std::array<char, kGhostMaxNameLen>> g_roster_names;
// Lobby browser state: a pending request flag (network thread only) and the
// latest room list (g_mu-guarded; read by the menu).
std::atomic<bool> g_request_rooms{false};
RoomList g_rooms;
// Network thread. // Network thread.
std::thread g_thread; std::thread g_thread;
std::atomic<bool> g_stop{false}; std::atomic<bool> g_stop{false};
@ -394,6 +399,31 @@ void SendPose(GhostSock sock, const GhostAddr& to, uint32_t& seq) {
// ---- 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 == kTypeRooms) {
if (p.h.payload_size < sizeof(RoomsPayload)) {
return;
}
RoomsPayload rp = {};
std::memcpy(&rp, p.payload, sizeof(rp));
// Only trust the room list from the relay we are talking to (a spoofed
// list from anywhere else is ignored).
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);
if (!from_public && !from_lan) {
return;
}
const uint8_t n = std::min<uint8_t>(rp.room_count, kGhostMaxRooms);
std::lock_guard<std::mutex> lock(g_mu);
g_rooms = RoomList{};
g_rooms.version = rp.version;
g_rooms.count = n;
std::memcpy(g_rooms.rooms, rp.rooms, size_t(n) * sizeof(RoomEntry));
g_rooms.received = now;
return;
}
if (p.h.type == kTypePoseChunk) { if (p.h.type == kTypePoseChunk) {
if (p.h.payload_size < sizeof(PoseChunkPayload)) { if (p.h.payload_size < sizeof(PoseChunkPayload)) {
return; return;
@ -619,6 +649,7 @@ void ResetSession() {
g_ghost_index.clear(); g_ghost_index.clear();
g_pending_poses.clear(); g_pending_poses.clear();
g_roster_names.clear(); g_roster_names.clear();
g_rooms = RoomList{};
g_session_public = 0; g_session_public = 0;
g_session_lan = 0; g_session_lan = 0;
g_peer_public = Peer{}; g_peer_public = Peer{};
@ -804,6 +835,24 @@ void GhostNetworkLoop() {
} }
} }
// Lobby browser request: fetch the room list from the best current peer
// (public relay, LAN peer, or our own host when we are the relay).
if (g_request_rooms.exchange(false)) {
GhostAddr target;
if (g_peer_public.configured && g_peer_public.joined) {
target = g_peer_public.addr;
} else if (g_peer_lan.configured && g_peer_lan.joined) {
target = g_peer_lan.addr;
} else if (g_hosting) {
target = GhostAddrLoopback(port);
}
if (target.valid()) {
PacketBuf lp;
BuildPacket(lp, kTypeList, nullptr, 0);
GhostSend(sock, target, lp);
}
}
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));
@ -1041,6 +1090,19 @@ bool GetLocalWorld(float world[16]) {
return true; return true;
} }
void RequestRooms() {
g_request_rooms.store(true, std::memory_order_relaxed);
}
bool GetRooms(RoomList* out) {
std::lock_guard<std::mutex> lock(g_mu);
if (out == nullptr) {
return false;
}
*out = g_rooms;
return g_rooms.received > 0.0;
}
bool GetGhostLabel(uint32_t index, float pos[3], float color[3], char* name, bool GetGhostLabel(uint32_t index, float pos[3], float color[3], char* name,
size_t name_max) { size_t name_max) {
std::lock_guard<std::mutex> lock(g_mu); std::lock_guard<std::mutex> lock(g_mu);

View file

@ -85,6 +85,20 @@ bool GetLocalWorld(float world[16]);
bool GetGhostLabel(uint32_t index, float pos[3], float color[3], char* name, bool GetGhostLabel(uint32_t index, float pos[3], float color[3], char* name,
size_t name_max); size_t name_max);
// ---- lobby browser ------------------------------------------------------------
// Ask the network thread to fetch the relay's room list (non-blocking; the
// reply updates GetRooms asynchronously, a second or two later).
void RequestRooms();
// Latest room list snapshot. count == 0 before the first ROOMS reply.
struct RoomList {
uint8_t version = 0;
uint8_t count = 0;
RoomEntry rooms[kGhostMaxRooms];
double received = 0.0; // steady seconds of the reply (0 = none yet)
};
bool GetRooms(RoomList* out);
// ---- cube geometry (56-byte records matching kSceneInputLayout) ----------------- // ---- cube geometry (56-byte records matching kSceneInputLayout) -----------------
struct GhostCubeVertex { struct GhostCubeVertex {
float pos[3]; float pos[3];

View file

@ -23,11 +23,13 @@
namespace skate3::native_ghost { namespace skate3::native_ghost {
constexpr uint32_t kGhostMagic = 0x54484753u; // "GHST" (LE) constexpr uint32_t kGhostMagic = 0x54484753u; // "GHST" (LE)
constexpr uint8_t kGhostVersion = 3; constexpr uint8_t kGhostVersion = 4;
constexpr uint16_t kGhostDefaultPort = 40001; constexpr uint16_t kGhostDefaultPort = 40001;
constexpr uint32_t kGhostMaxRoomLen = 8; constexpr uint32_t kGhostMaxRoomLen = 8;
constexpr uint32_t kGhostMaxRoster = 32; constexpr uint32_t kGhostMaxRoster = 32;
constexpr uint32_t kGhostMaxNameLen = 16; // display name, UTF-8-safe ASCII constexpr uint32_t kGhostMaxNameLen = 16; // display name, UTF-8-safe ASCII
constexpr uint32_t kGhostMaxRooms = 16; // rooms listed per LOBBY reply
constexpr uint32_t kGhostMaxRoomMembers = 4; // member names shown per room
// Max skeleton rows carried in a POSE (3 float4s per bone). The Skate 3 // Max skeleton rows carried in a POSE (3 float4s per bone). The Skate 3
// skater skeleton fits comfortably under this; rows beyond the sender's // skater skeleton fits comfortably under this; rows beyond the sender's
// captured count are identity-padded so every mesh's referenced bones stay // captured count are identity-padded so every mesh's referenced bones stay
@ -47,6 +49,8 @@ enum PacketType : uint8_t {
kTypePong = 5, // relay -> client: empty kTypePong = 5, // relay -> client: empty
kTypeLeave = 6, // client -> relay: empty kTypeLeave = 6, // client -> relay: empty
kTypePoseChunk = 7, // client -> relay -> other members: { PoseChunkPayload } kTypePoseChunk = 7, // client -> relay -> other members: { PoseChunkPayload }
kTypeList = 8, // client -> relay: request the room list (empty payload)
kTypeRooms = 9, // relay -> client: { RoomsPayload } (the lobby browser)
}; };
struct alignas(1) PacketHeader { struct alignas(1) PacketHeader {
@ -83,6 +87,23 @@ struct alignas(1) HelloPayload {
RosterEntry roster[kGhostMaxRoster]; RosterEntry roster[kGhostMaxRoster];
}; };
// One room in the lobby list: the code, the total member count, and up to
// kGhostMaxRoomMembers member display names (for the "who's in there" glance).
struct alignas(1) RoomEntry {
uint8_t room_len;
char room[kGhostMaxRoomLen];
uint8_t member_count; // total players in this room
uint8_t shown_members; // how many names follow (<= kGhostMaxRoomMembers)
uint8_t member_name_len[kGhostMaxRoomMembers];
char member_names[kGhostMaxRoomMembers][kGhostMaxNameLen];
};
struct alignas(1) RoomsPayload {
uint8_t version; // the relay's protocol version (see the handshake)
uint8_t room_count;
RoomEntry rooms[kGhostMaxRooms];
};
// Full-pose blob (the character-model path): the sender's skater skeleton, // Full-pose blob (the character-model path): the sender's skater skeleton,
// 3 float4 rows per bone (column-vector affine [R | t], model -> world, row // 3 float4 rows per bone (column-vector affine [R | t], model -> world, row
// k = bone k exactly as the scene renderer's bone ring expects). Flags bit 0 // k = bone k exactly as the scene renderer's bone ring expects). Flags bit 0

View file

@ -5,6 +5,7 @@
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
#include <string> #include <string>
#include <unordered_map>
#include <vector> #include <vector>
// The relay core is compiled into BOTH the game (rex logging available) and // The relay core is compiled into BOTH the game (rex logging available) and
@ -154,7 +155,8 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p,
// Resolve the sender's client record. Client records are only created by // Resolve the sender's client record. Client records are only created by
// JOIN/PING (a client must announce itself before posing); any other // JOIN/PING (a client must announce itself before posing); any other
// packet from an unknown address is dropped, so spoofed POSE/LEAVE floods // packet from an unknown address is dropped, so spoofed POSE/LEAVE floods
// cannot fill the client table. // cannot fill the client table. LIST is the exception: a lobby browser can
// ask for the room list before joining anything.
Client* me = nullptr; Client* me = nullptr;
for (Client& c : clients_) { for (Client& c : clients_) {
if (GhostAddrEqual(c.addr, from)) { if (GhostAddrEqual(c.addr, from)) {
@ -163,6 +165,10 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p,
} }
} }
if (me == nullptr) { if (me == nullptr) {
if (p.h.type == kTypeList) {
BuildRoomList(from);
return;
}
if (p.h.type != kTypeJoin && p.h.type != kTypePing) { if (p.h.type != kTypeJoin && p.h.type != kTypePing) {
return; return;
} }
@ -332,11 +338,67 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p,
break; break;
} }
case kTypeList: {
BuildRoomList(from);
break;
}
default: default:
break; // PONG etc. are client-side only; ignore at the relay. 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) { void GhostRelay::Sweep(double now) {
std::lock_guard<std::mutex> lock(mu_); std::lock_guard<std::mutex> lock(mu_);
if (now <= 0.0) { if (now <= 0.0) {

View file

@ -54,6 +54,7 @@ class GhostRelay {
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);
void BuildRoomList(const GhostAddr& to);
void Sweep(double now); void Sweep(double now);
GhostSock sock_ = GHOST_INVALID_SOCKET; GhostSock sock_ = GHOST_INVALID_SOCKET;

View file

@ -110,6 +110,43 @@ void GhostDialog::OnDraw(ImGuiIO& io) {
ImGui::Separator(); ImGui::Separator();
if (ImGui::CollapsingHeader("Browse rooms (lobby)", ImGuiTreeNodeFlags_DefaultOpen)) {
if (ImGui::Button("Refresh rooms")) {
skate3::native_ghost::RequestRooms();
}
ImGui::SameLine();
skate3::native_ghost::RoomList rl;
const bool have = skate3::native_ghost::GetRooms(&rl);
ImGui::TextDisabled(have ? "%u room(s) on the relay" : "no room list yet");
if (have) {
for (uint8_t i = 0; i < rl.count; ++i) {
const skate3::native_ghost::RoomEntry& e = rl.rooms[i];
std::string label(e.room, e.room_len);
label += " (" + std::to_string(int(e.member_count)) + " player" +
(e.member_count == 1 ? "" : "s") + ")";
std::string names;
for (uint8_t n = 0; n < e.shown_members; ++n) {
if (n != 0) {
names += ", ";
}
names += std::string(e.member_names[n], e.member_name_len[n]);
}
if (!names.empty()) {
label += " [" + names + "]";
}
const std::string code(e.room, e.room_len);
if (ImGui::Selectable(label.c_str(), false)) {
REXCVAR_SET(skate3_ghost_room, code);
std::snprintf(room_input_, sizeof(room_input_), "%s", code.c_str());
}
}
ImGui::TextDisabled("Click a room to join it (applies on the next "
"heartbeat).");
}
}
ImGui::Separator();
ImGui::Text("Relay server (optional)"); ImGui::Text("Relay server (optional)");
ImGui::PushItemWidth(-1.0f); ImGui::PushItemWidth(-1.0f);
if (ImGui::InputText("##ghost_server", server_input_, sizeof(server_input_), if (ImGui::InputText("##ghost_server", server_input_, sizeof(server_input_),