Compare commits
3 commits
f6e0ae87fd
...
7b949a312b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b949a312b | ||
|
|
bfb504db7d | ||
|
|
269fbcee32 |
19 changed files with 2120 additions and 4 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -3,7 +3,9 @@
|
||||||
/build/
|
/build/
|
||||||
/generated/
|
/generated/
|
||||||
/generated.backup-*/
|
/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.
|
# Local game dump and retail media. Users must provide their own copy.
|
||||||
/game/
|
/game/
|
||||||
|
|
|
||||||
2
.gitmodules
vendored
2
.gitmodules
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
[submodule "third_party/rexglue-sdk"]
|
[submodule "third_party/rexglue-sdk"]
|
||||||
path = third_party/rexglue-sdk
|
path = third_party/rexglue-sdk
|
||||||
url = https://github.com/mchughalex/rexglue-skate3.git
|
url = git@git.mithraic.cloud:ad3laid3/rexglue-skate3.git
|
||||||
branch = skate3-sdk-clean
|
branch = skate3-sdk-clean
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -248,6 +248,9 @@ set(SKATE3_COMMON_SOURCES
|
||||||
src/skate3_iso_installer.cpp
|
src/skate3_iso_installer.cpp
|
||||||
src/native/skate3_native_diagnostics.cpp
|
src/native/skate3_native_diagnostics.cpp
|
||||||
src/native/skate3_native_entity.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_guest_read.cpp
|
||||||
src/native/skate3_native_lw.cpp
|
src/native/skate3_native_lw.cpp
|
||||||
src/native/skate3_native_palette.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)
|
add_dependencies(skate3 skate3_stage_title_update_patches)
|
||||||
endif()
|
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)
|
if(MSVC)
|
||||||
target_compile_options(skate3 PRIVATE /utf-8)
|
target_compile_options(skate3 PRIVATE /utf-8)
|
||||||
else()
|
else()
|
||||||
|
|
|
||||||
835
src/native/skate3_native_ghost.cpp
Normal file
835
src/native/skate3_native_ghost.cpp
Normal file
|
|
@ -0,0 +1,835 @@
|
||||||
|
#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 "skate3_native_scene.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, 15.0, "Skate 3",
|
||||||
|
"Ghost pose broadcast rate in Hz (a full skeleton pose "
|
||||||
|
"is a few KB; 15 Hz is the LAN/internet sweet spot).")
|
||||||
|
.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;
|
||||||
|
|
||||||
|
// Local player's skater skeleton (captured per built frame from the body
|
||||||
|
// item), identity-padded to kGhostMaxBones rows so the wire pose always has
|
||||||
|
// a full, index-ordered palette. g_local_pose_count = live rows.
|
||||||
|
bool g_local_pose_valid = false;
|
||||||
|
uint16_t g_local_pose_count = 0;
|
||||||
|
float g_local_pose[kGhostMaxBones * 12] = {};
|
||||||
|
double g_local_pose_time = 0.0;
|
||||||
|
|
||||||
|
// Remote ghosts, keyed by the sender's session id.
|
||||||
|
struct Ghost {
|
||||||
|
uint32_t session_id = 0;
|
||||||
|
float world[16] = {};
|
||||||
|
uint16_t bone_count = 0; // live skeleton rows (0 = no pose)
|
||||||
|
float bones[kGhostMaxBones * 12] = {};
|
||||||
|
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];
|
||||||
|
uint16_t pose_rows = 0;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(g_mu);
|
||||||
|
if (!g_local_valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::memcpy(world, g_local_world, sizeof(world));
|
||||||
|
pose_rows = g_local_pose_count;
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
if (pose_rows != 0) {
|
||||||
|
std::lock_guard<std::mutex> lock(g_mu);
|
||||||
|
std::memcpy(pp.bones, g_local_pose, size_t(pose_rows) * 12 * sizeof(float));
|
||||||
|
}
|
||||||
|
pp.bone_count = pose_rows;
|
||||||
|
pp.flags = pose_rows != 0 ? kPoseFlagsBones : 0;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
const uint16_t rows =
|
||||||
|
(pp.flags & kPoseFlagsBones) != 0
|
||||||
|
? std::min<uint16_t>(pp.bone_count, kGhostMaxBones)
|
||||||
|
: 0;
|
||||||
|
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.bone_count = rows;
|
||||||
|
if (rows != 0) {
|
||||||
|
std::memcpy(gh.bones, pp.bones, size_t(rows) * 12 * sizeof(float));
|
||||||
|
}
|
||||||
|
gh.last_seen = now;
|
||||||
|
} else if (g_ghosts.size() < 16) {
|
||||||
|
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.bone_count = rows;
|
||||||
|
if (rows != 0) {
|
||||||
|
std::memcpy(gh.bones, pp.bones, size_t(rows) * 12 * sizeof(float));
|
||||||
|
}
|
||||||
|
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{};
|
||||||
|
g_local_pose_valid = false;
|
||||||
|
g_local_pose_count = 0;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Identity rows for a bone (column-vector affine [R|t]): the safe padding
|
||||||
|
// for skeleton rows the sender did not capture (unreferenced or
|
||||||
|
// off-skeleton bones render at bind/identity rather than garbage).
|
||||||
|
void IdentityBoneRows(float* out) {
|
||||||
|
const float kI[12] = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0};
|
||||||
|
std::memcpy(out, kI, sizeof(kI));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable, bright, per-session marker color.
|
||||||
|
void GhostColor(uint32_t session_id, float color[3]) {
|
||||||
|
const uint32_t h = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void OnFrameSceneBuilt(uint8_t* base,
|
||||||
|
const skate3::native_scene::FrameScene& scene) {
|
||||||
|
(void)base;
|
||||||
|
if (!Enabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Identify the local player's body item: the highest-bone-count skinned
|
||||||
|
// non-ropa, non-LivingWorld character item near the camera (the same
|
||||||
|
// heuristic the renderer's char_track uses). Its bone palette is the
|
||||||
|
// index-ordered skater skeleton this client broadcasts.
|
||||||
|
const skate3::native_scene::DrawItem* body = nullptr;
|
||||||
|
uint32_t best_bones = 0;
|
||||||
|
for (const skate3::native_scene::DrawItem& it : scene.items) {
|
||||||
|
if (!(it.skinned && it.bones.size() >= 12) || it.ropa || it.lw_entity != 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (it.char_family < 1 || it.char_family > 5) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const float px = it.bones[3], py = it.bones[7], pz = it.bones[11];
|
||||||
|
const float dx = px - scene.cam_pos[0], dy = py - scene.cam_pos[1],
|
||||||
|
dz = pz - scene.cam_pos[2];
|
||||||
|
if (dx * dx + dy * dy + dz * dz > 25.0f) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const uint32_t nb = uint32_t(it.bones.size() / 12);
|
||||||
|
if (nb > best_bones) {
|
||||||
|
best_bones = nb;
|
||||||
|
body = ⁢
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body == nullptr || best_bones == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uint16_t rows = uint16_t(std::min<uint32_t>(best_bones, kGhostMaxBones));
|
||||||
|
std::lock_guard<std::mutex> lock(g_mu);
|
||||||
|
std::memcpy(g_local_pose, body->bones.data(), size_t(rows) * 12 * sizeof(float));
|
||||||
|
for (uint16_t r = rows; r < kGhostMaxBones; ++r) {
|
||||||
|
IdentityBoneRows(g_local_pose + size_t(r) * 12);
|
||||||
|
}
|
||||||
|
g_local_pose_count = rows;
|
||||||
|
g_local_pose_valid = true;
|
||||||
|
g_local_pose_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);
|
||||||
|
GhostColor(gh.session_id, color);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GetGhostPose(uint32_t index, float world[16], float color[3],
|
||||||
|
uint16_t max_bones, float* bones, uint16_t* bone_count) {
|
||||||
|
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);
|
||||||
|
GhostColor(gh.session_id, color);
|
||||||
|
const uint16_t rows = std::min<uint16_t>(gh.bone_count, max_bones);
|
||||||
|
*bone_count = rows;
|
||||||
|
if (rows != 0) {
|
||||||
|
std::memcpy(bones, gh.bones, size_t(rows) * 12 * sizeof(float));
|
||||||
|
}
|
||||||
|
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
|
||||||
86
src/native/skate3_native_ghost.h
Normal file
86
src/native/skate3_native_ghost.h
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Ghost multiplayer module (bolt-on, hand-written, confined to src/native):
|
||||||
|
// a from-scratch "ghost player" layer that relays each player's world
|
||||||
|
// transform (position + rotation) through a small UDP relay so other players
|
||||||
|
// see a placeholder marker at the same spot. It deliberately does NOT touch
|
||||||
|
// the game's own netcode, animation, physics or any codegen'd logic.
|
||||||
|
//
|
||||||
|
// Topology (automatic, zero client-side setup):
|
||||||
|
// 1. Every client connects OUTBOUND to the default public relay
|
||||||
|
// (kGhostDefaultServer / skate3_ghost_server cvar) and joins a room
|
||||||
|
// code. No port forwarding on any client.
|
||||||
|
// 2. If the public relay is unreachable, the game automatically becomes a
|
||||||
|
// LAN host (GhostRelay in-process) and LAN peers auto-discover it via
|
||||||
|
// UDP broadcast PING/HELLO.
|
||||||
|
//
|
||||||
|
// Local-player capture: at Sk8::UpdateBoneTransforms exit (the documented
|
||||||
|
// player-only hook) the pack-owner entity is the player's SkaterPresEntity;
|
||||||
|
// its verified world matrix lives at entity+416 (m_MatLtoWTrans). The
|
||||||
|
// capture runs on the sim thread and copies HOST floats under a mutex; the
|
||||||
|
// network thread never reads guest memory.
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "skate3_native_ghost_proto.h"
|
||||||
|
|
||||||
|
namespace skate3::native_scene {
|
||||||
|
struct FrameScene;
|
||||||
|
} // namespace skate3::native_scene
|
||||||
|
|
||||||
|
namespace skate3::native_ghost {
|
||||||
|
|
||||||
|
// ---- host-side capture (called from skate3_native_render.cpp) -------------
|
||||||
|
void OnLocalSkaterTick(uint8_t* base);
|
||||||
|
|
||||||
|
// ---- host-side pose capture (called from BuildFrameScene pre-publish) -----
|
||||||
|
// Snapshot the local player's skater skeleton (the highest-bone-count
|
||||||
|
// skinned non-ropa character item near the camera) as the pose this client
|
||||||
|
// broadcasts. Called every built frame; gated internally on the enable cvar.
|
||||||
|
void OnFrameSceneBuilt(uint8_t* base, const skate3::native_scene::FrameScene& scene);
|
||||||
|
|
||||||
|
// ---- 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]);
|
||||||
|
// Full-pose variant: additionally copies up to max_bones 12-float skeleton
|
||||||
|
// rows (the exact layout DrawItem::bones uses) so the renderer can drive a
|
||||||
|
// real character mesh. bone_count receives the copied row count (0 = the
|
||||||
|
// sender had no pose available; the caller should use the cube fallback).
|
||||||
|
bool GetGhostPose(uint32_t index, float world[16], float color[3],
|
||||||
|
uint16_t max_bones, float* bones, uint16_t* bone_count);
|
||||||
|
// 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
|
||||||
219
src/native/skate3_native_ghost_net.h
Normal file
219
src/native/skate3_native_ghost_net.h
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Minimal UDP socket shim shared by the ghost relay (standalone binary +
|
||||||
|
// in-game host mode) and the in-game client. Dependency-free: plain
|
||||||
|
// POSIX sockets on Linux/macOS, Winsock on Windows. The rexglue SDK's own
|
||||||
|
// rex::net is only close/ioctl and its XSocket is the dead Xbox Live
|
||||||
|
// emulation, which the ghost feature deliberately avoids.
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#if defined(_WIN32)
|
||||||
|
#include <winsock2.h>
|
||||||
|
#include <ws2tcpip.h>
|
||||||
|
#pragma comment(lib, "ws2_32.lib")
|
||||||
|
using GhostSock = SOCKET;
|
||||||
|
#define GHOST_INVALID_SOCKET INVALID_SOCKET
|
||||||
|
#else
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <cerrno>
|
||||||
|
#include <netdb.h>
|
||||||
|
#include <netinet/in.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
using GhostSock = int;
|
||||||
|
#define GHOST_INVALID_SOCKET (-1)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace skate3::native_ghost {
|
||||||
|
|
||||||
|
struct GhostAddr {
|
||||||
|
sockaddr_storage ss = {};
|
||||||
|
socklen_t len = 0;
|
||||||
|
bool valid() const { return len != 0; }
|
||||||
|
};
|
||||||
|
|
||||||
|
inline void GhostSockInit() {
|
||||||
|
#if defined(_WIN32)
|
||||||
|
static bool s_init = [] {
|
||||||
|
WSADATA wsa = {};
|
||||||
|
WSAStartup(MAKEWORD(2, 2), &wsa);
|
||||||
|
return true;
|
||||||
|
}();
|
||||||
|
(void)s_init;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool GhostSockClose(GhostSock s) {
|
||||||
|
if (s == GHOST_INVALID_SOCKET) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
#if defined(_WIN32)
|
||||||
|
closesocket(s);
|
||||||
|
#else
|
||||||
|
::close(s);
|
||||||
|
#endif
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline GhostSock GhostSocketCreate(bool allow_broadcast) {
|
||||||
|
GhostSockInit();
|
||||||
|
GhostSock s = ::socket(AF_INET, SOCK_DGRAM, 0);
|
||||||
|
if (s == GHOST_INVALID_SOCKET) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
int one = 1;
|
||||||
|
::setsockopt(s, SOL_SOCKET, SO_REUSEADDR,
|
||||||
|
reinterpret_cast<const char*>(&one), sizeof(one));
|
||||||
|
if (allow_broadcast) {
|
||||||
|
::setsockopt(s, SOL_SOCKET, SO_BROADCAST,
|
||||||
|
reinterpret_cast<const char*>(&one), sizeof(one));
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool GhostSockBind(GhostSock s, uint16_t port) {
|
||||||
|
sockaddr_in sin = {};
|
||||||
|
sin.sin_family = AF_INET;
|
||||||
|
sin.sin_addr.s_addr = htonl(INADDR_ANY);
|
||||||
|
sin.sin_port = htons(port);
|
||||||
|
return ::bind(s, reinterpret_cast<const sockaddr*>(&sin), sizeof(sin)) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse "host[:port]" (IP or hostname; port optional, defaults to
|
||||||
|
// default_port). Returns false on resolve failure.
|
||||||
|
inline bool GhostResolve(const std::string& hostport, uint16_t default_port,
|
||||||
|
GhostAddr* out) {
|
||||||
|
std::string host = hostport;
|
||||||
|
uint16_t port = default_port;
|
||||||
|
const size_t colon = hostport.rfind(':');
|
||||||
|
if (colon != std::string::npos) {
|
||||||
|
host = hostport.substr(0, colon);
|
||||||
|
const std::string port_str = hostport.substr(colon + 1);
|
||||||
|
if (!port_str.empty()) {
|
||||||
|
port = uint16_t(std::stoi(port_str));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (host.empty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
addrinfo hints = {};
|
||||||
|
hints.ai_family = AF_INET;
|
||||||
|
hints.ai_socktype = SOCK_DGRAM;
|
||||||
|
hints.ai_protocol = IPPROTO_UDP;
|
||||||
|
addrinfo* res = nullptr;
|
||||||
|
const std::string port_str = std::to_string(port);
|
||||||
|
if (getaddrinfo(host.c_str(), port_str.c_str(), &hints, &res) != 0 ||
|
||||||
|
res == nullptr) {
|
||||||
|
if (res != nullptr) {
|
||||||
|
freeaddrinfo(res);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sockaddr_in* sin = reinterpret_cast<sockaddr_in*>(res->ai_addr);
|
||||||
|
sin->sin_port = htons(port);
|
||||||
|
std::memcpy(&out->ss, res->ai_addr, res->ai_addrlen);
|
||||||
|
out->len = socklen_t(res->ai_addrlen);
|
||||||
|
freeaddrinfo(res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loopback relay feed (in-game host mode forwards the host's own pose to
|
||||||
|
// LAN members).
|
||||||
|
inline GhostAddr GhostAddrLoopback(uint16_t port) {
|
||||||
|
GhostAddr a;
|
||||||
|
sockaddr_in sin = {};
|
||||||
|
sin.sin_family = AF_INET;
|
||||||
|
sin.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||||
|
sin.sin_port = htons(port);
|
||||||
|
std::memcpy(&a.ss, &sin, sizeof(sin));
|
||||||
|
a.len = sizeof(sin);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LAN broadcast address for a port.
|
||||||
|
inline GhostAddr GhostAddrBroadcast(uint16_t port) {
|
||||||
|
GhostAddr a;
|
||||||
|
sockaddr_in sin = {};
|
||||||
|
sin.sin_family = AF_INET;
|
||||||
|
sin.sin_addr.s_addr = htonl(INADDR_BROADCAST);
|
||||||
|
sin.sin_port = htons(port);
|
||||||
|
std::memcpy(&a.ss, &sin, sizeof(sin));
|
||||||
|
a.len = sizeof(sin);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline uint16_t GhostAddrPort(const GhostAddr& a) {
|
||||||
|
const sockaddr_in* sin = reinterpret_cast<const sockaddr_in*>(&a.ss);
|
||||||
|
return ntohs(sin->sin_port);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool GhostAddrEqual(const GhostAddr& a, const GhostAddr& b) {
|
||||||
|
return a.len == b.len && std::memcmp(&a.ss, &b.ss, a.len) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline std::string GhostAddrStr(const GhostAddr& a) {
|
||||||
|
const sockaddr_in* sin = reinterpret_cast<const sockaddr_in*>(&a.ss);
|
||||||
|
char buf[INET_ADDRSTRLEN] = {};
|
||||||
|
inet_ntop(AF_INET, &sin->sin_addr, buf, sizeof(buf));
|
||||||
|
return std::string(buf) + ":" + std::to_string(ntohs(sin->sin_port));
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool GhostSockSendTo(GhostSock s, const GhostAddr& to, const void* data,
|
||||||
|
int size) {
|
||||||
|
#if defined(_WIN32)
|
||||||
|
const int n = ::sendto(s, reinterpret_cast<const char*>(data), size, 0,
|
||||||
|
reinterpret_cast<const sockaddr*>(&to.ss), int(to.len));
|
||||||
|
#else
|
||||||
|
const int n = ::sendto(s, data, size, 0,
|
||||||
|
reinterpret_cast<const sockaddr*>(&to.ss), to.len);
|
||||||
|
#endif
|
||||||
|
return n == size;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline int GhostSockRecvFrom(GhostSock s, GhostAddr* from, void* data, int size) {
|
||||||
|
#if defined(_WIN32)
|
||||||
|
int addr_len = int(sizeof(from->ss));
|
||||||
|
const int n = ::recvfrom(s, reinterpret_cast<char*>(data), size, 0,
|
||||||
|
reinterpret_cast<sockaddr*>(&from->ss), &addr_len);
|
||||||
|
from->len = n >= 0 ? socklen_t(addr_len) : 0;
|
||||||
|
#else
|
||||||
|
socklen_t addr_len = sizeof(from->ss);
|
||||||
|
const int n = ::recvfrom(s, data, size, 0,
|
||||||
|
reinterpret_cast<sockaddr*>(&from->ss), &addr_len);
|
||||||
|
from->len = n >= 0 ? addr_len : 0;
|
||||||
|
#endif
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool GhostSockSetRecvTimeout(GhostSock s, int timeout_ms) {
|
||||||
|
#if defined(_WIN32)
|
||||||
|
DWORD tv = DWORD(timeout_ms);
|
||||||
|
#else
|
||||||
|
timeval tv = {};
|
||||||
|
tv.tv_sec = timeout_ms / 1000;
|
||||||
|
tv.tv_usec = (timeout_ms % 1000) * 1000;
|
||||||
|
#endif
|
||||||
|
return ::setsockopt(s, SOL_SOCKET, SO_RCVTIMEO,
|
||||||
|
reinterpret_cast<const char*>(&tv), sizeof(tv)) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline int GhostSockLastError() {
|
||||||
|
#if defined(_WIN32)
|
||||||
|
return WSAGetLastError();
|
||||||
|
#else
|
||||||
|
return errno;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool GhostSockWouldBlock(int err) {
|
||||||
|
#if defined(_WIN32)
|
||||||
|
return err == WSAEWOULDBLOCK || err == WSAETIMEDOUT;
|
||||||
|
#else
|
||||||
|
return err == EAGAIN || err == EWOULDBLOCK;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace skate3::native_ghost
|
||||||
152
src/native/skate3_native_ghost_proto.h
Normal file
152
src/native/skate3_native_ghost_proto.h
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Ghost multiplayer wire protocol (shared by the in-game client, the
|
||||||
|
// in-game host mode and the standalone ghost_relay binary).
|
||||||
|
//
|
||||||
|
// Topology: every client connects OUTBOUND (UDP) to a relay; the relay
|
||||||
|
// forwards each client's pose packet to the other members of the same room.
|
||||||
|
// No client needs inbound reachability (no port forwarding). The relay is
|
||||||
|
// either a public VPS server (ghost_relay) or a player's game in host mode
|
||||||
|
// (the GhostRelay class below), reachable directly on a LAN or via a free
|
||||||
|
// tunnel on the internet.
|
||||||
|
//
|
||||||
|
// All multi-byte fields are little-endian (all supported hosts are
|
||||||
|
// little-endian; a struct with explicit layout is serialized with memcpy).
|
||||||
|
// A packet is PacketHeader followed by a fixed per-type payload. PacketBuf
|
||||||
|
// is sized for the largest payload.
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
namespace skate3::native_ghost {
|
||||||
|
|
||||||
|
constexpr uint32_t kGhostMagic = 0x54484753u; // "GHST" (LE)
|
||||||
|
constexpr uint8_t kGhostVersion = 1;
|
||||||
|
constexpr uint16_t kGhostDefaultPort = 40001;
|
||||||
|
constexpr uint32_t kGhostMaxRoomLen = 8;
|
||||||
|
constexpr uint32_t kGhostMaxRoster = 32;
|
||||||
|
// Max skeleton rows carried in a POSE (3 float4s per bone). The Skate 3
|
||||||
|
// skater skeleton fits comfortably under this; rows beyond the sender's
|
||||||
|
// captured count are identity-padded so every mesh's referenced bones stay
|
||||||
|
// in range on the receiving side.
|
||||||
|
constexpr uint32_t kGhostMaxBones = 64;
|
||||||
|
// 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];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Full-pose packet (the Phase 2 character-model path): the sender's skater
|
||||||
|
// skeleton, 3 float4 rows per bone (column-vector affine [R | t], model ->
|
||||||
|
// world, row k = bone k exactly as the scene renderer's bone ring expects).
|
||||||
|
// Flags bit 0 = bones[] valid. pos/rot are kept from the entity world for
|
||||||
|
// the cube fallback / diagnostics.
|
||||||
|
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 (entity world, row 3)
|
||||||
|
float rot[9]; // world rotation, row-vector 3x3 (matches entity+416)
|
||||||
|
uint16_t bone_count; // rows in bones[] (0 = pose unavailable)
|
||||||
|
uint8_t flags; // bit0 = bones[] valid
|
||||||
|
uint8_t reserved;
|
||||||
|
float bones[kGhostMaxBones * 12];
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr uint8_t kPoseFlagsBones = 1;
|
||||||
|
|
||||||
|
// Largest payload: PosePayload (32 + 3072 = 3104 bytes).
|
||||||
|
constexpr uint16_t kGhostMaxPayload = 3200;
|
||||||
|
|
||||||
|
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
|
||||||
235
src/native/skate3_native_ghost_relay.cpp
Normal file
235
src/native/skate3_native_ghost_relay.cpp
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
#include "skate3_native_ghost_relay.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
namespace skate3::native_ghost {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr double kClientTimeoutSec = 8.0;
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
GhostRelay::~GhostRelay() {
|
||||||
|
Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GhostRelay::Start() {
|
||||||
|
if (running_.load(std::memory_order_relaxed)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
sock_ = GhostSocketCreate(/*allow_broadcast=*/true);
|
||||||
|
if (sock_ == GHOST_INVALID_SOCKET) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!GhostSockBind(sock_, port_)) {
|
||||||
|
GhostSockClose(sock_);
|
||||||
|
sock_ = GHOST_INVALID_SOCKET;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
GhostSockSetRecvTimeout(sock_, 200);
|
||||||
|
running_.store(true, std::memory_order_relaxed);
|
||||||
|
stop_.store(false, std::memory_order_relaxed);
|
||||||
|
thread_ = std::thread(&GhostRelay::Run, this);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GhostRelay::Stop() {
|
||||||
|
if (!running_.load(std::memory_order_relaxed)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stop_.store(true, std::memory_order_relaxed);
|
||||||
|
if (thread_.joinable()) {
|
||||||
|
thread_.join();
|
||||||
|
}
|
||||||
|
if (sock_ != GHOST_INVALID_SOCKET) {
|
||||||
|
GhostSockClose(sock_);
|
||||||
|
sock_ = GHOST_INVALID_SOCKET;
|
||||||
|
}
|
||||||
|
running_.store(false, std::memory_order_relaxed);
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
clients_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t GhostRelay::ClientCount() const {
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
return clients_.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
void GhostRelay::SetLockedRoom(const char* room) {
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
RoomSet(locked_room_, locked_room_len_, room);
|
||||||
|
locked_ = locked_room_len_ != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GhostRelay::Run() {
|
||||||
|
while (!stop_.load(std::memory_order_relaxed)) {
|
||||||
|
GhostAddr from;
|
||||||
|
PacketBuf p;
|
||||||
|
const int n = GhostSockRecvFrom(sock_, &from, &p, sizeof(p));
|
||||||
|
if (n <= 0) {
|
||||||
|
Sweep(0.0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (n < static_cast<int>(sizeof(PacketHeader))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p.h.magic != kGhostMagic) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const uint16_t expect = static_cast<uint16_t>(n - sizeof(PacketHeader));
|
||||||
|
if (p.h.payload_size != expect ||
|
||||||
|
p.h.payload_size > kGhostMaxPayload) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const double now = double(std::chrono::steady_clock::now().time_since_epoch().count()) /
|
||||||
|
1e9;
|
||||||
|
HandlePacket(from, p, now);
|
||||||
|
Sweep(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p,
|
||||||
|
double now) {
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
|
||||||
|
// Resolve (or create) the sender's client record.
|
||||||
|
Client* me = nullptr;
|
||||||
|
for (Client& c : clients_) {
|
||||||
|
if (GhostAddrEqual(c.addr, from)) {
|
||||||
|
me = &c;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (me == nullptr) {
|
||||||
|
clients_.push_back(Client{});
|
||||||
|
me = &clients_.back();
|
||||||
|
me->addr = from;
|
||||||
|
me->session_id = next_session_++;
|
||||||
|
RoomClear(me->room, me->room_len);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto send = [this, &from](const PacketBuf& out) {
|
||||||
|
GhostSockSendTo(sock_, from, &out, PacketSize(out));
|
||||||
|
};
|
||||||
|
PacketBuf out = {};
|
||||||
|
|
||||||
|
switch (p.h.type) {
|
||||||
|
case kTypeJoin:
|
||||||
|
case kTypePing: {
|
||||||
|
JoinPayload jp = {};
|
||||||
|
std::memcpy(&jp, p.payload, sizeof(jp));
|
||||||
|
if (locked_) {
|
||||||
|
std::memcpy(me->room, locked_room_, locked_room_len_);
|
||||||
|
me->room_len = locked_room_len_;
|
||||||
|
} else if (RoomValid(jp.room, jp.room_len)) {
|
||||||
|
std::memcpy(me->room, jp.room, jp.room_len);
|
||||||
|
me->room_len = jp.room_len;
|
||||||
|
}
|
||||||
|
// Empty room: assign one (uppercase alnum, 4 chars, seeded by the
|
||||||
|
// session id and the wall of the receiving thread).
|
||||||
|
if (me->room_len == 0) {
|
||||||
|
uint32_t state = (uint32_t(now * 1e3) * 2654435761u) ^
|
||||||
|
(me->session_id * 40503u) ^ 0x9E3779B9u;
|
||||||
|
char room[kGhostMaxRoomLen] = {};
|
||||||
|
for (uint8_t i = 0; i < 4; ++i) {
|
||||||
|
state = state * 1664525u + 1013904223u;
|
||||||
|
room[i] = (state & 1u) != 0
|
||||||
|
? static_cast<char>('A' + (state >> 1) % 26u)
|
||||||
|
: static_cast<char>('0' + (state >> 2) % 10u);
|
||||||
|
}
|
||||||
|
std::memcpy(me->room, room, 4);
|
||||||
|
me->room_len = 4;
|
||||||
|
}
|
||||||
|
me->last_seen = now;
|
||||||
|
if (p.h.type == kTypeJoin) {
|
||||||
|
// HELLO: our session id, room and the roster of the OTHER members.
|
||||||
|
HelloPayload hp = {};
|
||||||
|
hp.session_id = me->session_id;
|
||||||
|
hp.room_len = me->room_len;
|
||||||
|
std::memcpy(hp.room, me->room, me->room_len);
|
||||||
|
for (const Client& c : clients_) {
|
||||||
|
if (&c != me && c.room_len == me->room_len &&
|
||||||
|
RoomEquals(c.room, c.room_len, me->room, me->room_len) &&
|
||||||
|
hp.roster_count < kGhostMaxRoster) {
|
||||||
|
hp.roster[hp.roster_count++] = c.session_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BuildPacket(out, kTypeHello, &hp, static_cast<uint16_t>(sizeof(hp)));
|
||||||
|
send(out);
|
||||||
|
} else {
|
||||||
|
// PING probe: HELLO with the host's room/roster so probing clients
|
||||||
|
// can decide whether to join this LAN host.
|
||||||
|
HelloPayload hp = {};
|
||||||
|
hp.session_id = me->session_id;
|
||||||
|
hp.room_len = me->room_len;
|
||||||
|
std::memcpy(hp.room, me->room, me->room_len);
|
||||||
|
for (const Client& c : clients_) {
|
||||||
|
if (&c != me && c.room_len == me->room_len &&
|
||||||
|
RoomEquals(c.room, c.room_len, me->room, me->room_len) &&
|
||||||
|
hp.roster_count < kGhostMaxRoster) {
|
||||||
|
hp.roster[hp.roster_count++] = c.session_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BuildPacket(out, kTypeHello, &hp, static_cast<uint16_t>(sizeof(hp)));
|
||||||
|
send(out);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case kTypePose: {
|
||||||
|
// Clients join before posing; a pose with no room is auto-joined to
|
||||||
|
// the lobby so the relay stays resilient to a missed HELLO.
|
||||||
|
if (me->room_len == 0) {
|
||||||
|
RoomSet(me->room, me->room_len, kGhostLobbyRoom);
|
||||||
|
}
|
||||||
|
me->last_seen = now;
|
||||||
|
// Forward the payload VERBATIM to the other room members, re-stamping
|
||||||
|
// only the sender session id at payload offset 0 (little-endian). The
|
||||||
|
// relay never parses the pose body, so the protocol can grow (e.g. the
|
||||||
|
// full skeleton palette) without a relay update.
|
||||||
|
PacketBuf out = p;
|
||||||
|
out.h.magic = kGhostMagic;
|
||||||
|
out.h.type = kTypePose;
|
||||||
|
std::memcpy(out.payload, &me->session_id, sizeof(me->session_id));
|
||||||
|
const uint16_t size = PacketSize(out);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
GhostSockSendTo(sock_, c.addr, &out, size);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case kTypeLeave: {
|
||||||
|
clients_.erase(std::remove_if(clients_.begin(), clients_.end(),
|
||||||
|
[&from](const Client& c) {
|
||||||
|
return GhostAddrEqual(c.addr, from);
|
||||||
|
}),
|
||||||
|
clients_.end());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break; // PONG etc. are client-side only; ignore at the relay.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void GhostRelay::Sweep(double now) {
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
if (now <= 0.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clients_.erase(std::remove_if(clients_.begin(), clients_.end(),
|
||||||
|
[now](const Client& c) {
|
||||||
|
return now - c.last_seen > kClientTimeoutSec;
|
||||||
|
}),
|
||||||
|
clients_.end());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace skate3::native_ghost
|
||||||
72
src/native/skate3_native_ghost_relay.h
Normal file
72
src/native/skate3_native_ghost_relay.h
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Ghost multiplayer relay core: a small UDP forwarder shared by the
|
||||||
|
// standalone ghost_relay binary (run on a public VPS) and the in-game host
|
||||||
|
// mode (a player's game relays its LAN room). Clients connect OUTBOUND only
|
||||||
|
// and are matched into rooms by a shareable code. The relay forwards each
|
||||||
|
// client's PosePayload to every OTHER member of the same room.
|
||||||
|
//
|
||||||
|
// Clients are keyed by source address; every packet refreshes last_seen and
|
||||||
|
// silent clients are dropped (their ghosts vanish on the other side). Room
|
||||||
|
// codes are uppercase alphanumeric; a JOIN with an empty room gets one
|
||||||
|
// assigned by the server and returned in the HELLO.
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "skate3_native_ghost_net.h"
|
||||||
|
#include "skate3_native_ghost_proto.h"
|
||||||
|
|
||||||
|
namespace skate3::native_ghost {
|
||||||
|
|
||||||
|
class GhostRelay {
|
||||||
|
public:
|
||||||
|
explicit GhostRelay(uint16_t port = kGhostDefaultPort) : port_(port) {}
|
||||||
|
~GhostRelay();
|
||||||
|
GhostRelay(const GhostRelay&) = delete;
|
||||||
|
GhostRelay& operator=(const GhostRelay&) = delete;
|
||||||
|
|
||||||
|
// Bind the socket and start the receive thread. Returns false when the
|
||||||
|
// port cannot be bound (already in use / no permission).
|
||||||
|
bool Start();
|
||||||
|
void Stop();
|
||||||
|
|
||||||
|
bool running() const { return running_.load(std::memory_order_relaxed); }
|
||||||
|
uint16_t port() const { return port_; }
|
||||||
|
size_t ClientCount() const;
|
||||||
|
|
||||||
|
// When non-empty, every client is forced into this room (the standalone
|
||||||
|
// binary's --room lock); client JOINs and PINGs cannot override it.
|
||||||
|
void SetLockedRoom(const char* room);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void Run();
|
||||||
|
void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now);
|
||||||
|
void Sweep(double now);
|
||||||
|
|
||||||
|
GhostSock sock_ = GHOST_INVALID_SOCKET;
|
||||||
|
uint16_t port_;
|
||||||
|
std::thread thread_;
|
||||||
|
std::atomic<bool> running_{false};
|
||||||
|
std::atomic<bool> stop_{false};
|
||||||
|
|
||||||
|
struct Client {
|
||||||
|
GhostAddr addr;
|
||||||
|
uint32_t session_id = 0;
|
||||||
|
char room[kGhostMaxRoomLen] = {};
|
||||||
|
uint8_t room_len = 0;
|
||||||
|
double last_seen = 0.0;
|
||||||
|
};
|
||||||
|
mutable std::mutex mu_;
|
||||||
|
std::vector<Client> clients_;
|
||||||
|
uint32_t next_session_ = 1;
|
||||||
|
bool locked_ = false;
|
||||||
|
char locked_room_[kGhostMaxRoomLen] = {};
|
||||||
|
uint8_t locked_room_len_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace skate3::native_ghost
|
||||||
|
|
@ -889,6 +889,10 @@ uint32_t ExchangePackOwner(uint32_t entity) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uint32_t CurrentPackOwner() {
|
||||||
|
return tl_pack_owner;
|
||||||
|
}
|
||||||
|
|
||||||
void OnPackPalette(uint8_t* base, uint32_t instance) {
|
void OnPackPalette(uint8_t* base, uint32_t instance) {
|
||||||
if (instance == 0 || !CaptureActive()) {
|
if (instance == 0 || !CaptureActive()) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,11 @@ void OnLwPack(uint8_t* base, uint32_t entity);
|
||||||
// the bracket carry the owning entity for identity-exact serving.
|
// the bracket carry the owning entity for identity-exact serving.
|
||||||
uint32_t ExchangePackOwner(uint32_t entity);
|
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:
|
// Called from BuildFrameScene while the frame's items are finalized:
|
||||||
// replaces each mapped skinned character item's captured bank palette with
|
// replaces each mapped skinned character item's captured bank palette with
|
||||||
// its instance's own packed rows. Served items get dbg_src=11 and
|
// its instance's own packed rows. Served items get dbg_src=11 and
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@
|
||||||
#include "skate3_title_update_installer.h"
|
#include "skate3_title_update_installer.h"
|
||||||
#include "skate3_user_settings.h"
|
#include "skate3_user_settings.h"
|
||||||
|
|
||||||
|
#include "native/skate3_native_ghost.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
@ -723,6 +725,10 @@ void Skate3BaseApp::OnCreateDialogs(rex::ui::ImGuiDrawer* drawer) {
|
||||||
"Native render debug menu", [this] {
|
"Native render debug menu", [this] {
|
||||||
ToggleNativeDebug();
|
ToggleNativeDebug();
|
||||||
});
|
});
|
||||||
|
rex::ui::RegisterBind("bind_skate3_ghost", "M",
|
||||||
|
"Ghost multiplayer menu", [this] {
|
||||||
|
ToggleGhost();
|
||||||
|
});
|
||||||
rex::ui::RegisterBind("bind_skate3_showcase", "Ctrl+Shift+B",
|
rex::ui::RegisterBind("bind_skate3_showcase", "Ctrl+Shift+B",
|
||||||
"Graphics build-up showcase", [] {
|
"Graphics build-up showcase", [] {
|
||||||
// A capture/recording tool, not a player feature:
|
// A capture/recording tool, not a player feature:
|
||||||
|
|
@ -793,6 +799,8 @@ void Skate3BaseApp::OnPostSetup() {
|
||||||
auto* dispatcher = runtime()->function_dispatcher();
|
auto* dispatcher = runtime()->function_dispatcher();
|
||||||
skate3::native_render::Install();
|
skate3::native_render::Install();
|
||||||
skate3::demo_path::InstallHooks(dispatcher);
|
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
|
// User-facing intro-movie skip (independent of the demo path): the movie
|
||||||
// completion override polls the merged UI pad state through this provider.
|
// completion override polls the merged UI pad state through this provider.
|
||||||
skate3::demo_path::SetUiInputProvider([this]() {
|
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_debug_marker");
|
||||||
rex::ui::UnregisterBind("bind_skate3_log_user_marker");
|
rex::ui::UnregisterBind("bind_skate3_log_user_marker");
|
||||||
rex::ui::UnregisterBind("bind_skate3_native_debug");
|
rex::ui::UnregisterBind("bind_skate3_native_debug");
|
||||||
|
rex::ui::UnregisterBind("bind_skate3_ghost");
|
||||||
|
skate3::native_ghost::Shutdown();
|
||||||
ApplyGameplayCursorMode();
|
ApplyGameplayCursorMode();
|
||||||
skate3::native_scene::SetSettingsMenuBlur(false);
|
skate3::native_scene::SetSettingsMenuBlur(false);
|
||||||
simple_settings_dialog_.reset();
|
simple_settings_dialog_.reset();
|
||||||
native_debug_dialog_.reset();
|
native_debug_dialog_.reset();
|
||||||
|
ghost_dialog_.reset();
|
||||||
render_mode_indicator_.reset();
|
render_mode_indicator_.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -944,6 +955,19 @@ void Skate3BaseApp::ToggleNativeDebug() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Skate3BaseApp::ToggleGhost() {
|
||||||
|
if (!ghost_dialog_) {
|
||||||
|
ghost_dialog_ = std::make_unique<skate3::GhostDialog>(imgui_drawer());
|
||||||
|
}
|
||||||
|
if (ghost_dialog_->visible()) {
|
||||||
|
ghost_dialog_->Hide();
|
||||||
|
ApplyGameplayCursorMode();
|
||||||
|
} else {
|
||||||
|
ApplySettingsCursorMode();
|
||||||
|
ghost_dialog_->Show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void Skate3BaseApp::ApplySettingsCursorMode() {
|
void Skate3BaseApp::ApplySettingsCursorMode() {
|
||||||
if (window()) {
|
if (window()) {
|
||||||
window()->SetCursorVisibility(rex::ui::Window::CursorVisibility::kVisible);
|
window()->SetCursorVisibility(rex::ui::Window::CursorVisibility::kVisible);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
#include <rex/ui/overlay/simple_settings_overlay.h>
|
#include <rex/ui/overlay/simple_settings_overlay.h>
|
||||||
|
|
||||||
#include "skate3_native_debug_dialog.h"
|
#include "skate3_native_debug_dialog.h"
|
||||||
|
#include "skate3_native_ghost_dialog.h"
|
||||||
|
|
||||||
namespace rex::ui {
|
namespace rex::ui {
|
||||||
class ImGuiDrawer;
|
class ImGuiDrawer;
|
||||||
|
|
@ -40,6 +41,7 @@ class Skate3BaseApp : public rex::ReXApp {
|
||||||
void InstallDlcPackages();
|
void InstallDlcPackages();
|
||||||
void ToggleSimpleSettings();
|
void ToggleSimpleSettings();
|
||||||
void ToggleNativeDebug();
|
void ToggleNativeDebug();
|
||||||
|
void ToggleGhost();
|
||||||
void ApplySettingsCursorMode();
|
void ApplySettingsCursorMode();
|
||||||
void ApplyGameplayCursorMode();
|
void ApplyGameplayCursorMode();
|
||||||
void RestartGame();
|
void RestartGame();
|
||||||
|
|
@ -59,6 +61,7 @@ class Skate3BaseApp : public rex::ReXApp {
|
||||||
std::filesystem::path profiles_path_;
|
std::filesystem::path profiles_path_;
|
||||||
std::unique_ptr<rex::ui::SimpleSettingsDialog> simple_settings_dialog_;
|
std::unique_ptr<rex::ui::SimpleSettingsDialog> simple_settings_dialog_;
|
||||||
std::unique_ptr<skate3::NativeDebugDialog> native_debug_dialog_;
|
std::unique_ptr<skate3::NativeDebugDialog> native_debug_dialog_;
|
||||||
|
std::unique_ptr<skate3::GhostDialog> ghost_dialog_;
|
||||||
std::unique_ptr<skate3::RenderModeIndicator> render_mode_indicator_;
|
std::unique_ptr<skate3::RenderModeIndicator> render_mode_indicator_;
|
||||||
bool recipe_overlay_installed_ = false;
|
bool recipe_overlay_installed_ = false;
|
||||||
bool big_device_aliases_installed_ = false;
|
bool big_device_aliases_installed_ = false;
|
||||||
|
|
|
||||||
147
src/skate3_native_ghost_dialog.cpp
Normal file
147
src/skate3_native_ghost_dialog.cpp
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
#include "skate3_native_ghost_dialog.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <imgui.h>
|
||||||
|
|
||||||
|
#include <rex/cvar.h>
|
||||||
|
#include <rex/logging.h>
|
||||||
|
|
||||||
|
#include "native/skate3_native_ghost.h"
|
||||||
|
|
||||||
|
REXCVAR_DECLARE(bool, skate3_ghost_enable);
|
||||||
|
REXCVAR_DECLARE(std::string, skate3_ghost_server);
|
||||||
|
REXCVAR_DECLARE(int32_t, skate3_ghost_port);
|
||||||
|
REXCVAR_DECLARE(std::string, skate3_ghost_room);
|
||||||
|
REXCVAR_DECLARE(double, skate3_ghost_rate);
|
||||||
|
REXCVAR_DECLARE(bool, skate3_ghost_draw_local);
|
||||||
|
|
||||||
|
namespace skate3 {
|
||||||
|
|
||||||
|
void GhostDialog::Show() {
|
||||||
|
visible_ = true;
|
||||||
|
SetDrawActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void GhostDialog::Hide() {
|
||||||
|
if (!visible_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
visible_ = false;
|
||||||
|
SetDrawActive(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void GhostDialog::Toggle() {
|
||||||
|
if (visible_) {
|
||||||
|
Hide();
|
||||||
|
} else {
|
||||||
|
Show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void GhostDialog::SyncBuffersFromCvars() {
|
||||||
|
const std::string room = REXCVAR_GET(skate3_ghost_room);
|
||||||
|
std::snprintf(room_input_, sizeof(room_input_), "%s", room.c_str());
|
||||||
|
const std::string server = REXCVAR_GET(skate3_ghost_server);
|
||||||
|
std::snprintf(server_input_, sizeof(server_input_), "%s", server.c_str());
|
||||||
|
synced_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GhostDialog::OnDraw(ImGuiIO& io) {
|
||||||
|
(void)io;
|
||||||
|
if (!visible_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!synced_) {
|
||||||
|
SyncBuffersFromCvars();
|
||||||
|
}
|
||||||
|
bool open = visible_;
|
||||||
|
ImGui::SetNextWindowSize(ImVec2(380.0f, 0.0f), ImGuiCond_FirstUseEver);
|
||||||
|
if (!ImGui::Begin("Ghost Multiplayer (M)", &open,
|
||||||
|
ImGuiWindowFlags_NoCollapse)) {
|
||||||
|
ImGui::End();
|
||||||
|
if (!open) {
|
||||||
|
Hide();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool enable = REXCVAR_GET(skate3_ghost_enable);
|
||||||
|
if (ImGui::Checkbox("Enable ghost multiplayer", &enable)) {
|
||||||
|
REXCVAR_SET(skate3_ghost_enable, enable);
|
||||||
|
if (enable) {
|
||||||
|
REXLOG_INFO("ghost: multiplayer enabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::TextDisabled("(relays your position, draws remote players)");
|
||||||
|
|
||||||
|
ImGui::Separator();
|
||||||
|
|
||||||
|
ImGui::Text("Room code");
|
||||||
|
if (ImGui::InputText("##ghost_room", room_input_, sizeof(room_input_),
|
||||||
|
ImGuiInputTextFlags_EnterReturnsTrue |
|
||||||
|
ImGuiInputTextFlags_CharsUppercase)) {
|
||||||
|
REXCVAR_SET(skate3_ghost_room, std::string(room_input_));
|
||||||
|
ImGui::SetKeyboardFocusHere(-1);
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ImGui::Button("Copy")) {
|
||||||
|
ImGui::SetClipboardText(REXCVAR_GET(skate3_ghost_room).c_str());
|
||||||
|
}
|
||||||
|
ImGui::TextDisabled("Empty = the server assigns one; share it with friends.");
|
||||||
|
|
||||||
|
ImGui::Separator();
|
||||||
|
|
||||||
|
ImGui::Text("Relay server (optional)");
|
||||||
|
ImGui::PushItemWidth(-1.0f);
|
||||||
|
if (ImGui::InputText("##ghost_server", server_input_, sizeof(server_input_),
|
||||||
|
ImGuiInputTextFlags_EnterReturnsTrue)) {
|
||||||
|
REXCVAR_SET(skate3_ghost_server, std::string(server_input_));
|
||||||
|
ImGui::SetKeyboardFocusHere(-1);
|
||||||
|
}
|
||||||
|
ImGui::PopItemWidth();
|
||||||
|
ImGui::TextDisabled("Empty = built-in default relay. No port forwarding "
|
||||||
|
"needed on any client.");
|
||||||
|
|
||||||
|
ImGui::Separator();
|
||||||
|
|
||||||
|
int port = int(REXCVAR_GET(skate3_ghost_port));
|
||||||
|
if (ImGui::SliderInt("Port", &port, 1024, 65535)) {
|
||||||
|
REXCVAR_SET(skate3_ghost_port, int32_t(port));
|
||||||
|
}
|
||||||
|
const double rate = REXCVAR_GET(skate3_ghost_rate);
|
||||||
|
rate_int_ = int(rate + 0.5);
|
||||||
|
if (ImGui::SliderInt("Broadcast rate (Hz)", &rate_int_, 5, 60)) {
|
||||||
|
REXCVAR_SET(skate3_ghost_rate, double(rate_int_));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool draw_local = REXCVAR_GET(skate3_ghost_draw_local);
|
||||||
|
if (ImGui::Checkbox("Show my own cube (self-check)", &draw_local)) {
|
||||||
|
REXCVAR_SET(skate3_ghost_draw_local, draw_local);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::Separator();
|
||||||
|
|
||||||
|
skate3::native_ghost::Status st;
|
||||||
|
skate3::native_ghost::GetStatus(&st);
|
||||||
|
ImGui::Text("Status:");
|
||||||
|
ImGui::Text(" Enabled: %s", st.enabled ? "yes" : "no");
|
||||||
|
ImGui::Text(" Connected: %s%s", st.connected ? "yes" : "no",
|
||||||
|
st.hosting ? " (LAN host)" : "");
|
||||||
|
ImGui::Text(" Session: %u", st.session_id);
|
||||||
|
ImGui::Text(" Room: %.*s", int(st.room_len), st.room);
|
||||||
|
ImGui::Text(" In room: %zu player(s)", st.roster + 1);
|
||||||
|
ImGui::Text(" Ghosts drawn: %zu", st.ghosts);
|
||||||
|
if (st.peer[0] != '\0') {
|
||||||
|
ImGui::Text(" Relay: %s", st.peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::End();
|
||||||
|
if (!open) {
|
||||||
|
Hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace skate3
|
||||||
34
src/skate3_native_ghost_dialog.h
Normal file
34
src/skate3_native_ghost_dialog.h
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Ghost multiplayer menu: enable toggle, room-code entry, relay override and
|
||||||
|
// a live connection status readout. A player-facing dialog (bound to M by
|
||||||
|
// default in skate3_app_common.cpp); everything it edits is a hot-reload
|
||||||
|
// cvar in skate3_native_ghost.cpp.
|
||||||
|
|
||||||
|
#include <rex/ui/imgui_dialog.h>
|
||||||
|
|
||||||
|
namespace skate3 {
|
||||||
|
|
||||||
|
class GhostDialog final : public rex::ui::ImGuiDialog {
|
||||||
|
public:
|
||||||
|
explicit GhostDialog(rex::ui::ImGuiDrawer* drawer) : ImGuiDialog(drawer) {}
|
||||||
|
|
||||||
|
void Show();
|
||||||
|
void Hide();
|
||||||
|
void Toggle();
|
||||||
|
bool visible() const { return visible_; }
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void OnDraw(ImGuiIO& io) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void SyncBuffersFromCvars();
|
||||||
|
|
||||||
|
bool visible_ = false;
|
||||||
|
bool synced_ = false;
|
||||||
|
char room_input_[16] = {};
|
||||||
|
char server_input_[128] = {};
|
||||||
|
int rate_int_ = 20;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace skate3
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
#include "native/skate3_native_diag.h"
|
#include "native/skate3_native_diag.h"
|
||||||
#include "native/skate3_native_entity.h"
|
#include "native/skate3_native_entity.h"
|
||||||
|
#include "native/skate3_native_ghost.h"
|
||||||
#include "native/skate3_native_guest_read.h"
|
#include "native/skate3_native_guest_read.h"
|
||||||
#include "native/skate3_native_lw.h"
|
#include "native/skate3_native_lw.h"
|
||||||
#include "native/skate3_native_palette.h"
|
#include "native/skate3_native_palette.h"
|
||||||
|
|
@ -472,6 +473,9 @@ extern "C" REX_FUNC(sub_827A52C8) {
|
||||||
__imp__sub_827A52C8(ctx, base);
|
__imp__sub_827A52C8(ctx, base);
|
||||||
if (skate3::native_render::Enabled()) {
|
if (skate3::native_render::Enabled()) {
|
||||||
skate3::native_palette::OnBoneTransforms(base, parts, count);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@
|
||||||
|
|
||||||
#include "native/skate3_native_diag.h"
|
#include "native/skate3_native_diag.h"
|
||||||
#include "native/skate3_native_entity.h"
|
#include "native/skate3_native_entity.h"
|
||||||
|
#include "native/skate3_native_ghost.h"
|
||||||
#include "native/skate3_native_guest_read.h"
|
#include "native/skate3_native_guest_read.h"
|
||||||
#include "native/skate3_native_lw.h"
|
#include "native/skate3_native_lw.h"
|
||||||
#include "native/skate3_native_palette.h"
|
#include "native/skate3_native_palette.h"
|
||||||
|
|
@ -10446,6 +10447,9 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) {
|
||||||
std::chrono::steady_clock::now().time_since_epoch())
|
std::chrono::steady_clock::now().time_since_epoch())
|
||||||
.count(),
|
.count(),
|
||||||
std::memory_order_relaxed);
|
std::memory_order_relaxed);
|
||||||
|
// Ghost multiplayer: snapshot the local player's skeleton from the final
|
||||||
|
// item list (the pose this client broadcasts to the relay).
|
||||||
|
skate3::native_ghost::OnFrameSceneBuilt(base, scene);
|
||||||
std::lock_guard<std::mutex> lock(g_scene_mutex);
|
std::lock_guard<std::mutex> lock(g_scene_mutex);
|
||||||
scene.generation = ++g_generation;
|
scene.generation = ++g_generation;
|
||||||
g_scene = std::make_shared<const FrameScene>(std::move(scene));
|
g_scene = std::make_shared<const FrameScene>(std::move(scene));
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@
|
||||||
|
|
||||||
#include "native/skate3_native_diag.h"
|
#include "native/skate3_native_diag.h"
|
||||||
#include "native/skate3_native_entity.h"
|
#include "native/skate3_native_entity.h"
|
||||||
|
#include "native/skate3_native_ghost.h"
|
||||||
#include "native/skate3_native_guest_read.h"
|
#include "native/skate3_native_guest_read.h"
|
||||||
#include "native/skate3_native_lw.h"
|
#include "native/skate3_native_lw.h"
|
||||||
#include "native/skate3_native_palette.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_transparents);
|
||||||
REXCVAR_DECLARE(bool, skate3_native_render_scene_world_v2);
|
REXCVAR_DECLARE(bool, skate3_native_render_scene_world_v2);
|
||||||
REXCVAR_DECLARE(double, skate3_menu_blur_sigma);
|
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_2d_sharp);
|
||||||
REXCVAR_DECLARE(double, skate3_native_render_scene_bloom_intensity);
|
REXCVAR_DECLARE(double, skate3_native_render_scene_bloom_intensity);
|
||||||
REXCVAR_DECLARE(double, skate3_native_render_scene_bloom_knee);
|
REXCVAR_DECLARE(double, skate3_native_render_scene_bloom_knee);
|
||||||
|
|
@ -10339,6 +10342,178 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
|
||||||
uint32_t(frame_number % RendererState::kUiRegions) * RendererState::kUiRegionSize;
|
uint32_t(frame_number % RendererState::kUiRegions) * RendererState::kUiRegionSize;
|
||||||
uint32_t ui_offset = 0;
|
uint32_t ui_offset = 0;
|
||||||
|
|
||||||
|
// ---- Ghost multiplayer (native/skate3_native_ghost) ---------------------
|
||||||
|
// Full-character ghosts: each remote player's skater skeleton (broadcast
|
||||||
|
// in the POSE packets) drives a CLONE of this player's own character
|
||||||
|
// pieces - same meshes, same textures, same cached GPU decodes, remote
|
||||||
|
// pose in the bone ring. Ghosts with no pose data (older peer, capture
|
||||||
|
// not ready) fall back to a solid-color cube.
|
||||||
|
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) {
|
||||||
|
// The mesh source: the local player's character pieces (body + nearby
|
||||||
|
// skinned non-ropa, non-LivingWorld items), found the same way the
|
||||||
|
// ghost capture on the game thread finds the body.
|
||||||
|
std::vector<const DrawItem*> player_pieces;
|
||||||
|
{
|
||||||
|
const DrawItem* body = nullptr;
|
||||||
|
uint32_t best_bones = 0;
|
||||||
|
float body_p[3] = {};
|
||||||
|
for (const DrawItem& it : scene.items) {
|
||||||
|
if (!(it.skinned && it.bones.size() >= 12) || it.ropa ||
|
||||||
|
it.lw_entity != 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (it.char_family < 1 || it.char_family > 5) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const float px = it.bones[3], py = it.bones[7], pz = it.bones[11];
|
||||||
|
const float dx = px - scene.cam_pos[0], dy = py - scene.cam_pos[1],
|
||||||
|
dz = pz - scene.cam_pos[2];
|
||||||
|
if (dx * dx + dy * dy + dz * dz > 25.0f) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const uint32_t nb = uint32_t(it.bones.size() / 12);
|
||||||
|
if (nb > best_bones) {
|
||||||
|
best_bones = nb;
|
||||||
|
body = ⁢
|
||||||
|
body_p[0] = px;
|
||||||
|
body_p[1] = py;
|
||||||
|
body_p[2] = pz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body != nullptr) {
|
||||||
|
player_pieces.push_back(body);
|
||||||
|
for (const DrawItem& it : scene.items) {
|
||||||
|
if (&it == body) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!(it.skinned && it.bones.size() >= 12) || it.ropa ||
|
||||||
|
it.lw_entity != 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const float px = it.bones[3], py = it.bones[7], pz = it.bones[11];
|
||||||
|
const float dx = px - body_p[0], dy = py - body_p[1],
|
||||||
|
dz = pz - body_p[2];
|
||||||
|
if (dx * dx + dy * dy + dz * dz <= 4.0f) {
|
||||||
|
player_pieces.push_back(&it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cube fallback geometry (also used by skate3_ghost_draw_local).
|
||||||
|
constexpr uint32_t kCubeVerts = 36;
|
||||||
|
const uint32_t kVertBytes =
|
||||||
|
uint32_t(sizeof(skate3::native_ghost::GhostCubeVertex));
|
||||||
|
const uint32_t kCubeBytes = kCubeVerts * kVertBytes;
|
||||||
|
const bool cube_uploaded = ui_offset + kCubeBytes <= RendererState::kUiRegionSize;
|
||||||
|
if (cube_uploaded) {
|
||||||
|
std::memcpy(g_r.ui_ring_cpu + ui_region + ui_offset,
|
||||||
|
skate3::native_ghost::CubeVertices(), kCubeBytes);
|
||||||
|
}
|
||||||
|
const uint32_t cube_offset = ui_offset;
|
||||||
|
if (cube_uploaded) {
|
||||||
|
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_cube = [&](const float world[16], const float color[3]) {
|
||||||
|
if (!cube_uploaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const auto draw_model = [&](const float* pose_rows, uint16_t pose_count) {
|
||||||
|
if (player_pieces.empty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Identity world: the remote skeleton's bone 0 translation already
|
||||||
|
// places the ghost; mvp becomes view*proj, exactly like the real
|
||||||
|
// skinned items (whose bones carry the world).
|
||||||
|
static const float kIdent[16] = {1, 0, 0, 0, 0, 1, 0, 0,
|
||||||
|
0, 0, 1, 0, 0, 0, 0, 1};
|
||||||
|
for (const DrawItem* piece : player_pieces) {
|
||||||
|
DrawItem g = *piece; // clone: same mesh/textures, remote pose
|
||||||
|
const uint32_t need = uint32_t(g.bones.size() / 12);
|
||||||
|
const uint32_t copy_rows = std::min<uint32_t>(pose_count, need);
|
||||||
|
g.bones.resize(size_t(need) * 12);
|
||||||
|
std::memcpy(g.bones.data(), pose_rows,
|
||||||
|
size_t(copy_rows) * 12 * sizeof(float));
|
||||||
|
for (uint32_t r = copy_rows; r < need; ++r) {
|
||||||
|
float* row = g.bones.data() + size_t(r) * 12;
|
||||||
|
row[0] = 1.0f;
|
||||||
|
row[5] = 1.0f;
|
||||||
|
row[10] = 1.0f;
|
||||||
|
}
|
||||||
|
std::memcpy(g.world, kIdent, sizeof(kIdent));
|
||||||
|
g.ropa = false;
|
||||||
|
g.pending = false;
|
||||||
|
g.selected = false;
|
||||||
|
g.retained = false;
|
||||||
|
g.dyn_entity = false;
|
||||||
|
g.unlit = false;
|
||||||
|
g.transparent = false;
|
||||||
|
g.water = false;
|
||||||
|
g.lw_alpha = -1.0f;
|
||||||
|
timed_draw(g);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
float ghost_world[16];
|
||||||
|
float ghost_color[3];
|
||||||
|
for (uint32_t i = 0; i < n_ghosts; ++i) {
|
||||||
|
float pose_scratch[skate3::native_ghost::kGhostMaxBones * 12];
|
||||||
|
uint16_t ghost_rows = 0;
|
||||||
|
if (skate3::native_ghost::GetGhostPose(
|
||||||
|
i, ghost_world, ghost_color,
|
||||||
|
uint16_t(skate3::native_ghost::kGhostMaxBones), pose_scratch,
|
||||||
|
&ghost_rows)) {
|
||||||
|
if (ghost_rows != 0) {
|
||||||
|
draw_model(pose_scratch, ghost_rows);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
draw_cube(ghost_world, ghost_color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (draw_local) {
|
||||||
|
const float local_color[3] = {0.2f, 1.0f, 0.2f};
|
||||||
|
draw_cube(local_world, local_color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// In-world neon splines (waypoint arrows / marker beams): replayed inside
|
// In-world neon splines (waypoint arrows / marker beams): replayed inside
|
||||||
// the scene pass, depth-tested against the world like the emulated frame,
|
// the scene pass, depth-tested against the world like the emulated frame,
|
||||||
// in submission order (darken backdrop passes precede the additive glow).
|
// in submission order (darken backdrop passes precede the additive glow).
|
||||||
|
|
|
||||||
99
tools/ghost_relay_main.cpp
Normal file
99
tools/ghost_relay_main.cpp
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
// ghost_relay: standalone ghost multiplayer relay server.
|
||||||
|
//
|
||||||
|
// Run on a public VPS (or your own machine behind a free tunnel like
|
||||||
|
// playit.gg / ngrok / Tailscale Funnel) so players connect OUTBOUND to it
|
||||||
|
// and need no port forwarding:
|
||||||
|
//
|
||||||
|
// ghost_relay [--port 40001] [--room CODE]
|
||||||
|
//
|
||||||
|
// --room optionally locks every client into one room (ignores JOIN rooms).
|
||||||
|
// The server is stateless across restarts: clients re-send their JOIN every
|
||||||
|
// ~5 s, so a redeployed relay re-gathers the room automatically.
|
||||||
|
//
|
||||||
|
// Build: cmake target `ghost_relay` (no rexglue dependency; plain sockets).
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
#include "skate3_native_ghost_relay.h"
|
||||||
|
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
#include <csignal>
|
||||||
|
namespace {
|
||||||
|
volatile std::sig_atomic_t g_stop = 0;
|
||||||
|
void OnSignal(int) { g_stop = 1; }
|
||||||
|
} // namespace
|
||||||
|
#endif
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
uint16_t port = skate3::native_ghost::kGhostDefaultPort;
|
||||||
|
std::string locked_room;
|
||||||
|
for (int i = 1; i < argc; ++i) {
|
||||||
|
const std::string arg = argv[i];
|
||||||
|
auto next = [&](const char* name) -> std::string {
|
||||||
|
if (i + 1 < argc) {
|
||||||
|
return argv[++i];
|
||||||
|
}
|
||||||
|
std::fprintf(stderr, "ghost_relay: %s requires a value\n", name);
|
||||||
|
std::exit(2);
|
||||||
|
};
|
||||||
|
if (arg == "--port") {
|
||||||
|
port = uint16_t(std::stoi(next("--port")));
|
||||||
|
} else if (arg == "--room") {
|
||||||
|
locked_room = next("--room");
|
||||||
|
} else if (arg == "--help" || arg == "-h") {
|
||||||
|
std::printf(
|
||||||
|
"ghost_relay - ghost multiplayer UDP relay\n"
|
||||||
|
"usage: ghost_relay [--port 40001] [--room CODE]\n"
|
||||||
|
" --port UDP port to listen on (clients connect here; allow this\n"
|
||||||
|
" port through any firewall, e.g. `ufw allow 40001/udp`)\n"
|
||||||
|
" --room optional: force every client into one room code\n");
|
||||||
|
return 0;
|
||||||
|
} else {
|
||||||
|
std::fprintf(stderr, "ghost_relay: unknown argument '%s'\n", arg.c_str());
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
std::signal(SIGINT, OnSignal);
|
||||||
|
std::signal(SIGTERM, OnSignal);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
skate3::native_ghost::GhostRelay relay(port);
|
||||||
|
if (!locked_room.empty()) {
|
||||||
|
relay.SetLockedRoom(locked_room.c_str());
|
||||||
|
}
|
||||||
|
if (!relay.Start()) {
|
||||||
|
std::fprintf(stderr, "ghost_relay: failed to bind UDP port %u\n", port);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const std::string lock_note =
|
||||||
|
locked_room.empty() ? "" : (" (locked room: " + locked_room + ")");
|
||||||
|
std::printf("ghost_relay: listening on UDP %u%s\n", port, lock_note.c_str());
|
||||||
|
std::printf("ghost_relay: clients connect outbound; no client-side port "
|
||||||
|
"forwarding needed\n");
|
||||||
|
|
||||||
|
// Status line every 5 s; on POSIX exit on SIGINT/SIGTERM.
|
||||||
|
auto last_log = std::chrono::steady_clock::now();
|
||||||
|
while (true) {
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
if (g_stop != 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
if (now - last_log >= std::chrono::seconds(5)) {
|
||||||
|
std::printf("ghost_relay: %zu client(s)\n", relay.ClientCount());
|
||||||
|
std::fflush(stdout);
|
||||||
|
last_log = now;
|
||||||
|
}
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||||
|
}
|
||||||
|
relay.Stop();
|
||||||
|
std::printf("ghost_relay: stopped\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue