Add ghost multiplayer: UDP relay + pose broadcasting + placeholder cubes

Bolt-on multiplayer layer confined to src/native and hand-written code (no
codegen output touched):

- GhostRelay core (src/native/skate3_native_ghost_relay.*): small UDP relay
  matching clients into rooms by a shareable code; forwards each client's
  pose to the other room members. Used by both the standalone binary and
  the in-game LAN host fallback.
- Standalone server (tools/ghost_relay_main.cpp, ghost_relay target): runs
  on a public VPS or behind a free tunnel; clients connect OUTBOUND, so no
  port forwarding is needed on any client.
- In-game module (skate3_native_ghost.*): captures the local player's world
  transform at Sk8::UpdateBoneTransforms exit (pack-owner entity +416,
  verified layout) and broadcasts it at a fixed rate; auto-connects to the
  default public relay, falls back to LAN auto-host + UDP broadcast
  discovery when unreachable.
- Renderer: draws a solid-color cube per remote player in RenderScene via
  the existing scene PSO tint.a>0 branch (no new shader/SPIR-V).
- UI: GhostDialog (M key) with enable toggle, room code, relay override,
  status readout. Off by default.

Wire protocol in skate3_native_ghost_proto.h (JOIN/HELLO/POSE/PING/LEAVE).
This commit is contained in:
MiTHRAL 2026-08-26 20:12:45 -04:00
parent f6e0ae87fd
commit 269fbcee32
17 changed files with 1859 additions and 1 deletions

4
.gitignore vendored
View file

@ -3,7 +3,9 @@
/build/
/generated/
/generated.backup-*/
/tools/
/tools/*
# ...except the ghost multiplayer relay server (a first-party tool).
!/tools/ghost_relay_main.cpp
# Local game dump and retail media. Users must provide their own copy.
/game/

View file

@ -248,6 +248,9 @@ set(SKATE3_COMMON_SOURCES
src/skate3_iso_installer.cpp
src/native/skate3_native_diagnostics.cpp
src/native/skate3_native_entity.cpp
src/native/skate3_native_ghost.cpp
src/skate3_native_ghost_dialog.cpp
src/native/skate3_native_ghost_relay.cpp
src/native/skate3_native_guest_read.cpp
src/native/skate3_native_lw.cpp
src/native/skate3_native_palette.cpp
@ -392,6 +395,19 @@ if(NOT SKATE3_EFFECTIVE_TITLE_UPDATE_PACKAGE STREQUAL "")
add_dependencies(skate3 skate3_stage_title_update_patches)
endif()
# Ghost multiplayer relay server (standalone): run on a public VPS (or
# behind a free tunnel) so players connect OUTBOUND and need no port
# forwarding. Pure sockets, no rexglue dependency. See tools/ghost_relay_main.cpp.
add_executable(ghost_relay
tools/ghost_relay_main.cpp
src/native/skate3_native_ghost_relay.cpp
)
target_include_directories(ghost_relay PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/native)
target_compile_features(ghost_relay PRIVATE cxx_std_23)
if(WIN32)
target_link_libraries(ghost_relay PRIVATE ws2_32)
endif()
if(MSVC)
target_compile_options(skate3 PRIVATE /utf-8)
else()

View file

@ -0,0 +1,724 @@
#include "skate3_native_ghost.h"
#include <algorithm>
#include <atomic>
#include <bit>
#include <chrono>
#include <cmath>
#include <cstring>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>
#include "skate3_native_ghost_net.h"
#include "skate3_native_ghost_relay.h"
#include "skate3_native_guest_read.h"
#include "skate3_native_palette.h"
#include "generated/skate3_init.h"
#include <rex/cvar.h>
#include <rex/logging.h>
// ---- cvars -----------------------------------------------------------------
// The default public relay lives in ghost_proto.h (kGhostDefaultServer);
// leave skate3_ghost_server empty to use it. Set it to reach a custom relay
// (your own VPS, a friend's, or a free tunnel like playit.gg / ngrok).
REXCVAR_DEFINE_BOOL(skate3_ghost_enable, false, "Skate 3",
"Enable ghost multiplayer: relay this player's position and "
"render remote players as placeholder cubes. Off by default; "
"no packets are sent while off.")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_STRING(skate3_ghost_server, "", "Skate 3",
"Ghost relay 'host[:port]' (empty = the built-in default "
"public relay). Any relay on the internet works; clients "
"only connect outbound, so no port forwarding is needed.")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_INT32(skate3_ghost_port, int32_t(skate3::native_ghost::kGhostDefaultPort),
"Skate 3",
"UDP port for the ghost relay (outbound connect + LAN "
"discovery/host).")
.range(1024, 65535)
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_STRING(skate3_ghost_room, "", "Skate 3",
"Room code (4-8 letters/digits). Empty = the server "
"assigns one and it is shown in the multiplayer menu. "
"Players sharing a code see each other's ghosts.")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_DOUBLE(skate3_ghost_rate, 20.0, "Skate 3",
"Ghost pose broadcast rate in Hz.")
.range(1.0, 60.0)
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL(skate3_ghost_lan, true, "Skate 3",
"LAN auto-discovery + auto-host fallback when the public "
"relay is unreachable (broadcast PING/HELLO on the ghost "
"port).")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL(skate3_ghost_draw_local, false, "Skate 3",
"Draw a cube at this player's captured pose (self-check "
"that the transform capture works without any peer).")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL(skate3_ghost_debug, false, "Skate 3",
"Verbose ghost multiplayer log lines.")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
namespace skate3::native_ghost {
namespace {
using Clock = std::chrono::steady_clock;
double NowSec() {
return std::chrono::duration<double>(Clock::now().time_since_epoch()).count();
}
// ---- guest memory layouts (verified in skate3_native_entity.h) -------------
constexpr uint32_t kEntLtoWTrans = 416; // m_MatLtoWTrans (staged rows)
constexpr uint32_t kEntLtoW = 352; // m_MatLtoW (row-vector Matrix44)
bool MatrixPlausibleRowVector(const float m[16]) {
if (!(m[15] > 0.99f && m[15] < 1.01f)) {
return false;
}
for (int i = 0; i < 3; ++i) {
if (m[3 + i * 4] > 1e-3f || m[3 + i * 4] < -1e-3f) {
return false;
}
}
for (int i = 0; i < 3; ++i) {
const float t = m[12 + i];
if (t > 20000.0f || t < -20000.0f) {
return false;
}
}
for (int r = 0; r < 3; ++r) {
const float* row = m + r * 4;
const float n = row[0] * row[0] + row[1] * row[1] + row[2] * row[2];
if (!(n > 6.25e-6f && n < 160000.0f)) {
return false;
}
}
return true;
}
// entity+416 staged layout: rows r0..r2 (translation in component 3), tail
// row (0,0,0,1). Converted to the row-vector world used by DrawItem (and the
// cube), i.e. translation in row 3.
bool StagedToWorld(const float s[16], float world[16]) {
if (!(s[12] > -1e-3f && s[12] < 1e-3f && s[13] > -1e-3f && s[13] < 1e-3f &&
s[14] > -1e-3f && s[14] < 1e-3f && s[15] > 0.99f && s[15] < 1.01f)) {
return false;
}
for (int r = 0; r < 3; ++r) {
const float* row = s + r * 4;
const float n = row[0] * row[0] + row[1] * row[1] + row[2] * row[2];
if (!(n > 6.25e-6f && n < 160000.0f)) {
return false;
}
}
std::memset(world, 0, sizeof(float) * 16);
world[0] = s[0]; world[1] = s[1]; world[2] = s[2];
world[4] = s[4]; world[5] = s[5]; world[6] = s[6];
world[8] = s[8]; world[9] = s[9]; world[10] = s[10];
for (int i = 0; i < 3; ++i) {
const float t = s[3 + i * 4];
if (t > 20000.0f || t < -20000.0f) {
return false;
}
world[12 + i] = t;
}
world[15] = 1.0f;
return true;
}
bool ReadEntityWorld(uint8_t* base, uint32_t entity, float world[16]) {
uint32_t raw[16] = {};
if (skate3::native_scene::GuestTryCopy(raw, REX_RAW_ADDR(entity + kEntLtoWTrans),
sizeof(raw))) {
float s[16];
for (int i = 0; i < 16; ++i) {
s[i] = std::bit_cast<float>(__builtin_bswap32(raw[i]));
}
if (StagedToWorld(s, world)) {
return true;
}
}
if (skate3::native_scene::GuestTryCopy(raw, REX_RAW_ADDR(entity + kEntLtoW),
sizeof(raw))) {
float m[16];
for (int i = 0; i < 16; ++i) {
m[i] = std::bit_cast<float>(__builtin_bswap32(raw[i]));
}
if (MatrixPlausibleRowVector(m)) {
std::memcpy(world, m, sizeof(float) * 16);
return true;
}
}
return false;
}
// ---- shared state ----------------------------------------------------------
std::mutex g_mu;
// Local player pose (captured on the sim thread; consumed by the network
// thread and the render thread).
bool g_local_valid = false;
float g_local_world[16] = {};
double g_local_time = 0.0;
// Remote ghosts, keyed by the sender's session id.
struct Ghost {
uint32_t session_id = 0;
float world[16] = {};
double last_seen = 0.0;
};
std::vector<Ghost> g_ghosts;
std::unordered_map<uint32_t, size_t> g_ghost_index;
// Status snapshot.
bool g_connected = false;
bool g_hosting = false;
uint32_t g_session = 0;
char g_room[kGhostMaxRoomLen] = {};
uint8_t g_room_len = 0;
size_t g_roster = 0;
char g_peer[96] = {};
// Network thread.
std::thread g_thread;
std::atomic<bool> g_stop{false};
std::atomic<bool> g_started{false};
// Session ids we own (never rendered as ghosts).
uint32_t g_session_public = 0;
uint32_t g_session_lan = 0;
// One relay we are joined to (public relay or LAN host / loopback host).
struct Peer {
GhostAddr addr;
bool configured = false;
bool joined = false;
double last_join = 0.0;
double last_hello = 0.0;
};
Peer g_peer_public;
Peer g_peer_lan;
// LAN discovery / hosting.
std::unique_ptr<GhostRelay> g_relay;
bool g_lan_probing = false;
double g_lan_probe_since = 0.0;
double g_last_ping = 0.0;
// Pose pacing.
double g_pose_acc = 0.0;
uint32_t g_seq = 0;
constexpr double kJoinIntervalSec = 5.0;
constexpr double kHelloTimeoutSec = 8.0;
constexpr double kGhostStaleSec = 8.0;
constexpr double kLanProbeBeforeHostSec = 4.0;
bool Enabled() { return REXCVAR_GET(skate3_ghost_enable); }
bool Debug() { return REXCVAR_GET(skate3_ghost_debug); }
void CopyRoomFromPayload(const char* room, uint8_t len) {
std::lock_guard<std::mutex> lock(g_mu);
g_room_len = std::min<uint8_t>(len, kGhostMaxRoomLen);
std::memcpy(g_room, room, g_room_len);
}
void SetStatusPeer(const GhostAddr& a) {
const std::string s = GhostAddrStr(a);
std::lock_guard<std::mutex> lock(g_mu);
std::snprintf(g_peer, sizeof(g_peer), "%s", s.c_str());
}
// ---- packet send helpers ---------------------------------------------------
void SendJoin(GhostSock sock, const GhostAddr& to, bool ping) {
JoinPayload jp = {};
jp.room_len = 0;
{
std::lock_guard<std::mutex> lock(g_mu);
std::memcpy(jp.room, g_room, g_room_len);
jp.room_len = g_room_len;
}
PacketBuf p;
BuildPacket(p, ping ? kTypePing : kTypeJoin, &jp, sizeof(jp));
GhostSockSendTo(sock, to, &p, PacketSize(p));
}
void SendPose(GhostSock sock, const GhostAddr& to, uint32_t& seq) {
float world[16];
{
std::lock_guard<std::mutex> lock(g_mu);
if (!g_local_valid) {
return;
}
std::memcpy(world, g_local_world, sizeof(world));
}
PosePayload pp = {};
pp.session_id = 0; // filled by the relay
pp.seq = ++seq;
pp.timestamp = NowSec();
std::memcpy(pp.rot, world, 9 * sizeof(float));
std::memcpy(pp.pos, world + 12, 3 * sizeof(float));
PacketBuf p;
BuildPacket(p, kTypePose, &pp, sizeof(pp));
GhostSockSendTo(sock, to, &p, PacketSize(p));
}
// ---- packet receive --------------------------------------------------------
void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now) {
if (p.h.type == kTypePose) {
PosePayload pp = {};
std::memcpy(&pp, p.payload, sizeof(pp));
{
std::lock_guard<std::mutex> lock(g_mu);
// Determine which relay this came from and skip our own pose echoes
// (session ids are per-relay, so a LAN session can collide with a
// public session; compare against the matching own session).
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);
const uint32_t own = from_public ? g_session_public
: from_lan ? g_session_lan
: 0;
if (pp.session_id == 0 || (own != 0 && pp.session_id == own)) {
return;
}
const bool finite =
std::isfinite(pp.pos[0]) && std::isfinite(pp.pos[1]) &&
std::isfinite(pp.pos[2]);
if (!finite) {
return;
}
auto it = g_ghost_index.find(pp.session_id);
if (it != g_ghost_index.end() && it->second < g_ghosts.size()) {
Ghost& gh = g_ghosts[it->second];
std::memcpy(gh.world, pp.rot, 9 * sizeof(float));
std::memcpy(gh.world + 12, pp.pos, 3 * sizeof(float));
gh.world[15] = 1.0f;
gh.last_seen = now;
} else if (g_ghosts.size() < 32) {
Ghost gh;
gh.session_id = pp.session_id;
std::memcpy(gh.world, pp.rot, 9 * sizeof(float));
std::memcpy(gh.world + 12, pp.pos, 3 * sizeof(float));
gh.world[15] = 1.0f;
gh.last_seen = now;
g_ghost_index[pp.session_id] = g_ghosts.size();
g_ghosts.push_back(gh);
}
}
return;
}
if (p.h.type == kTypeHello) {
HelloPayload hp = {};
std::memcpy(&hp, p.payload, sizeof(hp));
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) {
Peer& peer = from_public ? g_peer_public : g_peer_lan;
peer.joined = true;
peer.last_hello = now;
if (from_public) {
g_session_public = hp.session_id;
} else {
g_session_lan = hp.session_id;
}
g_session = hp.session_id;
CopyRoomFromPayload(hp.room, hp.room_len);
{
std::lock_guard<std::mutex> lock(g_mu);
g_roster = hp.roster_count;
g_connected = true;
}
SetStatusPeer(from);
if (Debug()) {
REXLOG_INFO("ghost: joined {} room={} roster={}", GhostAddrStr(from).c_str(),
std::string(hp.room, hp.room_len).c_str(), hp.roster_count);
}
return;
}
// A LAN host reply to our discovery PING (from != our configured peers):
// remember the host if its room matches ours (or we have no room yet).
// Only adopt a new host while we are not already joined to one (multiple
// LAN hosts would otherwise steal our active peer away).
const bool room_match =
RoomEquals(g_room, g_room_len, hp.room, hp.room_len) || g_room_len == 0;
if (room_match && !g_peer_public.joined && !g_hosting && !g_peer_lan.joined) {
g_peer_lan.addr = from;
g_peer_lan.configured = true;
g_peer_lan.last_hello = now;
// A proper JOIN is sent to the discovered host on the next tick.
g_peer_lan.joined = false;
g_peer_lan.last_join = 0.0;
}
return;
}
// PING / JOIN / PONG / LEAVE are relay-side concerns; a client ignores
// them (the in-process GhostRelay answers PINGs on its own socket).
}
void DrainSocket(GhostSock sock, double now) {
for (int i = 0; i < 32; ++i) {
GhostAddr from;
PacketBuf p;
const int n = GhostSockRecvFrom(sock, &from, &p, sizeof(p));
if (n <= 0) {
break;
}
if (n < static_cast<int>(sizeof(PacketHeader)) || p.h.magic != kGhostMagic ||
p.h.payload_size != static_cast<uint16_t>(n - sizeof(PacketHeader)) ||
p.h.payload_size > kGhostMaxPayload) {
continue;
}
HandlePacket(from, p, now);
}
}
void PruneGhosts(double now) {
std::lock_guard<std::mutex> lock(g_mu);
for (size_t i = 0; i < g_ghosts.size();) {
if (now - g_ghosts[i].last_seen > kGhostStaleSec) {
g_ghost_index.erase(g_ghosts[i].session_id);
g_ghosts[i] = g_ghosts.back();
g_ghosts.pop_back();
// Fix the moved entry's index (only if it wasn't the one we removed).
if (i < g_ghosts.size()) {
g_ghost_index[g_ghosts[i].session_id] = i;
}
} else {
++i;
}
}
}
void ResetSession() {
std::lock_guard<std::mutex> lock(g_mu);
g_connected = false;
g_session = 0;
g_roster = 0;
g_ghosts.clear();
g_ghost_index.clear();
g_session_public = 0;
g_session_lan = 0;
g_peer_public = Peer{};
g_peer_lan = Peer{};
if (g_relay) {
g_relay->Stop();
g_relay.reset();
}
g_hosting = false;
g_lan_probing = false;
g_lan_probe_since = 0.0;
g_last_ping = 0.0;
std::snprintf(g_peer, sizeof(g_peer), "%s", "");
}
// Start the in-process LAN relay (host mode).
bool StartHost(uint16_t port) {
if (g_relay) {
g_relay->Stop();
g_relay.reset();
}
auto relay = std::make_unique<GhostRelay>(port);
if (!relay->Start()) {
if (Debug()) {
REXLOG_WARN("ghost: LAN host bind failed on UDP {}", port);
}
return false;
}
g_relay = std::move(relay);
{
std::lock_guard<std::mutex> lock(g_mu);
g_hosting = true;
}
// Feed our own pose through the local relay so LAN members see us.
g_peer_lan.addr = GhostAddrLoopback(port);
g_peer_lan.configured = true;
g_peer_lan.joined = false;
g_peer_lan.last_join = 0.0;
if (Debug()) {
REXLOG_INFO("ghost: LAN host started on UDP {} (room auto-advertised)",
port);
}
return true;
}
void GhostNetworkLoop() {
GhostSock sock = GhostSocketCreate(/*allow_broadcast=*/true);
if (sock != GHOST_INVALID_SOCKET) {
GhostSockSetRecvTimeout(sock, 50);
}
uint16_t last_port = 0;
std::string last_server;
double last_tick = NowSec();
while (!g_stop.load(std::memory_order_relaxed)) {
const double now = NowSec();
const double dt = std::min(now - last_tick, 0.25);
last_tick = now;
if (!Enabled()) {
ResetSession();
std::this_thread::sleep_for(std::chrono::milliseconds(50));
continue;
}
const bool debug = Debug();
const uint16_t port = uint16_t(REXCVAR_GET(skate3_ghost_port));
std::string server = REXCVAR_GET(skate3_ghost_server);
if (server.empty()) {
server = kGhostDefaultServer;
}
const std::string room_str = REXCVAR_GET(skate3_ghost_room);
char room_buf[kGhostMaxRoomLen] = {};
uint8_t room_len = 0;
RoomSet(room_buf, room_len, room_str.c_str());
{
std::lock_guard<std::mutex> lock(g_mu);
std::memcpy(g_room, room_buf, kGhostMaxRoomLen);
g_room_len = room_len;
}
if (sock == GHOST_INVALID_SOCKET) {
sock = GhostSocketCreate(/*allow_broadcast=*/true);
if (sock == GHOST_INVALID_SOCKET) {
std::this_thread::sleep_for(std::chrono::milliseconds(500));
continue;
}
GhostSockSetRecvTimeout(sock, 50);
}
// Resolve the public relay (cache per server/port change).
if (server != last_server || port != last_port) {
GhostAddr a;
g_peer_public.configured =
GhostResolve(server + ":" + std::to_string(port), port, &a);
if (g_peer_public.configured) {
g_peer_public.addr = a;
}
last_server = server;
last_port = port;
}
// Public relay: JOIN every kJoinIntervalSec until HELLO, then send POSE.
if (g_peer_public.configured) {
if (!g_peer_public.joined && now - g_peer_public.last_join >= kJoinIntervalSec) {
SendJoin(sock, g_peer_public.addr, /*ping=*/false);
g_peer_public.last_join = now;
}
if (g_peer_public.joined && now - g_peer_public.last_hello > kHelloTimeoutSec) {
g_peer_public.joined = false;
g_peer_public.last_join = 0.0;
if (debug) {
REXLOG_INFO("ghost: public relay timed out; reconnecting");
}
}
}
// LAN fallback only when the public relay is not connected.
const bool lan_enabled = REXCVAR_GET(skate3_ghost_lan);
if (lan_enabled && !g_peer_public.joined) {
if (!g_hosting) {
// Discovery: broadcast PING (with our room) until a host replies or
// we decide to host ourselves. Stops once we are joined to a host.
if (!g_peer_lan.joined) {
if (!g_lan_probing) {
g_lan_probing = true;
g_lan_probe_since = now;
}
if (now - g_last_ping >= 2.0) {
const GhostAddr bcast = GhostAddrBroadcast(port);
SendJoin(sock, bcast, /*ping=*/true);
g_last_ping = now;
}
}
// No matching host found in time: become the host so LAN friends
// with our room code can join.
if (now - g_lan_probe_since >= kLanProbeBeforeHostSec &&
!g_peer_lan.configured) {
if (StartHost(port)) {
g_lan_probing = false;
} else {
g_lan_probe_since = now; // retry hosting later
}
}
}
} else {
g_lan_probing = false;
}
// The LAN peer (a discovered host, or our own loopback host): JOIN + POSE.
if (g_peer_lan.configured) {
if (!g_peer_lan.joined && now - g_peer_lan.last_join >= kJoinIntervalSec) {
SendJoin(sock, g_peer_lan.addr, /*ping=*/false);
g_peer_lan.last_join = now;
}
if (g_peer_lan.joined && now - g_peer_lan.last_hello > kHelloTimeoutSec) {
g_peer_lan.joined = false;
g_peer_lan.last_join = 0.0;
}
}
// Send our pose at the configured rate to every joined peer.
g_pose_acc += dt;
const double rate = REXCVAR_GET(skate3_ghost_rate);
if (rate > 0.0 && g_pose_acc >= 1.0 / rate) {
g_pose_acc = 0.0;
if (g_peer_public.configured && g_peer_public.joined) {
SendPose(sock, g_peer_public.addr, g_seq);
}
if (g_peer_lan.configured && g_peer_lan.joined) {
SendPose(sock, g_peer_lan.addr, g_seq);
}
}
DrainSocket(sock, now);
PruneGhosts(now);
std::this_thread::sleep_for(std::chrono::milliseconds(25));
}
if (sock != GHOST_INVALID_SOCKET) {
GhostSockClose(sock);
}
{
std::lock_guard<std::mutex> lock(g_mu);
if (g_relay) {
g_relay->Stop();
g_relay.reset();
}
}
}
} // namespace
// ---- capture (sim thread; hook in skate3_native_render.cpp) ------------------
void OnLocalSkaterTick(uint8_t* base) {
if (!Enabled()) {
return;
}
const uint32_t entity = skate3::native_palette::CurrentPackOwner();
if (entity == 0) {
return;
}
float world[16];
if (!ReadEntityWorld(base, entity, world)) {
return;
}
std::lock_guard<std::mutex> lock(g_mu);
std::memcpy(g_local_world, world, sizeof(g_local_world));
g_local_valid = true;
g_local_time = NowSec();
}
void EnsureNetworkThread() {
if (g_started.exchange(true)) {
return;
}
g_thread = std::thread(GhostNetworkLoop);
}
void Shutdown() {
g_stop.store(true, std::memory_order_relaxed);
if (g_thread.joinable()) {
g_thread.join();
}
}
bool GetStatus(Status* out) {
std::lock_guard<std::mutex> lock(g_mu);
if (out == nullptr) {
return false;
}
out->enabled = Enabled();
out->connected = g_connected;
out->hosting = g_hosting;
out->session_id = g_session;
out->room_len = g_room_len;
std::memcpy(out->room, g_room, g_room_len);
out->roster = g_roster;
out->ghosts = g_ghosts.size();
std::snprintf(out->peer, sizeof(out->peer), "%s", g_peer);
return true;
}
size_t GhostCount() {
std::lock_guard<std::mutex> lock(g_mu);
return g_ghosts.size();
}
bool GetGhost(uint32_t index, float world[16], float color[3]) {
std::lock_guard<std::mutex> lock(g_mu);
if (index >= g_ghosts.size()) {
return false;
}
const Ghost& gh = g_ghosts[index];
std::memcpy(world, gh.world, sizeof(float) * 16);
// Stable color from the session id.
const uint32_t h = gh.session_id * 2654435761u;
color[0] = float((h >> 8) & 0xFF) / 255.0f;
color[1] = float((h >> 16) & 0xFF) / 255.0f;
color[2] = float((h >> 24) & 0xFF) / 255.0f;
const float lum = color[0] * 0.3f + color[1] * 0.59f + color[2] * 0.11f;
if (lum < 0.45f) {
const float boost = 0.6f / (lum + 1e-4f);
color[0] = std::min(1.0f, color[0] * boost);
color[1] = std::min(1.0f, color[1] * boost);
color[2] = std::min(1.0f, color[2] * boost);
}
return true;
}
bool GetLocalWorld(float world[16]) {
std::lock_guard<std::mutex> lock(g_mu);
if (!g_local_valid) {
return false;
}
std::memcpy(world, g_local_world, sizeof(float) * 16);
return true;
}
// ---- cube geometry -----------------------------------------------------------
// Unit cube centered at the origin, half-extent 0.5, 12 triangles as a
// non-indexed triangle list (36 vertices). All attribute lanes except the
// position are zeroed: vs_main's non-skinned path only needs POSITION (the
// tint.a > 0 solid-color branch in ShadePixel returns before any sampling).
const GhostCubeVertex* CubeVertices() {
static const GhostCubeVertex kCube[36] = {
// +Z face
{{-0.5f, -0.5f, 0.5f}}, {{0.5f, -0.5f, 0.5f}}, {{-0.5f, 0.5f, 0.5f}},
{{0.5f, -0.5f, 0.5f}}, {{0.5f, 0.5f, 0.5f}}, {{-0.5f, 0.5f, 0.5f}},
// -Z face
{{-0.5f, -0.5f, -0.5f}}, {{-0.5f, 0.5f, -0.5f}}, {{0.5f, -0.5f, -0.5f}},
{{0.5f, -0.5f, -0.5f}}, {{-0.5f, 0.5f, -0.5f}}, {{0.5f, 0.5f, -0.5f}},
// +X face
{{0.5f, -0.5f, -0.5f}}, {{0.5f, 0.5f, -0.5f}}, {{0.5f, -0.5f, 0.5f}},
{{0.5f, -0.5f, 0.5f}}, {{0.5f, 0.5f, -0.5f}}, {{0.5f, 0.5f, 0.5f}},
// -X face
{{-0.5f, -0.5f, -0.5f}}, {{-0.5f, -0.5f, 0.5f}}, {{-0.5f, 0.5f, -0.5f}},
{{-0.5f, -0.5f, 0.5f}}, {{-0.5f, 0.5f, 0.5f}}, {{-0.5f, 0.5f, -0.5f}},
// +Y face
{{-0.5f, 0.5f, -0.5f}}, {{-0.5f, 0.5f, 0.5f}}, {{0.5f, 0.5f, -0.5f}},
{{0.5f, 0.5f, -0.5f}}, {{-0.5f, 0.5f, 0.5f}}, {{0.5f, 0.5f, 0.5f}},
// -Y face
{{-0.5f, -0.5f, -0.5f}}, {{0.5f, -0.5f, -0.5f}}, {{-0.5f, -0.5f, 0.5f}},
{{0.5f, -0.5f, -0.5f}}, {{0.5f, -0.5f, 0.5f}}, {{-0.5f, -0.5f, 0.5f}},
};
return kCube;
}
uint32_t CubeVertexCount() { return 36; }
} // namespace skate3::native_ghost

View file

@ -0,0 +1,70 @@
#pragma once
// Ghost multiplayer module (bolt-on, hand-written, confined to src/native):
// a from-scratch "ghost player" layer that relays each player's world
// transform (position + rotation) through a small UDP relay so other players
// see a placeholder marker at the same spot. It deliberately does NOT touch
// the game's own netcode, animation, physics or any codegen'd logic.
//
// Topology (automatic, zero client-side setup):
// 1. Every client connects OUTBOUND to the default public relay
// (kGhostDefaultServer / skate3_ghost_server cvar) and joins a room
// code. No port forwarding on any client.
// 2. If the public relay is unreachable, the game automatically becomes a
// LAN host (GhostRelay in-process) and LAN peers auto-discover it via
// UDP broadcast PING/HELLO.
//
// Local-player capture: at Sk8::UpdateBoneTransforms exit (the documented
// player-only hook) the pack-owner entity is the player's SkaterPresEntity;
// its verified world matrix lives at entity+416 (m_MatLtoWTrans). The
// capture runs on the sim thread and copies HOST floats under a mutex; the
// network thread never reads guest memory.
#include <cstdint>
#include "skate3_native_ghost_proto.h"
namespace skate3::native_ghost {
// ---- host-side capture (called from skate3_native_render.cpp) -------------
void OnLocalSkaterTick(uint8_t* base);
// ---- network thread lifecycle -------------------------------------------------
void EnsureNetworkThread();
void Shutdown();
// ---- status (UI / debug) --------------------------------------------------------
struct Status {
bool enabled = false;
bool connected = false; // joined to a relay (public or LAN)
bool hosting = false; // this game is relaying a LAN room
uint32_t session_id = 0;
char room[kGhostMaxRoomLen] = {};
uint8_t room_len = 0;
size_t roster = 0; // other members in our room
size_t ghosts = 0; // remote ghosts currently drawn
char peer[96] = {}; // active relay "ip:port"
};
bool GetStatus(Status* out);
// ---- render query (called from RenderScene, render thread) ---------------------
size_t GhostCount();
// Row-vector world matrix (translation in row 3) + a stable color per ghost.
bool GetGhost(uint32_t index, float world[16], float color[3]);
// Self-check cube at the local captured pose (skate3_ghost_draw_local).
bool GetLocalWorld(float world[16]);
// ---- cube geometry (56-byte records matching kSceneInputLayout) -----------------
struct GhostCubeVertex {
float pos[3];
float uv[2];
float uv2[2];
uint8_t bw[4];
uint8_t bi[4];
float normal[3];
float uv3[2];
};
const GhostCubeVertex* CubeVertices();
uint32_t CubeVertexCount(); // 36 (12 triangles, non-indexed)
} // namespace skate3::native_ghost

View file

@ -0,0 +1,219 @@
#pragma once
// Minimal UDP socket shim shared by the ghost relay (standalone binary +
// in-game host mode) and the in-game client. Dependency-free: plain
// POSIX sockets on Linux/macOS, Winsock on Windows. The rexglue SDK's own
// rex::net is only close/ioctl and its XSocket is the dead Xbox Live
// emulation, which the ghost feature deliberately avoids.
#include <cstdint>
#include <cstring>
#include <string>
#if defined(_WIN32)
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")
using GhostSock = SOCKET;
#define GHOST_INVALID_SOCKET INVALID_SOCKET
#else
#include <arpa/inet.h>
#include <cerrno>
#include <netdb.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
using GhostSock = int;
#define GHOST_INVALID_SOCKET (-1)
#endif
namespace skate3::native_ghost {
struct GhostAddr {
sockaddr_storage ss = {};
socklen_t len = 0;
bool valid() const { return len != 0; }
};
inline void GhostSockInit() {
#if defined(_WIN32)
static bool s_init = [] {
WSADATA wsa = {};
WSAStartup(MAKEWORD(2, 2), &wsa);
return true;
}();
(void)s_init;
#endif
}
inline bool GhostSockClose(GhostSock s) {
if (s == GHOST_INVALID_SOCKET) {
return false;
}
#if defined(_WIN32)
closesocket(s);
#else
::close(s);
#endif
return true;
}
inline GhostSock GhostSocketCreate(bool allow_broadcast) {
GhostSockInit();
GhostSock s = ::socket(AF_INET, SOCK_DGRAM, 0);
if (s == GHOST_INVALID_SOCKET) {
return s;
}
int one = 1;
::setsockopt(s, SOL_SOCKET, SO_REUSEADDR,
reinterpret_cast<const char*>(&one), sizeof(one));
if (allow_broadcast) {
::setsockopt(s, SOL_SOCKET, SO_BROADCAST,
reinterpret_cast<const char*>(&one), sizeof(one));
}
return s;
}
inline bool GhostSockBind(GhostSock s, uint16_t port) {
sockaddr_in sin = {};
sin.sin_family = AF_INET;
sin.sin_addr.s_addr = htonl(INADDR_ANY);
sin.sin_port = htons(port);
return ::bind(s, reinterpret_cast<const sockaddr*>(&sin), sizeof(sin)) == 0;
}
// Parse "host[:port]" (IP or hostname; port optional, defaults to
// default_port). Returns false on resolve failure.
inline bool GhostResolve(const std::string& hostport, uint16_t default_port,
GhostAddr* out) {
std::string host = hostport;
uint16_t port = default_port;
const size_t colon = hostport.rfind(':');
if (colon != std::string::npos) {
host = hostport.substr(0, colon);
const std::string port_str = hostport.substr(colon + 1);
if (!port_str.empty()) {
port = uint16_t(std::stoi(port_str));
}
}
if (host.empty()) {
return false;
}
addrinfo hints = {};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_DGRAM;
hints.ai_protocol = IPPROTO_UDP;
addrinfo* res = nullptr;
const std::string port_str = std::to_string(port);
if (getaddrinfo(host.c_str(), port_str.c_str(), &hints, &res) != 0 ||
res == nullptr) {
if (res != nullptr) {
freeaddrinfo(res);
}
return false;
}
sockaddr_in* sin = reinterpret_cast<sockaddr_in*>(res->ai_addr);
sin->sin_port = htons(port);
std::memcpy(&out->ss, res->ai_addr, res->ai_addrlen);
out->len = socklen_t(res->ai_addrlen);
freeaddrinfo(res);
return true;
}
// Loopback relay feed (in-game host mode forwards the host's own pose to
// LAN members).
inline GhostAddr GhostAddrLoopback(uint16_t port) {
GhostAddr a;
sockaddr_in sin = {};
sin.sin_family = AF_INET;
sin.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
sin.sin_port = htons(port);
std::memcpy(&a.ss, &sin, sizeof(sin));
a.len = sizeof(sin);
return a;
}
// LAN broadcast address for a port.
inline GhostAddr GhostAddrBroadcast(uint16_t port) {
GhostAddr a;
sockaddr_in sin = {};
sin.sin_family = AF_INET;
sin.sin_addr.s_addr = htonl(INADDR_BROADCAST);
sin.sin_port = htons(port);
std::memcpy(&a.ss, &sin, sizeof(sin));
a.len = sizeof(sin);
return a;
}
inline uint16_t GhostAddrPort(const GhostAddr& a) {
const sockaddr_in* sin = reinterpret_cast<const sockaddr_in*>(&a.ss);
return ntohs(sin->sin_port);
}
inline bool GhostAddrEqual(const GhostAddr& a, const GhostAddr& b) {
return a.len == b.len && std::memcmp(&a.ss, &b.ss, a.len) == 0;
}
inline std::string GhostAddrStr(const GhostAddr& a) {
const sockaddr_in* sin = reinterpret_cast<const sockaddr_in*>(&a.ss);
char buf[INET_ADDRSTRLEN] = {};
inet_ntop(AF_INET, &sin->sin_addr, buf, sizeof(buf));
return std::string(buf) + ":" + std::to_string(ntohs(sin->sin_port));
}
inline bool GhostSockSendTo(GhostSock s, const GhostAddr& to, const void* data,
int size) {
#if defined(_WIN32)
const int n = ::sendto(s, reinterpret_cast<const char*>(data), size, 0,
reinterpret_cast<const sockaddr*>(&to.ss), int(to.len));
#else
const int n = ::sendto(s, data, size, 0,
reinterpret_cast<const sockaddr*>(&to.ss), to.len);
#endif
return n == size;
}
inline int GhostSockRecvFrom(GhostSock s, GhostAddr* from, void* data, int size) {
#if defined(_WIN32)
int addr_len = int(sizeof(from->ss));
const int n = ::recvfrom(s, reinterpret_cast<char*>(data), size, 0,
reinterpret_cast<sockaddr*>(&from->ss), &addr_len);
from->len = n >= 0 ? socklen_t(addr_len) : 0;
#else
socklen_t addr_len = sizeof(from->ss);
const int n = ::recvfrom(s, data, size, 0,
reinterpret_cast<sockaddr*>(&from->ss), &addr_len);
from->len = n >= 0 ? addr_len : 0;
#endif
return n;
}
inline bool GhostSockSetRecvTimeout(GhostSock s, int timeout_ms) {
#if defined(_WIN32)
DWORD tv = DWORD(timeout_ms);
#else
timeval tv = {};
tv.tv_sec = timeout_ms / 1000;
tv.tv_usec = (timeout_ms % 1000) * 1000;
#endif
return ::setsockopt(s, SOL_SOCKET, SO_RCVTIMEO,
reinterpret_cast<const char*>(&tv), sizeof(tv)) == 0;
}
inline int GhostSockLastError() {
#if defined(_WIN32)
return WSAGetLastError();
#else
return errno;
#endif
}
inline bool GhostSockWouldBlock(int err) {
#if defined(_WIN32)
return err == WSAEWOULDBLOCK || err == WSAETIMEDOUT;
#else
return err == EAGAIN || err == EWOULDBLOCK;
#endif
}
} // namespace skate3::native_ghost

View file

@ -0,0 +1,136 @@
#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 <cstdint>
#include <cstring>
namespace skate3::native_ghost {
constexpr uint32_t kGhostMagic = 0x54484753u; // "GHST" (LE)
constexpr uint8_t kGhostVersion = 1;
constexpr uint16_t kGhostDefaultPort = 40001;
constexpr uint32_t kGhostMaxRoomLen = 8;
constexpr uint32_t kGhostMaxRoster = 32;
// Placeholder default public relay: replace in ONE place (skate3_native_ghost.cpp).
constexpr const char* kGhostDefaultServer = "ghost.skate3.example";
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, // client -> relay -> other members: { PosePayload }
kTypePing = 4, // client -> relay / LAN broadcast probe: { JoinPayload }
kTypePong = 5, // relay -> client: empty
kTypeLeave = 6, // client -> relay: empty
};
struct alignas(1) 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).
struct alignas(1) JoinPayload {
uint8_t room_len;
char room[kGhostMaxRoomLen];
};
struct alignas(1) HelloPayload {
uint32_t session_id; // our assigned session id (0 = refused)
uint8_t room_len;
char room[kGhostMaxRoomLen];
uint8_t roster_count; // other members' session ids in this room
uint32_t roster[kGhostMaxRoster];
};
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
float rot[9]; // world rotation, row-vector 3x3 (matches entity+416)
};
// Largest payload: HelloPayload (4 + 1 + 8 + 1 + 128 = 142 bytes).
constexpr uint16_t kGhostMaxPayload = 256;
struct alignas(1) PacketBuf {
PacketHeader h;
uint8_t payload[kGhostMaxPayload];
};
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);
}
}
// ---- 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

View file

@ -0,0 +1,230 @@
#include "skate3_native_ghost_relay.h"
#include <algorithm>
#include <chrono>
#include <cstring>
namespace skate3::native_ghost {
namespace {
constexpr double kClientTimeoutSec = 8.0;
} // namespace
GhostRelay::~GhostRelay() {
Stop();
}
bool GhostRelay::Start() {
if (running_.load(std::memory_order_relaxed)) {
return true;
}
sock_ = GhostSocketCreate(/*allow_broadcast=*/true);
if (sock_ == GHOST_INVALID_SOCKET) {
return false;
}
if (!GhostSockBind(sock_, port_)) {
GhostSockClose(sock_);
sock_ = GHOST_INVALID_SOCKET;
return false;
}
GhostSockSetRecvTimeout(sock_, 200);
running_.store(true, std::memory_order_relaxed);
stop_.store(false, std::memory_order_relaxed);
thread_ = std::thread(&GhostRelay::Run, this);
return true;
}
void GhostRelay::Stop() {
if (!running_.load(std::memory_order_relaxed)) {
return;
}
stop_.store(true, std::memory_order_relaxed);
if (thread_.joinable()) {
thread_.join();
}
if (sock_ != GHOST_INVALID_SOCKET) {
GhostSockClose(sock_);
sock_ = GHOST_INVALID_SOCKET;
}
running_.store(false, std::memory_order_relaxed);
std::lock_guard<std::mutex> lock(mu_);
clients_.clear();
}
size_t GhostRelay::ClientCount() const {
std::lock_guard<std::mutex> lock(mu_);
return clients_.size();
}
void GhostRelay::SetLockedRoom(const char* room) {
std::lock_guard<std::mutex> lock(mu_);
RoomSet(locked_room_, locked_room_len_, room);
locked_ = locked_room_len_ != 0;
}
void GhostRelay::Run() {
while (!stop_.load(std::memory_order_relaxed)) {
GhostAddr from;
PacketBuf p;
const int n = GhostSockRecvFrom(sock_, &from, &p, sizeof(p));
if (n <= 0) {
Sweep(0.0);
continue;
}
if (n < static_cast<int>(sizeof(PacketHeader))) {
continue;
}
if (p.h.magic != kGhostMagic) {
continue;
}
const uint16_t expect = static_cast<uint16_t>(n - sizeof(PacketHeader));
if (p.h.payload_size != expect ||
p.h.payload_size > kGhostMaxPayload) {
continue;
}
const double now = double(std::chrono::steady_clock::now().time_since_epoch().count()) /
1e9;
HandlePacket(from, p, now);
Sweep(now);
}
}
void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p,
double now) {
std::lock_guard<std::mutex> lock(mu_);
// Resolve (or create) the sender's client record.
Client* me = nullptr;
for (Client& c : clients_) {
if (GhostAddrEqual(c.addr, from)) {
me = &c;
break;
}
}
if (me == nullptr) {
clients_.push_back(Client{});
me = &clients_.back();
me->addr = from;
me->session_id = next_session_++;
RoomClear(me->room, me->room_len);
}
const auto send = [this, &from](const PacketBuf& out) {
GhostSockSendTo(sock_, from, &out, PacketSize(out));
};
PacketBuf out = {};
switch (p.h.type) {
case kTypeJoin:
case kTypePing: {
JoinPayload jp = {};
std::memcpy(&jp, p.payload, sizeof(jp));
if (locked_) {
std::memcpy(me->room, locked_room_, locked_room_len_);
me->room_len = locked_room_len_;
} else if (RoomValid(jp.room, jp.room_len)) {
std::memcpy(me->room, jp.room, jp.room_len);
me->room_len = jp.room_len;
}
// 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) {
uint32_t state = (uint32_t(now * 1e3) * 2654435761u) ^
(me->session_id * 40503u) ^ 0x9E3779B9u;
char room[kGhostMaxRoomLen] = {};
for (uint8_t i = 0; i < 4; ++i) {
state = state * 1664525u + 1013904223u;
room[i] = (state & 1u) != 0
? static_cast<char>('A' + (state >> 1) % 26u)
: static_cast<char>('0' + (state >> 2) % 10u);
}
std::memcpy(me->room, room, 4);
me->room_len = 4;
}
me->last_seen = now;
if (p.h.type == kTypeJoin) {
// HELLO: our session id, room and the roster of the OTHER members.
HelloPayload hp = {};
hp.session_id = me->session_id;
hp.room_len = me->room_len;
std::memcpy(hp.room, me->room, me->room_len);
for (const Client& c : clients_) {
if (&c != me && c.room_len == me->room_len &&
RoomEquals(c.room, c.room_len, me->room, me->room_len) &&
hp.roster_count < kGhostMaxRoster) {
hp.roster[hp.roster_count++] = c.session_id;
}
}
BuildPacket(out, kTypeHello, &hp, static_cast<uint16_t>(sizeof(hp)));
send(out);
} else {
// PING probe: HELLO with the host's room/roster so probing clients
// can decide whether to join this LAN host.
HelloPayload hp = {};
hp.session_id = me->session_id;
hp.room_len = me->room_len;
std::memcpy(hp.room, me->room, me->room_len);
for (const Client& c : clients_) {
if (&c != me && c.room_len == me->room_len &&
RoomEquals(c.room, c.room_len, me->room, me->room_len) &&
hp.roster_count < kGhostMaxRoster) {
hp.roster[hp.roster_count++] = c.session_id;
}
}
BuildPacket(out, kTypeHello, &hp, static_cast<uint16_t>(sizeof(hp)));
send(out);
}
break;
}
case kTypePose: {
// Clients join before posing; a pose with no room is auto-joined to
// the lobby so the relay stays resilient to a missed HELLO.
if (me->room_len == 0) {
RoomSet(me->room, me->room_len, kGhostLobbyRoom);
}
me->last_seen = now;
PosePayload pose = {};
std::memcpy(&pose, p.payload, sizeof(pose));
pose.session_id = me->session_id;
for (const Client& c : clients_) {
if (&c == me) {
continue;
}
if (c.room_len != me->room_len ||
!RoomEquals(c.room, c.room_len, me->room, me->room_len)) {
continue;
}
BuildPacket(out, kTypePose, &pose, static_cast<uint16_t>(sizeof(pose)));
GhostSockSendTo(sock_, c.addr, &out, PacketSize(out));
}
break;
}
case kTypeLeave: {
clients_.erase(std::remove_if(clients_.begin(), clients_.end(),
[&from](const Client& c) {
return GhostAddrEqual(c.addr, from);
}),
clients_.end());
break;
}
default:
break; // PONG etc. are client-side only; ignore at the relay.
}
}
void GhostRelay::Sweep(double now) {
std::lock_guard<std::mutex> lock(mu_);
if (now <= 0.0) {
return;
}
clients_.erase(std::remove_if(clients_.begin(), clients_.end(),
[now](const Client& c) {
return now - c.last_seen > kClientTimeoutSec;
}),
clients_.end());
}
} // namespace skate3::native_ghost

View file

@ -0,0 +1,72 @@
#pragma once
// Ghost multiplayer relay core: a small UDP forwarder shared by the
// standalone ghost_relay binary (run on a public VPS) and the in-game host
// mode (a player's game relays its LAN room). Clients connect OUTBOUND only
// and are matched into rooms by a shareable code. The relay forwards each
// client's PosePayload to every OTHER member of the same room.
//
// Clients are keyed by source address; every packet refreshes last_seen and
// silent clients are dropped (their ghosts vanish on the other side). Room
// codes are uppercase alphanumeric; a JOIN with an empty room gets one
// assigned by the server and returned in the HELLO.
#include <atomic>
#include <cstdint>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "skate3_native_ghost_net.h"
#include "skate3_native_ghost_proto.h"
namespace skate3::native_ghost {
class GhostRelay {
public:
explicit GhostRelay(uint16_t port = kGhostDefaultPort) : port_(port) {}
~GhostRelay();
GhostRelay(const GhostRelay&) = delete;
GhostRelay& operator=(const GhostRelay&) = delete;
// Bind the socket and start the receive thread. Returns false when the
// port cannot be bound (already in use / no permission).
bool Start();
void Stop();
bool running() const { return running_.load(std::memory_order_relaxed); }
uint16_t port() const { return port_; }
size_t ClientCount() const;
// When non-empty, every client is forced into this room (the standalone
// binary's --room lock); client JOINs and PINGs cannot override it.
void SetLockedRoom(const char* room);
private:
void Run();
void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now);
void Sweep(double now);
GhostSock sock_ = GHOST_INVALID_SOCKET;
uint16_t port_;
std::thread thread_;
std::atomic<bool> running_{false};
std::atomic<bool> stop_{false};
struct Client {
GhostAddr addr;
uint32_t session_id = 0;
char room[kGhostMaxRoomLen] = {};
uint8_t room_len = 0;
double last_seen = 0.0;
};
mutable std::mutex mu_;
std::vector<Client> clients_;
uint32_t next_session_ = 1;
bool locked_ = false;
char locked_room_[kGhostMaxRoomLen] = {};
uint8_t locked_room_len_ = 0;
};
} // namespace skate3::native_ghost

View file

@ -889,6 +889,10 @@ uint32_t ExchangePackOwner(uint32_t entity) {
return prev;
}
uint32_t CurrentPackOwner() {
return tl_pack_owner;
}
void OnPackPalette(uint8_t* base, uint32_t instance) {
if (instance == 0 || !CaptureActive()) {
return;

View file

@ -53,6 +53,11 @@ void OnLwPack(uint8_t* base, uint32_t entity);
// the bracket carry the owning entity for identity-exact serving.
uint32_t ExchangePackOwner(uint32_t entity);
// Current pack-owner entity on this thread (0 when outside any bracket).
// Read by the ghost multiplayer module inside the UpdateBoneTransforms hook
// to identify the local player's Sk8::SkaterPresEntity.
uint32_t CurrentPackOwner();
// Called from BuildFrameScene while the frame's items are finalized:
// replaces each mapped skinned character item's captured bank palette with
// its instance's own packed rows. Served items get dbg_src=11 and

View file

@ -11,6 +11,8 @@
#include "skate3_title_update_installer.h"
#include "skate3_user_settings.h"
#include "native/skate3_native_ghost.h"
#include <algorithm>
#include <chrono>
#include <cstring>
@ -723,6 +725,10 @@ void Skate3BaseApp::OnCreateDialogs(rex::ui::ImGuiDrawer* drawer) {
"Native render debug menu", [this] {
ToggleNativeDebug();
});
rex::ui::RegisterBind("bind_skate3_ghost", "M",
"Ghost multiplayer menu", [this] {
ToggleGhost();
});
rex::ui::RegisterBind("bind_skate3_showcase", "Ctrl+Shift+B",
"Graphics build-up showcase", [] {
// A capture/recording tool, not a player feature:
@ -793,6 +799,8 @@ void Skate3BaseApp::OnPostSetup() {
auto* dispatcher = runtime()->function_dispatcher();
skate3::native_render::Install();
skate3::demo_path::InstallHooks(dispatcher);
// Ghost multiplayer network thread (idle until skate3_ghost_enable).
skate3::native_ghost::EnsureNetworkThread();
// User-facing intro-movie skip (independent of the demo path): the movie
// completion override polls the merged UI pad state through this provider.
skate3::demo_path::SetUiInputProvider([this]() {
@ -820,10 +828,13 @@ void Skate3BaseApp::OnShutdown() {
rex::ui::UnregisterBind("bind_skate3_log_debug_marker");
rex::ui::UnregisterBind("bind_skate3_log_user_marker");
rex::ui::UnregisterBind("bind_skate3_native_debug");
rex::ui::UnregisterBind("bind_skate3_ghost");
skate3::native_ghost::Shutdown();
ApplyGameplayCursorMode();
skate3::native_scene::SetSettingsMenuBlur(false);
simple_settings_dialog_.reset();
native_debug_dialog_.reset();
ghost_dialog_.reset();
render_mode_indicator_.reset();
}
@ -944,6 +955,19 @@ void Skate3BaseApp::ToggleNativeDebug() {
}
}
void Skate3BaseApp::ToggleGhost() {
if (!ghost_dialog_) {
ghost_dialog_ = std::make_unique<skate3::GhostDialog>(imgui_drawer());
}
if (ghost_dialog_->visible()) {
ghost_dialog_->Hide();
ApplyGameplayCursorMode();
} else {
ApplySettingsCursorMode();
ghost_dialog_->Show();
}
}
void Skate3BaseApp::ApplySettingsCursorMode() {
if (window()) {
window()->SetCursorVisibility(rex::ui::Window::CursorVisibility::kVisible);

View file

@ -14,6 +14,7 @@
#include <rex/ui/overlay/simple_settings_overlay.h>
#include "skate3_native_debug_dialog.h"
#include "skate3_native_ghost_dialog.h"
namespace rex::ui {
class ImGuiDrawer;
@ -40,6 +41,7 @@ class Skate3BaseApp : public rex::ReXApp {
void InstallDlcPackages();
void ToggleSimpleSettings();
void ToggleNativeDebug();
void ToggleGhost();
void ApplySettingsCursorMode();
void ApplyGameplayCursorMode();
void RestartGame();
@ -59,6 +61,7 @@ class Skate3BaseApp : public rex::ReXApp {
std::filesystem::path profiles_path_;
std::unique_ptr<rex::ui::SimpleSettingsDialog> simple_settings_dialog_;
std::unique_ptr<skate3::NativeDebugDialog> native_debug_dialog_;
std::unique_ptr<skate3::GhostDialog> ghost_dialog_;
std::unique_ptr<skate3::RenderModeIndicator> render_mode_indicator_;
bool recipe_overlay_installed_ = false;
bool big_device_aliases_installed_ = false;

View file

@ -0,0 +1,147 @@
#include "skate3_native_ghost_dialog.h"
#include <cstring>
#include <string>
#include <imgui.h>
#include <rex/cvar.h>
#include <rex/logging.h>
#include "native/skate3_native_ghost.h"
REXCVAR_DECLARE(bool, skate3_ghost_enable);
REXCVAR_DECLARE(std::string, skate3_ghost_server);
REXCVAR_DECLARE(int32_t, skate3_ghost_port);
REXCVAR_DECLARE(std::string, skate3_ghost_room);
REXCVAR_DECLARE(double, skate3_ghost_rate);
REXCVAR_DECLARE(bool, skate3_ghost_draw_local);
namespace skate3 {
void GhostDialog::Show() {
visible_ = true;
SetDrawActive(true);
}
void GhostDialog::Hide() {
if (!visible_) {
return;
}
visible_ = false;
SetDrawActive(false);
}
void GhostDialog::Toggle() {
if (visible_) {
Hide();
} else {
Show();
}
}
void GhostDialog::SyncBuffersFromCvars() {
const std::string room = REXCVAR_GET(skate3_ghost_room);
std::snprintf(room_input_, sizeof(room_input_), "%s", room.c_str());
const std::string server = REXCVAR_GET(skate3_ghost_server);
std::snprintf(server_input_, sizeof(server_input_), "%s", server.c_str());
synced_ = true;
}
void GhostDialog::OnDraw(ImGuiIO& io) {
(void)io;
if (!visible_) {
return;
}
if (!synced_) {
SyncBuffersFromCvars();
}
bool open = visible_;
ImGui::SetNextWindowSize(ImVec2(380.0f, 0.0f), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Ghost Multiplayer (M)", &open,
ImGuiWindowFlags_NoCollapse)) {
ImGui::End();
if (!open) {
Hide();
}
return;
}
bool enable = REXCVAR_GET(skate3_ghost_enable);
if (ImGui::Checkbox("Enable ghost multiplayer", &enable)) {
REXCVAR_SET(skate3_ghost_enable, enable);
if (enable) {
REXLOG_INFO("ghost: multiplayer enabled");
}
}
ImGui::SameLine();
ImGui::TextDisabled("(relays your position, draws remote players)");
ImGui::Separator();
ImGui::Text("Room code");
if (ImGui::InputText("##ghost_room", room_input_, sizeof(room_input_),
ImGuiInputTextFlags_EnterReturnsTrue |
ImGuiInputTextFlags_CharsUppercase)) {
REXCVAR_SET(skate3_ghost_room, std::string(room_input_));
ImGui::SetKeyboardFocusHere(-1);
}
ImGui::SameLine();
if (ImGui::Button("Copy")) {
ImGui::SetClipboardText(REXCVAR_GET(skate3_ghost_room).c_str());
}
ImGui::TextDisabled("Empty = the server assigns one; share it with friends.");
ImGui::Separator();
ImGui::Text("Relay server (optional)");
ImGui::PushItemWidth(-1.0f);
if (ImGui::InputText("##ghost_server", server_input_, sizeof(server_input_),
ImGuiInputTextFlags_EnterReturnsTrue)) {
REXCVAR_SET(skate3_ghost_server, std::string(server_input_));
ImGui::SetKeyboardFocusHere(-1);
}
ImGui::PopItemWidth();
ImGui::TextDisabled("Empty = built-in default relay. No port forwarding "
"needed on any client.");
ImGui::Separator();
int port = int(REXCVAR_GET(skate3_ghost_port));
if (ImGui::SliderInt("Port", &port, 1024, 65535)) {
REXCVAR_SET(skate3_ghost_port, int32_t(port));
}
const double rate = REXCVAR_GET(skate3_ghost_rate);
rate_int_ = int(rate + 0.5);
if (ImGui::SliderInt("Broadcast rate (Hz)", &rate_int_, 5, 60)) {
REXCVAR_SET(skate3_ghost_rate, double(rate_int_));
}
bool draw_local = REXCVAR_GET(skate3_ghost_draw_local);
if (ImGui::Checkbox("Show my own cube (self-check)", &draw_local)) {
REXCVAR_SET(skate3_ghost_draw_local, draw_local);
}
ImGui::Separator();
skate3::native_ghost::Status st;
skate3::native_ghost::GetStatus(&st);
ImGui::Text("Status:");
ImGui::Text(" Enabled: %s", st.enabled ? "yes" : "no");
ImGui::Text(" Connected: %s%s", st.connected ? "yes" : "no",
st.hosting ? " (LAN host)" : "");
ImGui::Text(" Session: %u", st.session_id);
ImGui::Text(" Room: %.*s", int(st.room_len), st.room);
ImGui::Text(" In room: %zu player(s)", st.roster + 1);
ImGui::Text(" Ghosts drawn: %zu", st.ghosts);
if (st.peer[0] != '\0') {
ImGui::Text(" Relay: %s", st.peer);
}
ImGui::End();
if (!open) {
Hide();
}
}
} // namespace skate3

View file

@ -0,0 +1,34 @@
#pragma once
// Ghost multiplayer menu: enable toggle, room-code entry, relay override and
// a live connection status readout. A player-facing dialog (bound to M by
// default in skate3_app_common.cpp); everything it edits is a hot-reload
// cvar in skate3_native_ghost.cpp.
#include <rex/ui/imgui_dialog.h>
namespace skate3 {
class GhostDialog final : public rex::ui::ImGuiDialog {
public:
explicit GhostDialog(rex::ui::ImGuiDrawer* drawer) : ImGuiDialog(drawer) {}
void Show();
void Hide();
void Toggle();
bool visible() const { return visible_; }
protected:
void OnDraw(ImGuiIO& io) override;
private:
void SyncBuffersFromCvars();
bool visible_ = false;
bool synced_ = false;
char room_input_[16] = {};
char server_input_[128] = {};
int rate_int_ = 20;
};
} // namespace skate3

View file

@ -2,6 +2,7 @@
#include "native/skate3_native_diag.h"
#include "native/skate3_native_entity.h"
#include "native/skate3_native_ghost.h"
#include "native/skate3_native_guest_read.h"
#include "native/skate3_native_lw.h"
#include "native/skate3_native_palette.h"
@ -472,6 +473,9 @@ extern "C" REX_FUNC(sub_827A52C8) {
__imp__sub_827A52C8(ctx, base);
if (skate3::native_render::Enabled()) {
skate3::native_palette::OnBoneTransforms(base, parts, count);
// Ghost multiplayer: capture the local player's world transform (the
// pack-owner entity at this point is the player's SkaterPresEntity).
skate3::native_ghost::OnLocalSkaterTick(base);
}
}

View file

@ -39,6 +39,7 @@
#include "native/skate3_native_diag.h"
#include "native/skate3_native_entity.h"
#include "native/skate3_native_ghost.h"
#include "native/skate3_native_guest_read.h"
#include "native/skate3_native_lw.h"
#include "native/skate3_native_palette.h"
@ -126,6 +127,8 @@ REXCVAR_DECLARE(bool, skate3_native_render_scene_tex_revalidate);
REXCVAR_DECLARE(bool, skate3_native_render_scene_transparents);
REXCVAR_DECLARE(bool, skate3_native_render_scene_world_v2);
REXCVAR_DECLARE(double, skate3_menu_blur_sigma);
REXCVAR_DECLARE(bool, skate3_ghost_enable);
REXCVAR_DECLARE(bool, skate3_ghost_draw_local);
REXCVAR_DECLARE(double, skate3_native_render_scene_2d_sharp);
REXCVAR_DECLARE(double, skate3_native_render_scene_bloom_intensity);
REXCVAR_DECLARE(double, skate3_native_render_scene_bloom_knee);
@ -10339,6 +10342,72 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
uint32_t(frame_number % RendererState::kUiRegions) * RendererState::kUiRegionSize;
uint32_t ui_offset = 0;
// ---- Ghost multiplayer placeholder cubes (native/skate3_native_ghost) ----
// Solid-color unit cubes at the remote players' captured world positions,
// drawn inside the scene pass (depth-tested against the world like the
// spline pass). Reuses the existing main-pass PSO and the scene.hlsl
// tint.a > 0 solid-color branch: no new shader, no SPIR-V regeneration.
if (REXCVAR_GET(skate3_ghost_enable) && g_r.pso != nullptr &&
g_r.ui_ring_cpu != nullptr && debug_mode == 0) {
const uint32_t n_ghosts = skate3::native_ghost::GhostCount();
float local_world[16];
const bool draw_local =
REXCVAR_GET(skate3_ghost_draw_local) &&
skate3::native_ghost::GetLocalWorld(local_world);
if (n_ghosts != 0 || draw_local) {
constexpr uint32_t kCubeVerts = 36;
const uint32_t kVertBytes =
uint32_t(sizeof(skate3::native_ghost::GhostCubeVertex));
const uint32_t kCubeBytes = kCubeVerts * kVertBytes;
if (ui_offset + kCubeBytes <= RendererState::kUiRegionSize) {
std::memcpy(g_r.ui_ring_cpu + ui_region + ui_offset,
skate3::native_ghost::CubeVertices(), kCubeBytes);
const uint32_t cube_offset = ui_offset;
ui_offset += kCubeBytes;
cmd->SetPipeline(g_r.pso);
cmd->SetVertexBuffer(g_r.ui_ring, ui_region + cube_offset, kCubeBytes,
kVertBytes);
cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList);
cmd->SetTexture(1, g_r.white.srv);
const auto draw_ghost = [&](const float world[16], const float color[3]) {
float constants[52] = {};
std::memcpy(constants, world, sizeof(float) * 16);
float* mvp = constants + 16;
for (int r = 0; r < 4; ++r) {
for (int c = 0; c < 4; ++c) {
float sum = 0.0f;
for (int k = 0; k < 4; ++k) {
sum += constants[r * 4 + k] * scene.view_proj[k * 4 + c];
}
mvp[r * 4 + c] = sum;
}
}
constants[32] = color[0];
constants[33] = color[1];
constants[34] = color[2];
constants[35] = 1.0f; // tint.a > 0 -> solid color in ShadePixel
constants[36] = scene.cam_pos[0];
constants[37] = scene.cam_pos[1];
constants[38] = scene.cam_pos[2];
constants[39] = 0.0f;
cmd->SetRootConstants(0, 52, constants, 0);
cmd->Draw(kCubeVerts, 0);
};
for (uint32_t i = 0; i < n_ghosts; ++i) {
float world[16];
float color[3];
if (skate3::native_ghost::GetGhost(i, world, color)) {
draw_ghost(world, color);
}
}
if (draw_local) {
const float local_color[3] = {0.2f, 1.0f, 0.2f};
draw_ghost(local_world, local_color);
}
}
}
}
// In-world neon splines (waypoint arrows / marker beams): replayed inside
// the scene pass, depth-tested against the world like the emulated frame,
// in submission order (darken backdrop passes precede the additive glow).

View file

@ -0,0 +1,99 @@
// ghost_relay: standalone ghost multiplayer relay server.
//
// Run on a public VPS (or your own machine behind a free tunnel like
// playit.gg / ngrok / Tailscale Funnel) so players connect OUTBOUND to it
// and need no port forwarding:
//
// ghost_relay [--port 40001] [--room CODE]
//
// --room optionally locks every client into one room (ignores JOIN rooms).
// The server is stateless across restarts: clients re-send their JOIN every
// ~5 s, so a redeployed relay re-gathers the room automatically.
//
// Build: cmake target `ghost_relay` (no rexglue dependency; plain sockets).
#include <chrono>
#include <cstdio>
#include <cstring>
#include <string>
#include <thread>
#include "skate3_native_ghost_relay.h"
#if !defined(_WIN32)
#include <csignal>
namespace {
volatile std::sig_atomic_t g_stop = 0;
void OnSignal(int) { g_stop = 1; }
} // namespace
#endif
int main(int argc, char** argv) {
uint16_t port = skate3::native_ghost::kGhostDefaultPort;
std::string locked_room;
for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i];
auto next = [&](const char* name) -> std::string {
if (i + 1 < argc) {
return argv[++i];
}
std::fprintf(stderr, "ghost_relay: %s requires a value\n", name);
std::exit(2);
};
if (arg == "--port") {
port = uint16_t(std::stoi(next("--port")));
} else if (arg == "--room") {
locked_room = next("--room");
} else if (arg == "--help" || arg == "-h") {
std::printf(
"ghost_relay - ghost multiplayer UDP relay\n"
"usage: ghost_relay [--port 40001] [--room CODE]\n"
" --port UDP port to listen on (clients connect here; allow this\n"
" port through any firewall, e.g. `ufw allow 40001/udp`)\n"
" --room optional: force every client into one room code\n");
return 0;
} else {
std::fprintf(stderr, "ghost_relay: unknown argument '%s'\n", arg.c_str());
return 2;
}
}
#if !defined(_WIN32)
std::signal(SIGINT, OnSignal);
std::signal(SIGTERM, OnSignal);
#endif
skate3::native_ghost::GhostRelay relay(port);
if (!locked_room.empty()) {
relay.SetLockedRoom(locked_room.c_str());
}
if (!relay.Start()) {
std::fprintf(stderr, "ghost_relay: failed to bind UDP port %u\n", port);
return 1;
}
const std::string lock_note =
locked_room.empty() ? "" : (" (locked room: " + locked_room + ")");
std::printf("ghost_relay: listening on UDP %u%s\n", port, lock_note.c_str());
std::printf("ghost_relay: clients connect outbound; no client-side port "
"forwarding needed\n");
// Status line every 5 s; on POSIX exit on SIGINT/SIGTERM.
auto last_log = std::chrono::steady_clock::now();
while (true) {
#if !defined(_WIN32)
if (g_stop != 0) {
break;
}
#endif
const auto now = std::chrono::steady_clock::now();
if (now - last_log >= std::chrono::seconds(5)) {
std::printf("ghost_relay: %zu client(s)\n", relay.ClientCount());
std::fflush(stdout);
last_log = now;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
relay.Stop();
std::printf("ghost_relay: stopped\n");
return 0;
}