Compare commits

...

5 commits

Author SHA1 Message Date
MiTHRAL
dbe8afaa57 netcode-revival: ghost client feeds the game's netcode transport (Phase 1c)
- SendJoin carries the machine's virtual IP (v5) so the relay can route the
  game's UDP and answer session queries.
- While the public relay is connected, keep rexglue's XNetTransport on the
  same relay + room (idempotent; reconnects on any change), so the game's
  RakNet netcode follows the room the player joined.
2026-08-28 02:25:31 -04:00
MiTHRAL
7e86b8563a netcode-revival: pin rexglue to transport fixes (packing, heartbeat, logging) 2026-08-28 02:14:19 -04:00
MiTHRAL
3664138037 netcode-revival: pin rexglue to bf3635d (Phase 1b relay transport)
The game's RakNet UDP now routes through the relay via the rexglue
XNetTransport + XSocket integration; QosLookup discovers room members.
2026-08-28 02:11:28 -04:00
MiTHRAL
0ab155341c netcode-revival: relay game-transport + session discovery (Phase 1a)
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).
2026-08-28 02:04:34 -04:00
MiTHRAL
bd855c5d88 netcode-revival: document the revival plan + Phase 0 progress 2026-08-28 01:58:21 -04:00
6 changed files with 204 additions and 3 deletions

64
docs/netcode-revival.md Normal file
View file

@ -0,0 +1,64 @@
# Netcode revival (branch `netcode-revival`)
Goal: revive Skate 3's **original** Xbox 360 multiplayer — the game's own
RakNet-based peer-to-peer netcode — instead of only the ghost co-presence
layer. `main` keeps the working ghost multiplayer + releases; this branch
carries the netcode work and stays clearly separate.
The transport is **RakNet over XNet/XSocket** (confirmed: rexglue's XNet
comments reference RakNet, and the game's `socket → setsockopt → bind →
sendto` sequence matches `RakPeer::Startup`). The game speaks its own
protocol; we only need the **platform netcode layer to work** (implement
rexglue's stubbed XNet session APIs + route the game's UDP through the
relay). We do not decode the game protocol.
## Architecture
- **Virtual addressing**: each machine gets a stable `XNADDR` (virtual IP
`10.80.0.x` + unique 6-byte `abEnet` → RakNet GUID). `XNetXnAddrToInAddr` /
`XNetServerToInAddr` resolve those into the socket layer's address space.
- **Relay = P2P stabilizer**: session UDP is forwarded outbound through the
relay (tagged `game` vs `ghost`) to the destination member's real socket;
on a LAN the transport can go direct. The server stabilizes NAT/remote
play but is not required on a LAN.
- **Session registry in the relay**: `XNetQosListen` advertises a session;
`XNetQosLookup` returns discovered sessions; the lobby/room-code UI stays
as the manual join path.
- **Ghost layer is kept** as the guaranteed fallback (co-presence that works
for everyone even if the netcode path fails).
## Phase 0 — XNet session/address layer (done, rexglue `767bd6e`)
Implemented in `rexglue-sdk/src/kernel/xam/xam_net.cpp`:
- `XNetGetTitleXnAddr`: stable virtual identity (was loopback + `0xCC` MAC).
- `XNetXnAddrToInAddr` / `XNetInAddrToXnAddr` / `XNetServerToInAddr` /
`XNetXnAddrToMachineId` / `XNetInAddrToString`: resolve virtual addresses
(were failure / `666.666.666.666`).
- `XNetQosListen`: accept advertisement (was `X_ERROR_FUNCTION_FAILED`).
- `XNetQosLookup`: success + empty `XNQOS` + completion signal (Phase 1
fills it from the relay registry).
- `XNetCreateKey` / `XNetRegisterKey` / `XNetUnregisterKey`: session keys.
- `XNetConnect` / `XNetGetConnectStatus`: report established.
- `getsockopt`: real basic reads off the native socket.
- `XNetSetSystemLinkPort`: accept.
## Phase 1 — relay game-transport + session registry (next)
- Extend `ghost_relay` to forward game UDP between room members + expose the
session list that `XNetQosLookup` reads.
- Give rexglue `XSocket` a relay-transport mode (recognize `10.80.0.x`
virtual destinations; route via the relay outbound).
- Test: two game instances on one machine (loopback through the relay) reach
a joined session.
## Phase 2 — invite UI
- `XamShowGameInviteUI` / `XamShowFriendsUI` as ImGui `XamDialog`s backed by
the relay lobby (real player list). Receive side (`XamNotifyCreateListener`
/ `XamSessionRefObjByHandle`) already works.
## Phase 3 — iterate to playable
Whatever the game needs past discovery (RakNet handshake, ports, privilege
fakes). Ghost stays as fallback throughout.
## Notes
- Testing is done with **two instances on one machine** (no second device);
the relay loops them via `127.0.0.1`. A headless harness may be added.
- Build: `clang-22` override, preset `linux-relwithdebinfo`, per
`docs/build.md` — same as `main`.

View file

@ -27,6 +27,7 @@
#include <rex/cvar.h>
#include <rex/logging.h>
#include <rex/system/xnet_transport.h>
#if defined(_WIN32)
#include <windows.h>
@ -363,6 +364,10 @@ void SendJoin(GhostSock sock, const GhostAddr& to, bool ping) {
std::memcpy(jp.room, g_room, g_room_len);
jp.room_len = g_room_len;
}
// v5: the machine's virtual IP (10.80.0.x) so the relay can route the
// game's own netcode and answer session queries. Mirrors the XNet layer's
// identity (single source of truth in the rexglue transport).
jp.virtual_ip = rex::system::XNetTransport::LocalVirtualIp();
// Display name: JOIN carries it so the relay can echo it in the roster;
// the LAN discovery PING carries it too (a host can tag probes).
CurrentDisplayName(jp.name, &jp.name_len);
@ -781,6 +786,14 @@ void GhostNetworkLoop() {
// keeps the roster/name labels current as members join and leave), and
// reconnect when the HELLO stream goes quiet.
if (g_peer_public.configured) {
// netcode-revival: keep the game's own netcode transport on the same
// relay + room (idempotent; reconnect happens on any change).
char active_room[kGhostMaxRoomLen] = {};
{
std::lock_guard<std::mutex> lock(g_mu);
std::memcpy(active_room, g_room, g_room_len);
}
rex::system::XNetTransport::Instance().SetRelay(server.c_str(), port, active_room);
if (now - g_peer_public.last_join >= kJoinIntervalSec) {
SendJoin(sock, g_peer_public.addr, /*ping=*/false);
g_peer_public.last_join = now;

View file

@ -23,7 +23,7 @@
namespace skate3::native_ghost {
constexpr uint32_t kGhostMagic = 0x54484753u; // "GHST" (LE)
constexpr uint8_t kGhostVersion = 4;
constexpr uint8_t kGhostVersion = 5;
constexpr uint16_t kGhostDefaultPort = 40001;
constexpr uint32_t kGhostMaxRoomLen = 8;
constexpr uint32_t kGhostMaxRoster = 32;
@ -53,6 +53,10 @@ enum PacketType : uint8_t {
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
@ -72,13 +76,17 @@ struct PacketHeader {
// 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.
// 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.
@ -114,6 +122,34 @@ struct alignas(1) RoomsPayload {
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

View file

@ -228,6 +228,11 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p,
std::memcpy(me->name, jp.name, jp.name_len);
me->name_len = jp.name_len;
}
// v5: the client's virtual IP identity (used to route kTypeGame and to
// answer kTypeSessionQuery). PING probes may omit it.
if (jp.virtual_ip != 0) {
me->virtual_ip = jp.virtual_ip;
}
// 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) {
@ -326,6 +331,58 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p,
break;
}
case kTypeGame: {
// netcode-revival: the game's own RakNet UDP, opaque to us. Forward
// to the destination member (dst_virtual_ip, or every other member
// when 0), re-stamping the sender's virtual IP so the recipient can
// reply. Unknown addresses are dropped (a member with a bogus dst is
// gone or never joined).
if (me->refused) {
return;
}
if (p.h.payload_size != sizeof(GamePayload) || me->virtual_ip == 0) {
return;
}
GamePayload gp = {};
std::memcpy(&gp, p.payload, sizeof(gp));
if (gp.data_len > kGhostGameData) {
return;
}
me->last_seen = now;
PacketBuf out = p;
out.h.magic = kGhostMagic;
out.h.type = kTypeGame;
GamePayload* fwd = reinterpret_cast<GamePayload*>(out.payload);
fwd->src_virtual_ip = me->virtual_ip;
const uint16_t size = PacketSize(out);
for (Client& c : clients_) {
if (&c == me || c.refused) {
continue;
}
if (c.room_len != me->room_len ||
!RoomEquals(c.room, c.room_len, me->room, me->room_len)) {
continue;
}
if (gp.dst_virtual_ip != 0 && c.virtual_ip != gp.dst_virtual_ip) {
continue;
}
GhostSockSendTo(sock_, c.addr, &out, size);
}
break;
}
case kTypeSessionQuery: {
// netcode-revival: a client asks "who's in my room?" so XNetQosLookup
// can discover sessions. Report every OTHER member with a known
// virtual IP (their session id + address + name).
if (me->refused) {
return;
}
me->last_seen = now;
BuildSessionReply(from, *me);
break;
}
case kTypeLeave: {
const auto it = std::remove_if(clients_.begin(), clients_.end(),
[&from](const Client& c) {
@ -399,6 +456,34 @@ void GhostRelay::BuildRoomList(const GhostAddr& to) {
GhostSockSendTo(sock_, to, &out, PacketSize(out));
}
void GhostRelay::BuildSessionReply(const GhostAddr& to, const Client& me) {
// mu_ is held by the caller. Report the OTHER members of the requester's
// room that have a known virtual IP (their relay session id + 10.80.0.x
// address + display name) - exactly the discovery data XNetQosLookup needs
// to hand the game.
SessionReplyPayload sr = {};
for (const Client& c : clients_) {
if (&c == &me || c.refused || c.virtual_ip == 0) {
continue;
}
if (c.room_len != me.room_len || !RoomEquals(c.room, c.room_len, me.room, me.room_len)) {
continue;
}
if (sr.count >= kGhostMaxRoster) {
break;
}
SessionEntry& e = sr.list[sr.count++];
e.session_id = c.session_id;
e.virtual_ip = c.virtual_ip;
e.name_len = c.name_len;
std::memcpy(e.name, c.name, c.name_len);
}
PacketBuf out;
BuildPacket(out, kTypeSessionReply, &sr, static_cast<uint16_t>(sizeof(sr)));
GhostSockSendTo(sock_, to, &out, PacketSize(out));
}
void GhostRelay::Sweep(double now) {
std::lock_guard<std::mutex> lock(mu_);
if (now <= 0.0) {

View file

@ -52,9 +52,11 @@ class GhostRelay {
void SetVerbose(bool verbose) { verbose_ = verbose; }
private:
struct Client;
void Run();
void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now);
void BuildRoomList(const GhostAddr& to);
void BuildSessionReply(const GhostAddr& to, const Client& me);
void Sweep(double now);
GhostSock sock_ = GHOST_INVALID_SOCKET;
@ -68,6 +70,7 @@ class GhostRelay {
struct Client {
GhostAddr addr;
uint32_t session_id = 0;
uint32_t virtual_ip = 0; // 10.80.0.x identity from the JOIN (0 = unknown)
char room[kGhostMaxRoomLen] = {};
uint8_t room_len = 0;
char name[kGhostMaxNameLen] = {};

@ -1 +1 @@
Subproject commit 767bd6e4bc0a88edec7b1ec42b36c045d8c32c65
Subproject commit 01fe706c13038c861e028b52384c3922c98e80da