The game's own RakNet UDP now has a path through the relay (v5 protocol): - kTypeGame: opaque game datagrams forwarded to the destination member (dst_virtual_ip, or broadcast to all room members when 0); the relay re-stamps the sender's virtual IP so the recipient can reply. This gives the netcode a P2P route with the server as a pure relay/stabilizer. - kTypeSessionQuery / kTypeSessionReply: a client asks 'who's in my room?' and gets the other members' session ids + virtual IPs + names - exactly what XNetQosLookup must hand the game's session search. - JoinPayload now carries the client's virtual IP (10.80.0.x identity) so the relay can route and answer discovery. Tested end-to-end (unicast + broadcast + room isolation + discovery).
341 lines
No EOL
13 KiB
C++
341 lines
No EOL
13 KiB
C++
#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 <algorithm>
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <cstring>
|
|
|
|
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<uint16_t>(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<uint16_t>(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<const uint8_t*>(&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<uint16_t>(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
|