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).
724 lines
No EOL
23 KiB
C++
724 lines
No EOL
23 KiB
C++
#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
|