From 269fbcee32b664d393c530d6faccdf312ae853e3 Mon Sep 17 00:00:00 2001 From: MiTHRAL Date: Wed, 26 Aug 2026 20:12:45 -0400 Subject: [PATCH] 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). --- .gitignore | 4 +- CMakeLists.txt | 16 + src/native/skate3_native_ghost.cpp | 724 +++++++++++++++++++++++ src/native/skate3_native_ghost.h | 70 +++ src/native/skate3_native_ghost_net.h | 219 +++++++ src/native/skate3_native_ghost_proto.h | 136 +++++ src/native/skate3_native_ghost_relay.cpp | 230 +++++++ src/native/skate3_native_ghost_relay.h | 72 +++ src/native/skate3_native_palette.cpp | 4 + src/native/skate3_native_palette.h | 5 + src/skate3_app_common.cpp | 24 + src/skate3_app_common.h | 3 + src/skate3_native_ghost_dialog.cpp | 147 +++++ src/skate3_native_ghost_dialog.h | 34 ++ src/skate3_native_render.cpp | 4 + src/skate3_native_scene_gpu.cpp | 69 +++ tools/ghost_relay_main.cpp | 99 ++++ 17 files changed, 1859 insertions(+), 1 deletion(-) create mode 100644 src/native/skate3_native_ghost.cpp create mode 100644 src/native/skate3_native_ghost.h create mode 100644 src/native/skate3_native_ghost_net.h create mode 100644 src/native/skate3_native_ghost_proto.h create mode 100644 src/native/skate3_native_ghost_relay.cpp create mode 100644 src/native/skate3_native_ghost_relay.h create mode 100644 src/skate3_native_ghost_dialog.cpp create mode 100644 src/skate3_native_ghost_dialog.h create mode 100644 tools/ghost_relay_main.cpp diff --git a/.gitignore b/.gitignore index ca996da..7097c38 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 07972e0..1a452f0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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() diff --git a/src/native/skate3_native_ghost.cpp b/src/native/skate3_native_ghost.cpp new file mode 100644 index 0000000..c755144 --- /dev/null +++ b/src/native/skate3_native_ghost.cpp @@ -0,0 +1,724 @@ +#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 \ No newline at end of file diff --git a/src/native/skate3_native_ghost.h b/src/native/skate3_native_ghost.h new file mode 100644 index 0000000..268e342 --- /dev/null +++ b/src/native/skate3_native_ghost.h @@ -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 + +#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 \ No newline at end of file diff --git a/src/native/skate3_native_ghost_net.h b/src/native/skate3_native_ghost_net.h new file mode 100644 index 0000000..dd56389 --- /dev/null +++ b/src/native/skate3_native_ghost_net.h @@ -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 +#include +#include + +#if defined(_WIN32) +#include +#include +#pragma comment(lib, "ws2_32.lib") +using GhostSock = SOCKET; +#define GHOST_INVALID_SOCKET INVALID_SOCKET +#else +#include +#include +#include +#include +#include +#include +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(&one), sizeof(one)); + if (allow_broadcast) { + ::setsockopt(s, SOL_SOCKET, SO_BROADCAST, + reinterpret_cast(&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(&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(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(&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(&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(data), size, 0, + reinterpret_cast(&to.ss), int(to.len)); +#else + const int n = ::sendto(s, data, size, 0, + reinterpret_cast(&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(data), size, 0, + reinterpret_cast(&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(&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(&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 \ No newline at end of file diff --git a/src/native/skate3_native_ghost_proto.h b/src/native/skate3_native_ghost_proto.h new file mode 100644 index 0000000..fa6c3c0 --- /dev/null +++ b/src/native/skate3_native_ghost_proto.h @@ -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 +#include + +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 \ No newline at end of file diff --git a/src/native/skate3_native_ghost_relay.cpp b/src/native/skate3_native_ghost_relay.cpp new file mode 100644 index 0000000..69d0537 --- /dev/null +++ b/src/native/skate3_native_ghost_relay.cpp @@ -0,0 +1,230 @@ +#include "skate3_native_ghost_relay.h" + +#include +#include +#include + +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 lock(mu_); + clients_.clear(); +} + +size_t GhostRelay::ClientCount() const { + std::lock_guard lock(mu_); + return clients_.size(); +} + +void GhostRelay::SetLockedRoom(const char* room) { + std::lock_guard 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(sizeof(PacketHeader))) { + continue; + } + if (p.h.magic != kGhostMagic) { + continue; + } + const uint16_t expect = static_cast(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 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('A' + (state >> 1) % 26u) + : static_cast('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(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(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(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 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 \ No newline at end of file diff --git a/src/native/skate3_native_ghost_relay.h b/src/native/skate3_native_ghost_relay.h new file mode 100644 index 0000000..9e55eed --- /dev/null +++ b/src/native/skate3_native_ghost_relay.h @@ -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 +#include +#include +#include +#include +#include + +#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 running_{false}; + std::atomic 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 clients_; + uint32_t next_session_ = 1; + bool locked_ = false; + char locked_room_[kGhostMaxRoomLen] = {}; + uint8_t locked_room_len_ = 0; +}; + +} // namespace skate3::native_ghost \ No newline at end of file diff --git a/src/native/skate3_native_palette.cpp b/src/native/skate3_native_palette.cpp index c024cab..850bf85 100644 --- a/src/native/skate3_native_palette.cpp +++ b/src/native/skate3_native_palette.cpp @@ -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; diff --git a/src/native/skate3_native_palette.h b/src/native/skate3_native_palette.h index a40c425..dd7350a 100644 --- a/src/native/skate3_native_palette.h +++ b/src/native/skate3_native_palette.h @@ -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 diff --git a/src/skate3_app_common.cpp b/src/skate3_app_common.cpp index a9126bf..bf66071 100644 --- a/src/skate3_app_common.cpp +++ b/src/skate3_app_common.cpp @@ -11,6 +11,8 @@ #include "skate3_title_update_installer.h" #include "skate3_user_settings.h" +#include "native/skate3_native_ghost.h" + #include #include #include @@ -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(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); diff --git a/src/skate3_app_common.h b/src/skate3_app_common.h index f93a082..c24f10e 100644 --- a/src/skate3_app_common.h +++ b/src/skate3_app_common.h @@ -14,6 +14,7 @@ #include #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 simple_settings_dialog_; std::unique_ptr native_debug_dialog_; + std::unique_ptr ghost_dialog_; std::unique_ptr render_mode_indicator_; bool recipe_overlay_installed_ = false; bool big_device_aliases_installed_ = false; diff --git a/src/skate3_native_ghost_dialog.cpp b/src/skate3_native_ghost_dialog.cpp new file mode 100644 index 0000000..9f0a38b --- /dev/null +++ b/src/skate3_native_ghost_dialog.cpp @@ -0,0 +1,147 @@ +#include "skate3_native_ghost_dialog.h" + +#include +#include + +#include + +#include +#include + +#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 \ No newline at end of file diff --git a/src/skate3_native_ghost_dialog.h b/src/skate3_native_ghost_dialog.h new file mode 100644 index 0000000..f3d2015 --- /dev/null +++ b/src/skate3_native_ghost_dialog.h @@ -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 + +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 \ No newline at end of file diff --git a/src/skate3_native_render.cpp b/src/skate3_native_render.cpp index 29d39f1..33eacfd 100644 --- a/src/skate3_native_render.cpp +++ b/src/skate3_native_render.cpp @@ -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); } } diff --git a/src/skate3_native_scene_gpu.cpp b/src/skate3_native_scene_gpu.cpp index f2009df..af806b3 100644 --- a/src/skate3_native_scene_gpu.cpp +++ b/src/skate3_native_scene_gpu.cpp @@ -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). diff --git a/tools/ghost_relay_main.cpp b/tools/ghost_relay_main.cpp new file mode 100644 index 0000000..6428e0c --- /dev/null +++ b/tools/ghost_relay_main.cpp @@ -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 +#include +#include +#include +#include + +#include "skate3_native_ghost_relay.h" + +#if !defined(_WIN32) +#include +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; +} \ No newline at end of file