#include "skate3_native_ghost.h" #include #include #include #include #include #include #include #include #include #include #include #include #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 #include // ---- 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(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(__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(__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 g_ghosts; std::unordered_map 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 g_stop{false}; std::atomic 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 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 lock(g_mu); g_room_len = std::min(len, kGhostMaxRoomLen); std::memcpy(g_room, room, g_room_len); } void SetStatusPeer(const GhostAddr& a) { const std::string s = GhostAddrStr(a); std::lock_guard 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 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 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 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 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(sizeof(PacketHeader)) || p.h.magic != kGhostMagic || p.h.payload_size != static_cast(n - sizeof(PacketHeader)) || p.h.payload_size > kGhostMaxPayload) { continue; } HandlePacket(from, p, now); } } void PruneGhosts(double now) { std::lock_guard 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 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(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 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 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 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 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 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 lock(g_mu); return g_ghosts.size(); } bool GetGhost(uint32_t index, float world[16], float color[3]) { std::lock_guard 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 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