From d7c9cbcb7058c73c0b41f1f58cd0589369790282 Mon Sep 17 00:00:00 2001 From: MiTHRAL Date: Wed, 26 Aug 2026 22:19:34 -0400 Subject: [PATCH] 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. --- src/native/skate3_native_ghost.cpp | 62 +++++++++++++++++++++++ src/native/skate3_native_ghost.h | 14 ++++++ src/native/skate3_native_ghost_proto.h | 23 ++++++++- src/native/skate3_native_ghost_relay.cpp | 64 +++++++++++++++++++++++- src/native/skate3_native_ghost_relay.h | 1 + src/skate3_native_ghost_dialog.cpp | 37 ++++++++++++++ 6 files changed, 199 insertions(+), 2 deletions(-) diff --git a/src/native/skate3_native_ghost.cpp b/src/native/skate3_native_ghost.cpp index 5f9a852..29ffec0 100644 --- a/src/native/skate3_native_ghost.cpp +++ b/src/native/skate3_native_ghost.cpp @@ -223,6 +223,11 @@ char g_last_error[160] = {}; // latest HELLO roster; refreshed by the periodic re-JOIN). g_mu-guarded. std::unordered_map> 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 g_request_rooms{false}; +RoomList g_rooms; + // Network thread. std::thread g_thread; std::atomic g_stop{false}; @@ -394,6 +399,31 @@ void SendPose(GhostSock sock, const GhostAddr& to, uint32_t& seq) { // ---- packet receive -------------------------------------------------------- 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(rp.room_count, kGhostMaxRooms); + std::lock_guard 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.payload_size < sizeof(PoseChunkPayload)) { return; @@ -619,6 +649,7 @@ void ResetSession() { g_ghost_index.clear(); g_pending_poses.clear(); g_roster_names.clear(); + g_rooms = RoomList{}; g_session_public = 0; g_session_lan = 0; 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); PruneGhosts(now); std::this_thread::sleep_for(std::chrono::milliseconds(25)); @@ -1041,6 +1090,19 @@ bool GetLocalWorld(float world[16]) { return true; } +void RequestRooms() { + g_request_rooms.store(true, std::memory_order_relaxed); +} + +bool GetRooms(RoomList* out) { + std::lock_guard 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, size_t name_max) { std::lock_guard lock(g_mu); diff --git a/src/native/skate3_native_ghost.h b/src/native/skate3_native_ghost.h index 7d05c88..1f34ca1 100644 --- a/src/native/skate3_native_ghost.h +++ b/src/native/skate3_native_ghost.h @@ -85,6 +85,20 @@ bool GetLocalWorld(float world[16]); bool GetGhostLabel(uint32_t index, float pos[3], float color[3], char* name, 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) ----------------- struct GhostCubeVertex { float pos[3]; diff --git a/src/native/skate3_native_ghost_proto.h b/src/native/skate3_native_ghost_proto.h index 9bb9273..c63bad0 100644 --- a/src/native/skate3_native_ghost_proto.h +++ b/src/native/skate3_native_ghost_proto.h @@ -23,11 +23,13 @@ namespace skate3::native_ghost { constexpr uint32_t kGhostMagic = 0x54484753u; // "GHST" (LE) -constexpr uint8_t kGhostVersion = 3; +constexpr uint8_t kGhostVersion = 4; constexpr uint16_t kGhostDefaultPort = 40001; constexpr uint32_t kGhostMaxRoomLen = 8; constexpr uint32_t kGhostMaxRoster = 32; 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 // skater skeleton fits comfortably under this; rows beyond the sender's // 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 kTypeLeave = 6, // client -> relay: empty 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 { @@ -83,6 +87,23 @@ struct alignas(1) HelloPayload { 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, // 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 diff --git a/src/native/skate3_native_ghost_relay.cpp b/src/native/skate3_native_ghost_relay.cpp index 67b161c..a0356d5 100644 --- a/src/native/skate3_native_ghost_relay.cpp +++ b/src/native/skate3_native_ghost_relay.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include // 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 // 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. + // 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)) { @@ -163,6 +165,10 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p, } } if (me == nullptr) { + if (p.h.type == kTypeList) { + BuildRoomList(from); + return; + } if (p.h.type != kTypeJoin && p.h.type != kTypePing) { return; } @@ -332,11 +338,67 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p, 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 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(key.size(), kGhostMaxRoomLen)); + std::memcpy(e.room, key.data(), e.room_len); + e.member_count = uint8_t(std::min(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(sizeof(rp))); + GhostSockSendTo(sock_, to, &out, PacketSize(out)); +} + void GhostRelay::Sweep(double now) { std::lock_guard lock(mu_); if (now <= 0.0) { diff --git a/src/native/skate3_native_ghost_relay.h b/src/native/skate3_native_ghost_relay.h index 0cd0b74..ca0aef6 100644 --- a/src/native/skate3_native_ghost_relay.h +++ b/src/native/skate3_native_ghost_relay.h @@ -54,6 +54,7 @@ class GhostRelay { private: void Run(); void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now); + void BuildRoomList(const GhostAddr& to); void Sweep(double now); GhostSock sock_ = GHOST_INVALID_SOCKET; diff --git a/src/skate3_native_ghost_dialog.cpp b/src/skate3_native_ghost_dialog.cpp index 8faf474..e1bfc22 100644 --- a/src/skate3_native_ghost_dialog.cpp +++ b/src/skate3_native_ghost_dialog.cpp @@ -110,6 +110,43 @@ void GhostDialog::OnDraw(ImGuiIO& io) { 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::PushItemWidth(-1.0f); if (ImGui::InputText("##ghost_server", server_input_, sizeof(server_input_),