#pragma once // Ghost multiplayer wire protocol (shared by the in-game client, the // in-game host mode and the standalone ghost_relay binary). // // Topology: every client connects OUTBOUND (UDP) to a relay; the relay // forwards each client's pose packet to the other members of the same room. // No client needs inbound reachability (no port forwarding). The relay is // either a public VPS server (ghost_relay) or a player's game in host mode // (the GhostRelay class below), reachable directly on a LAN or via a free // tunnel on the internet. // // All multi-byte fields are little-endian (all supported hosts are // little-endian; a struct with explicit layout is serialized with memcpy). // A packet is PacketHeader followed by a fixed per-type payload. PacketBuf // is sized for the largest payload. #include #include #include #include namespace skate3::native_ghost { constexpr uint32_t kGhostMagic = 0x54484753u; // "GHST" (LE) constexpr uint8_t kGhostVersion = 5; 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 // in range on the receiving side. constexpr uint32_t kGhostMaxBones = 64; // Default public relay (the production beta relay on the OVH VPS). Clients // connect OUTBOUND to this; no port forwarding on any client. Override at // runtime with skate3_ghost_server. constexpr const char* kGhostDefaultServer = "relay.mithraic.lol"; constexpr uint16_t kGhostDefaultServerPort = 40001; // A LAN host with no room advertised is implicitly in this room. constexpr const char* kGhostLobbyRoom = "LOBBY"; enum PacketType : uint8_t { kTypeJoin = 1, // client -> relay: { JoinPayload } (empty room = server assigns) kTypeHello = 2, // relay -> client: { HelloPayload } (session id, room, roster) kTypePose = 3, // legacy single-datagram pose (forwarded, unused by clients) kTypePing = 4, // client -> relay / LAN broadcast probe: { JoinPayload } 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) // netcode-revival (v5): the game's own RakNet UDP rides the same relay. kTypeGame = 10, // client -> relay -> member: { GamePayload } (opaque) kTypeSessionQuery = 11, // client -> relay: request sessions in my room kTypeSessionReply = 12, // relay -> client: { SessionReplyPayload } }; // True byte-packing: the struct layouts below must match the wire format // exactly (the receiver bounds-checks every packet against these sizes, and // the relay rejects undersized datagrams with `n < sizeof(PacketHeader)`). // alignas(1) alone is NOT enough - it sets the struct alignment but the // compiler still pads MEMBERS to their natural alignment (a uint16_t lands // on an even offset, inflating PacketHeader to 8 bytes and dropping every // real 7-byte packet). #pragma pack(push, 1) struct PacketHeader { uint32_t magic; uint8_t type; uint16_t payload_size; // bytes following this header }; // JOIN and PING share the room payload (PING may carry the probing client's // room so LAN hosts can answer only matching rooms); JOIN additionally // carries the client's display name, which the relay echoes to room members // in the HELLO roster so nameplates can label every ghost. v5 adds the // client's virtual IP (the 10.80.0.x identity XNet reports for this machine) // so the relay can route the game's own UDP (kTypeGame) and answer session // queries (kTypeSessionReply) with per-member addresses. struct alignas(1) JoinPayload { uint8_t version; // protocol version (kGhostVersion); mismatches are refused uint8_t room_len; char room[kGhostMaxRoomLen]; uint8_t name_len; char name[kGhostMaxNameLen]; uint32_t virtual_ip; // host-order virtual IP (10.80.0.x); 0 = unknown }; // One room member as reported in a HELLO roster: session id + display name. struct alignas(1) RosterEntry { uint32_t session_id; uint8_t name_len; char name[kGhostMaxNameLen]; }; 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 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]; }; // ---- netcode-revival: the game's own UDP through the relay ------------------ // Opaque game datagrams (the game's RakNet netcode) are wrapped by rexglue's // XSocket relay-transport and forwarded by the relay to the destination // member. dst_virtual_ip selects the recipient (0 = broadcast to every other // member of the room); the relay re-stamps src_virtual_ip with the sender's // identity before forwarding so the recipient can reply. data[] is capped so // a full ~1400-byte UDP payload fits an MTU-safe datagram. constexpr uint16_t kGhostGameData = 1400; struct alignas(1) GamePayload { uint32_t dst_virtual_ip; // recipient (10.80.0.x) or 0 = all room members uint32_t src_virtual_ip; // filled by the relay on forward uint16_t data_len; // bytes valid in data[] uint8_t data[kGhostGameData]; }; // One discovered session (a member of the room) as reported to XNetQosLookup. struct alignas(1) SessionEntry { uint32_t session_id; // the member's relay session id uint32_t virtual_ip; // the member's 10.80.0.x address (host order) uint8_t name_len; char name[kGhostMaxNameLen]; }; struct alignas(1) SessionReplyPayload { uint8_t count; // entries in list[] (<= kGhostMaxRoster) SessionEntry list[kGhostMaxRoster]; }; // 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 // = bones[] valid. pos/rot are kept from the entity world for the cube // fallback / diagnostics. This struct is the REASSEMBLED content of a // chunked PoseChunk stream; it is no longer sent as a single datagram. struct alignas(1) PosePayload { uint32_t session_id; // set by the relay on forward (the sender's id) uint32_t seq; // sender monotonic sequence double timestamp; // sender monotonic seconds (informational) float pos[3]; // world translation (entity world, row 3) float rot[9]; // world rotation, row-vector 3x3 (matches entity+416) uint16_t bone_count; // rows in bones[] (0 = pose unavailable) uint8_t flags; // bit0 = bones[] valid uint8_t reserved; float bones[kGhostMaxBones * 12]; }; constexpr uint8_t kPoseFlagsBones = 1; // ---- chunked pose transport ------------------------------------------------- // A full PosePayload is ~3.1 KB, far above the 1500-byte path MTU, so it is // split into kGhostMaxChunks MTU-safe datagrams (<= ~1.2 KB each). The relay // forwards each chunk verbatim (it never parses the body), and the receiver // reassembles on the sequence. A lost chunk drops that pose (the next frame // resends); out-of-order chunks are tolerated by the per-chunk offset copy. constexpr uint16_t kGhostPoseHeaderSize = uint16_t(offsetof(PosePayload, bones)); // 68 constexpr uint16_t kGhostChunkData = 1200; constexpr uint8_t kGhostMaxChunks = 3; constexpr uint16_t kGhostPoseBlobSize = uint16_t(sizeof(PosePayload)); // 3140 struct alignas(1) PoseChunkPayload { uint32_t session_id; // offset 0: relay re-stamps the sender id uint32_t seq; // pose sequence this chunk belongs to uint16_t blob_size; // total blob bytes this seq comprises uint16_t data_size; // bytes actually valid in data[] uint8_t chunk; // 0-based chunk index uint8_t chunks; // total chunks for this seq uint8_t data[kGhostChunkData]; }; // Receiver-side reassembly for ONE session (one instance per remote peer, // network thread only). Push() returns true exactly when a complete pose // becomes available and fills *out; a fresh seq discards any partial state. struct GhostPoseReassembler { void Reset() { seq_ = 0; blob_size_ = 0; chunks_ = 0; received_ = 0; } bool Push(uint32_t seq, uint16_t blob_size, uint8_t chunk, uint8_t chunks, const uint8_t* data, uint16_t data_size, PosePayload* out) { if (chunk >= chunks || chunks == 0 || chunks > kGhostMaxChunks || blob_size == 0 || blob_size > kGhostPoseBlobSize) { return false; } if (seq != seq_) { Reset(); seq_ = seq; blob_size_ = blob_size; chunks_ = chunks; } else if (blob_size != blob_size_ || chunks != chunks_) { return false; // inconsistent mid-sequence; drop } const uint32_t bit = 1u << chunk; if ((received_ & bit) != 0) { return false; // duplicate chunk } received_ |= bit; const uint16_t off = uint16_t(chunk) * kGhostChunkData; const uint16_t avail = blob_size_ > off ? blob_size_ - off : 0; const uint16_t n = std::min(data_size, avail); std::memcpy(blob_ + off, data, n); const uint32_t want = (1u << chunks_) - 1u; if ((received_ & want) != want) { return false; } std::memset(out, 0, sizeof(*out)); std::memcpy(out, blob_, std::min(blob_size_, uint16_t(sizeof(*out)))); Reset(); return true; } private: uint32_t seq_ = 0; uint16_t blob_size_ = 0; uint8_t chunks_ = 0; uint32_t received_ = 0; uint8_t blob_[kGhostPoseBlobSize] = {}; }; // Largest payload: PoseChunkPayload (1214 bytes). constexpr uint16_t kGhostMaxPayload = 3200; struct PacketBuf { PacketHeader h; uint8_t payload[kGhostMaxPayload]; }; #pragma pack(pop) inline uint16_t PacketSize(const PacketBuf& p) { return uint16_t(sizeof(PacketHeader) + p.h.payload_size); } inline void BuildPacket(PacketBuf& p, uint8_t type, const void* payload, uint16_t payload_size) { p.h.magic = kGhostMagic; p.h.type = type; p.h.payload_size = payload_size; if (payload_size != 0) { std::memcpy(p.payload, payload, payload_size); } } // Builds the chunk packets for a pose. Always writes exactly the returned // chunk count (1 for a header-only pose, up to kGhostMaxChunks for a full // skeleton). The blob is the PosePayload serialized from its first byte. inline uint8_t GhostPoseBuildChunks(uint32_t session_id, uint32_t seq, const PosePayload& pose, PacketBuf out[kGhostMaxChunks]) { const uint16_t blob_size = (pose.flags & kPoseFlagsBones) != 0 ? uint16_t(sizeof(PosePayload)) : kGhostPoseHeaderSize; const uint8_t chunks = uint8_t((blob_size + kGhostChunkData - 1) / kGhostChunkData); const uint8_t* blob = reinterpret_cast(&pose); for (uint8_t c = 0; c < chunks; ++c) { PoseChunkPayload cp = {}; cp.session_id = session_id; cp.seq = seq; cp.blob_size = blob_size; cp.chunk = c; cp.chunks = chunks; const uint16_t off = uint16_t(c) * kGhostChunkData; const uint16_t n = std::min(kGhostChunkData, blob_size - off); cp.data_size = n; std::memcpy(cp.data, blob + off, n); BuildPacket(out[c], kTypePoseChunk, &cp, sizeof(cp)); } return chunks; } // ---- room code helpers (uppercase alnum, max kGhostMaxRoomLen) ------------- inline bool RoomValid(const char* room, uint8_t len) { if (len == 0 || len > kGhostMaxRoomLen) { return false; } for (uint8_t i = 0; i < len; ++i) { const char c = room[i]; if (!((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'))) { return false; } } return true; } inline void RoomClear(char* room, uint8_t& len) { len = 0; std::memset(room, 0, kGhostMaxRoomLen); } inline void RoomSet(char* room, uint8_t& len, const char* s) { len = 0; std::memset(room, 0, kGhostMaxRoomLen); if (s == nullptr) { return; } while (*s != '\0' && len < kGhostMaxRoomLen) { char c = *s; if (c >= 'a' && c <= 'z') { c = char(c - 'a' + 'A'); } if ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) { room[len++] = c; } ++s; } } inline bool RoomEquals(const char* a, uint8_t a_len, const char* b, uint8_t b_len) { return a_len == b_len && (a_len == 0 || std::memcmp(a, b, a_len) == 0); } } // namespace skate3::native_ghost