mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 17:27:19 +02:00
Players reported the game hanging for about a second while skating. A profile showed the memory line in the log walking every heap (HeapSummary) on the client thread, 0.5 to 2 s every two minutes. An audit of everything that runs on the game's update and render threads found more of the same, and Sentry's top crashes had causes of ours. Freezes: - Memory log line: no heap walk or thread snapshot; the GPU part and the write happen on a thread of their own. - Profile database: synchronous=NORMAL in WAL mode. A save is still kept when the game crashes; every play event no longer waits for the disk inside the game's hooks. - ReSkate's own preferences and values (menu clicks, session sliders, Discord) are saved by a background writer in order; reads see a value until it is saved. - Input: the foreground window and the keyboard are sampled on a thread of their own (GetAncestor and GetAsyncKeyState stalled the client tick 40-50 ms when another program held Windows' lock); flight input uses the sample; ClipCursor at most twice a second while a menu has the pointer. - Hooks installed while the game runs go in one transaction per place (HookBatch): throwdowns, remote players, the party, Hall of Meat. - Off the update thread: the host's map package lookup (one table read in the background, not the Mods folder per offer), the trainer's map file and settings save, Board Break's settings save, mod music playlists and their covers, the Spotify station covers. - Off the render thread: the Modding page's and the deck card's Mods folder reads. The overlay's three startup waits share five seconds. - Less work per frame: Steam names reused while one a frame is asked again, 2 friends a tick, steam_api64.dll checked once per process, cosmetic names read in place rather than a system call per character, an outfit load no longer copies the inventory per slot, the throwdown type scan runs at background priority over resident memory, the Board Break deck label is kept a minute. Crashes: - Render-resource pool: the metadata fields are written to the state owner only. The other renderer is 0x2daa00 bytes, and +0x11aae28 is far past its end (store_field crash; heap corruption in every release). - Other players' skateboard effects: a stale skateboard component is no longer followed, and the joint lookup is walked with a bound first (an endless loop in reused memory ended in GameStall). - Launcher: Oodle decodes into the buffer it asks for with corrupt data possible, and its assertions no longer stop the process (heap corruption in the store-copy scan; a breakpoint in oo2core). Timings (Send performance timings): - A frame of half a second or more in a level is sent as game.freeze: where the time went (ReSkate's tick, the game's frame, or outside it), whether the renderer kept presenting, and ReSkate's busiest zones. - Closing the game sends the level session it was in. Music after a connection drop: - Spotify device: an account Spotify could not be reached with is kept and tried again (3 s doubling to a minute); a session that drops is taken up again. Only an account Spotify refuses is asked for anew. - YouTube playlist pictures that could not be fetched are tried again, from 30 s to 10 minutes apart.
1093 lines
52 KiB
C++
1093 lines
52 KiB
C++
#include "steam_transport.h"
|
|
#include "steam_lanes.h"
|
|
#include "Extension/Multiplayer/Net/wire_codec.h"
|
|
#include "Engine/Core/Platform/launcher_support.h"
|
|
#ifdef _WIN32
|
|
#include <Windows.h>
|
|
#else
|
|
#include <chrono>
|
|
#include <dlfcn.h>
|
|
#endif
|
|
#ifdef _WIN32
|
|
#pragma warning(push, 0)
|
|
#endif
|
|
#include <isteamnetworkingsockets.h>
|
|
#ifdef _WIN32
|
|
#pragma warning(pop)
|
|
#endif
|
|
#include <array>
|
|
#include <atomic>
|
|
#include <algorithm>
|
|
#include <cstring>
|
|
#include <deque>
|
|
#include <unordered_map>
|
|
#include <filesystem>
|
|
#include <mutex>
|
|
#include <map>
|
|
#include <set>
|
|
#include <stdexcept>
|
|
|
|
namespace dingosdk::multiplayer {
|
|
namespace {
|
|
constexpr int virtual_port = 37;
|
|
#ifdef _WIN32
|
|
using NativeModule = HMODULE;
|
|
#else
|
|
using NativeModule = void *;
|
|
using ULONGLONG = std::uint64_t;
|
|
inline std::uint64_t tick_ms() noexcept {
|
|
return static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::steady_clock::now().time_since_epoch())
|
|
.count());
|
|
}
|
|
#ifndef GetTickCount64
|
|
#define GetTickCount64 tick_ms
|
|
#endif
|
|
#endif
|
|
template <class T> T symbol(NativeModule module, const char *name) {
|
|
#ifdef _WIN32
|
|
const auto result = GetProcAddress(module, name);
|
|
#else
|
|
const auto result = dlsym(module, name);
|
|
#endif
|
|
if (!result)
|
|
throw std::runtime_error(std::string("Missing Steam export: ") + name);
|
|
#ifdef _WIN32
|
|
#pragma warning(push)
|
|
#pragma warning(disable : 4191)
|
|
return reinterpret_cast<T>(result);
|
|
#pragma warning(pop)
|
|
#else
|
|
T value{};
|
|
std::memcpy(&value, &result, sizeof(value));
|
|
return value;
|
|
#endif
|
|
}
|
|
std::string bounded(const char *text, std::size_t size) {
|
|
std::size_t length{};
|
|
while (length < size && text[length])
|
|
++length;
|
|
return std::string(text, length);
|
|
}
|
|
} // namespace
|
|
struct SteamTransport::Impl {
|
|
void *sockets{};
|
|
void *friends{};
|
|
const char *(*persona_name)(void *, std::uint64_t){};
|
|
bool (*request_name)(void *, std::uint64_t, bool){};
|
|
ULONGLONG name_refresh{};
|
|
|
|
TransportStatus state;
|
|
SteamLanes lanes;
|
|
HSteamListenSocket listener{};
|
|
HSteamListenSocket direct_listener{}; // a dedicated server's, on its own address (listen_direct)
|
|
struct Link {
|
|
HSteamNetConnection handle{};
|
|
ULONGLONG connecting_since{};
|
|
bool connected{};
|
|
std::string name;
|
|
bool prioritized{};
|
|
// Steam's real-time status, read at most once per poll() frame and shared by
|
|
// telemetry and every fresh send of that frame.
|
|
SteamNetConnectionRealTimeStatus_t status{};
|
|
std::array<SteamNetConnectionRealTimeLaneStatus_t, lane_priorities.size()> lanes{};
|
|
std::uint64_t status_frame{};
|
|
bool status_ok{};
|
|
bool recheck{}; // a status callback named this connection: read its state this poll
|
|
bool direct{}; // straight to or from an address, not through Steam's relays
|
|
};
|
|
std::map<std::uint64_t, Link> links;
|
|
// How the connections Steam closed ended (take_closed). A handful at most wait here.
|
|
std::map<std::uint64_t, std::string> closed;
|
|
// What each connection may send a second, and Steam's call that changes an open one.
|
|
int send_rate = connection_send_rate, listen_rate = connection_send_rate; // now, and when the listen socket opened
|
|
void *utils{};
|
|
bool (*set_config)(void *, ESteamNetworkingConfigValue, ESteamNetworkingConfigScope, intptr_t,
|
|
ESteamNetworkingConfigDataType, const void *){};
|
|
// Steam's SteamRelayNetworkStatus_t, which the bundled header only names: how its relay
|
|
// network looks from here. Room to spare after the message, should Steam's grow.
|
|
struct RelayNetwork {
|
|
ESteamNetworkingAvailability available{};
|
|
int measuring{};
|
|
ESteamNetworkingAvailability config{}, any_relay{};
|
|
char message[256]{};
|
|
char spare[256]{};
|
|
};
|
|
ESteamNetworkingAvailability (*relay_network)(void *, RelayNetwork *){};
|
|
void (*debug_output)(void *, ESteamNetworkingSocketsDebugOutputType, void (*)(ESteamNetworkingSocketsDebugOutputType, const char *)){};
|
|
// The options of a connection by address: as options(), without the one about relays.
|
|
std::array<SteamNetworkingConfigValue_t, 5> direct_options() {
|
|
std::array<SteamNetworkingConfigValue_t, 5> out{};
|
|
out[4].SetInt32(k_ESteamNetworkingConfig_NagleTime, packing_us ? packing_us : 5000);
|
|
out[0] = callback_option();
|
|
out[1].SetInt32(k_ESteamNetworkingConfig_SendRateMax, send_rate);
|
|
out[2].SetInt32(k_ESteamNetworkingConfig_SendRateMin, send_rate);
|
|
out[3].SetInt32(k_ESteamNetworkingConfig_SendBufferSize, 4 * 1024 * 1024);
|
|
return out;
|
|
}
|
|
// A guest's direct attempt at its host did not come up (no answer, refused, or not the
|
|
// server it should be): the same link carries on through Steam's relays.
|
|
void fall_back(std::uint64_t id, Link &link, const std::string &why) {
|
|
if (link.handle && close) close(sockets, link.handle, 0, "Direct connection not used", false);
|
|
SteamNetworkingIdentity identity{};
|
|
identity.SetSteamID64(id);
|
|
auto relayed = options();
|
|
const auto connection = connect(sockets, &identity, virtual_port, static_cast<int>(relayed.size()), relayed.data());
|
|
const auto name = std::move(link.name);
|
|
link = Link{connection, GetTickCount64(), false, name, connection ? lanes.setup(sockets, connection) : false};
|
|
state.detail = "Direct connection not available (" + why + "): connecting through Steam's relays...";
|
|
direct_note = state.detail;
|
|
note_direct("Direct connection to the server did not come up (" + why + "); using Steam's relays.");
|
|
}
|
|
std::string direct_note; // how the last direct attempt went
|
|
// When each player's direct connection was last noted, and how often they asked since.
|
|
struct DirectAsked { std::uint64_t since{}; unsigned again{}; };
|
|
std::unordered_map<std::uint64_t, DirectAsked> direct_asked;
|
|
std::vector<std::string> direct_notes; // take_direct_notes
|
|
void note_direct(std::string text) {
|
|
if (direct_notes.size() < 64) direct_notes.push_back(std::move(text));
|
|
}
|
|
// Gives one open connection the current send rate. A connection a listen socket
|
|
// accepts starts with the rate the socket was opened with, so each one is given it.
|
|
bool apply_rate(HSteamNetConnection handle) {
|
|
if (!handle || !set_config || !utils) return false;
|
|
bool ok = true;
|
|
const auto set = [&](ESteamNetworkingConfigValue option, int32 value) {
|
|
ok &= set_config(utils, option, k_ESteamNetworkingConfig_Connection, static_cast<intptr_t>(handle),
|
|
k_ESteamNetworkingConfig_Int32, &value);
|
|
};
|
|
// The minimum never above the maximum on the way, whatever the rate was before.
|
|
set(k_ESteamNetworkingConfig_SendRateMin, min_send_rate);
|
|
set(k_ESteamNetworkingConfig_SendRateMax, send_rate);
|
|
set(k_ESteamNetworkingConfig_SendRateMin, send_rate);
|
|
return ok;
|
|
}
|
|
// "ping 48 ms, quality 99%/97%, 0 B queued": what was last measured of a link.
|
|
static std::string measured(const Link &link) {
|
|
if (!link.status_ok) return "link not measured";
|
|
const auto &s = link.status;
|
|
const auto percent = [](float quality) { return quality < 0 ? std::string("?") : std::to_string(static_cast<int>(quality * 100.f + .5f)) + "%"; };
|
|
return "ping " + std::to_string(s.m_nPing) + " ms, quality " + percent(s.m_flConnectionQualityLocal) + " here / " +
|
|
percent(s.m_flConnectionQualityRemote) + " there, " + std::to_string(s.m_cbPendingReliable + s.m_cbPendingUnreliable) +
|
|
" B queued (" + std::to_string(queue_time(s) / 1000) + " ms), " +
|
|
std::to_string(static_cast<int>(s.m_flOutBytesPerSec / 1024.f + .5f)) + " KB/s out, " +
|
|
std::to_string(static_cast<int>(s.m_flInBytesPerSec / 1024.f + .5f)) + " KB/s in";
|
|
}
|
|
std::uint64_t frame{};
|
|
std::uint64_t name_asked_frame{}; // the poll frame a known name was last asked of Steam again
|
|
ULONGLONG next_measure{}, next_sweep{};
|
|
struct Name {
|
|
std::string text;
|
|
ULONGLONG expires{};
|
|
};
|
|
std::map<std::uint64_t, Name> names;
|
|
unsigned capacity = max_players;
|
|
std::uint64_t host_id{};
|
|
std::set<std::uint64_t> allowed;
|
|
std::mutex events_mutex;
|
|
std::deque<SteamNetConnectionStatusChangedCallback_t> events;
|
|
static inline std::atomic<Impl *> callback_owner{};
|
|
HSteamListenSocket (*listen)(void *, int, int, const SteamNetworkingConfigValue_t *){};
|
|
// By address instead of through the relays; absent from a Steam too old to have them.
|
|
HSteamListenSocket (*listen_ip)(void *, const SteamNetworkingIPAddr *, int, const SteamNetworkingConfigValue_t *){};
|
|
HSteamNetConnection (*connect_ip)(void *, const SteamNetworkingIPAddr *, int, const SteamNetworkingConfigValue_t *){};
|
|
HSteamNetConnection (*connect)(void *, const SteamNetworkingIdentity *, int, int,
|
|
const SteamNetworkingConfigValue_t *){};
|
|
EResult (*accept)(void *, HSteamNetConnection){};
|
|
bool (*close)(void *, HSteamNetConnection, int, const char *, bool){};
|
|
bool (*close_listener)(void *, HSteamListenSocket){};
|
|
bool (*get_info)(void *, HSteamNetConnection, SteamNetConnectionInfo_t *){};
|
|
EResult (*real_time)(void *, HSteamNetConnection, SteamNetConnectionRealTimeStatus_t *, int,
|
|
SteamNetConnectionRealTimeLaneStatus_t *){};
|
|
EResult (*send_message)(void *, HSteamNetConnection, const void *, uint32, int, int64 *){};
|
|
int (*receive_messages)(void *, HSteamNetConnection, SteamNetworkingMessage_t **, int){};
|
|
void (*release_message)(SteamNetworkingMessage_t *){};
|
|
void (*run_callbacks)(void *){};
|
|
bool (*socket_pair)(void *, HSteamNetConnection *, HSteamNetConnection *, bool,
|
|
const SteamNetworkingIdentity *, const SteamNetworkingIdentity *){};
|
|
|
|
static void changed(SteamNetConnectionStatusChangedCallback_t *event) noexcept {
|
|
auto *p = callback_owner.load(std::memory_order_acquire);
|
|
if (!p || !event)
|
|
return;
|
|
bool full{};
|
|
try {
|
|
std::lock_guard lock(p->events_mutex);
|
|
full = p->events.size() >= 64;
|
|
if (!full)
|
|
p->events.push_back(*event);
|
|
} catch (...) {
|
|
full = true;
|
|
}
|
|
// The callback only queues transport state. It never touches the game.
|
|
if (full && event->m_info.m_eState == k_ESteamNetworkingConnectionState_Connecting && p->close)
|
|
p->close(p->sockets, event->m_hConn, 4001, "ReSkate callback queue full", false);
|
|
}
|
|
SteamNetworkingConfigValue_t callback_option() {
|
|
SteamNetworkingConfigValue_t option{};
|
|
option.SetPtr(k_ESteamNetworkingConfig_Callback_ConnectionStatusChanged,
|
|
reinterpret_cast<void *>(&changed));
|
|
return option;
|
|
}
|
|
std::array<SteamNetworkingConfigValue_t, 6> options() {
|
|
std::array<SteamNetworkingConfigValue_t, 6> out{};
|
|
out[5].SetInt32(k_ESteamNetworkingConfig_NagleTime, packing_us ? packing_us : 5000); // 5000: Steam's own
|
|
out[0] = callback_option();
|
|
// This connection's send rate. Steam asks for the minimum and the maximum to be set
|
|
// to the same value: with only the maximum raised a connection stays at the 256 KB/s
|
|
// default, which a busy server fills for every player. Do not alter the game's
|
|
// global Steam settings.
|
|
listen_rate = send_rate;
|
|
out[1].SetInt32(k_ESteamNetworkingConfig_SendRateMax, send_rate);
|
|
out[3].SetInt32(k_ESteamNetworkingConfig_SendRateMin, send_rate);
|
|
// Room for what must arrive. Steam refuses a reliable message once 512 KB wait, its
|
|
// default, and a busy connection already holds half of that in poses: the refusal
|
|
// then ends the session for a player whose connection was only slow.
|
|
out[4].SetInt32(k_ESteamNetworkingConfig_SendBufferSize, 4 * 1024 * 1024);
|
|
// Every connection goes through Steam's relays, which is how players who are not
|
|
// Steam friends already connect: no player's IP address is shared with the others
|
|
// in a session, whatever each one's own Steam setting is.
|
|
out[2].SetInt32(k_ESteamNetworkingConfig_P2P_Transport_ICE_Enable,
|
|
k_nSteamNetworkingConfig_P2P_Transport_ICE_Enable_Disable);
|
|
return out;
|
|
}
|
|
void read_status(Link &link) {
|
|
link.status = {};
|
|
link.lanes = {};
|
|
const auto count = link.prioritized ? static_cast<int>(link.lanes.size()) : 0;
|
|
link.status_ok = real_time(sockets, link.handle, &link.status, count, count ? link.lanes.data() : nullptr) ==
|
|
k_EResultOK;
|
|
link.status_frame = frame;
|
|
}
|
|
static std::span<const SteamNetConnectionRealTimeLaneStatus_t> lane_status(const Link &link) {
|
|
return {link.lanes.data(), link.prioritized ? link.lanes.size() : 0};
|
|
}
|
|
// The connection list the session reads every frame. No Steam calls.
|
|
void publish_links() {
|
|
state.connected = false;
|
|
state.peers.clear();
|
|
state.peer_id = 0;
|
|
state.peer_name.clear();
|
|
for (const auto &[id, link] : links) {
|
|
state.peers.push_back({id, link.connected});
|
|
if (!state.peer_id) {
|
|
state.peer_id = id;
|
|
state.peer_name = link.name;
|
|
}
|
|
state.connected |= link.connected;
|
|
}
|
|
}
|
|
// Connection quality for diagnostics and the upload budget: a few times a second.
|
|
void measure() {
|
|
state.telemetry = false;
|
|
state.ping_ms = state.send_rate = state.pending_bytes = 0;
|
|
state.queue_us = 0;
|
|
state.cosmetic_queue_us = state.prioritized_connections = 0;
|
|
state.outgoing_bps = state.incoming_bps = 0;
|
|
state.delivery_local = state.delivery_remote = -1;
|
|
for (auto &entry : links) {
|
|
auto &link = entry.second;
|
|
if (!link.connected) continue;
|
|
if (link.prioritized) ++state.prioritized_connections;
|
|
if (link.status_frame != frame) read_status(link);
|
|
const auto &info = link.status;
|
|
if (!link.status_ok || info.m_eState != k_ESteamNetworkingConnectionState_Connected)
|
|
continue;
|
|
state.telemetry = true;
|
|
state.ping_ms = std::max(state.ping_ms, info.m_nPing);
|
|
state.send_rate += info.m_nSendRateBytesPerSecond;
|
|
state.pending_bytes += info.m_cbPendingReliable + info.m_cbPendingUnreliable;
|
|
const auto queues = lane_status(link);
|
|
state.queue_us = std::max(state.queue_us, lane_queue_time(info, queues, TrafficLane::gameplay));
|
|
state.cosmetic_queue_us = std::max(state.cosmetic_queue_us,
|
|
lane_queue_time(info, queues, TrafficLane::cosmetics));
|
|
state.outgoing_bps += info.m_flOutBytesPerSec;
|
|
state.incoming_bps += info.m_flInBytesPerSec;
|
|
if (info.m_flConnectionQualityLocal >= 0)
|
|
state.delivery_local = state.delivery_local < 0
|
|
? info.m_flConnectionQualityLocal
|
|
: std::min(state.delivery_local, info.m_flConnectionQualityLocal);
|
|
if (info.m_flConnectionQualityRemote >= 0)
|
|
state.delivery_remote =
|
|
state.delivery_remote < 0
|
|
? info.m_flConnectionQualityRemote
|
|
: std::min(state.delivery_remote, info.m_flConnectionQualityRemote);
|
|
}
|
|
}
|
|
// The checks send() makes before Steam sees a message.
|
|
bool admit(Link &link, std::span<const std::uint8_t> bytes, bool fresh, TrafficLane lane) {
|
|
if (!link.connected || bytes.size() > max_packet)
|
|
return false;
|
|
if (fresh) {
|
|
if (link.status_frame != frame) read_status(link);
|
|
if (link.status_ok && lane_congested(link.status, lane_status(link), lane)) {
|
|
++state.skipped;
|
|
return false; // Let the next current pose/audio state replace this one.
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
int packing_us{}; // set_packing: how long a message may wait for company, 0 for not at all
|
|
int send_flags(bool reliable, TrafficLane lane) const {
|
|
// Packing: without "no Nagle" Steam fills a packet with what follows within the time
|
|
// above, and without "no delay" it queues instead of dropping (admit() has already
|
|
// turned away what a slow connection cannot take).
|
|
if (packing_us && lane != TrafficLane::voice) return reliable ? k_nSteamNetworkingSend_Reliable : k_nSteamNetworkingSend_Unreliable;
|
|
return reliable ? (lane == TrafficLane::cosmetics ? k_nSteamNetworkingSend_Reliable
|
|
: k_nSteamNetworkingSend_ReliableNoNagle)
|
|
: k_nSteamNetworkingSend_UnreliableNoDelay;
|
|
}
|
|
bool record(EResult result, bool reliable, std::span<const std::uint8_t> bytes) {
|
|
if (result != k_EResultOK) {
|
|
if (result == k_EResultIgnored && !reliable) {
|
|
++state.skipped;
|
|
return false;
|
|
}
|
|
++state.dropped;
|
|
++state.send_failures;
|
|
return false;
|
|
}
|
|
++state.sent;
|
|
state.sent_bytes += bytes.size();
|
|
state.raw_sent_bytes += wire_original_size(bytes);
|
|
return true;
|
|
}
|
|
};
|
|
SteamTransport::SteamTransport() : impl_(std::make_unique<Impl>()) {}
|
|
SteamTransport::~SteamTransport() {
|
|
stop();
|
|
Impl::callback_owner.store(nullptr);
|
|
}
|
|
// Binds the networking calls for `sockets` (the user's or a game server's).
|
|
bool SteamTransport::bind(void *library, void *sockets, void *networking_utils) {
|
|
auto &p = *impl_;
|
|
const auto module = static_cast<NativeModule>(library);
|
|
p.sockets = sockets;
|
|
if (!p.sockets)
|
|
throw std::runtime_error("Steam Networking Sockets v012 is unavailable.");
|
|
#define BIND(member, suffix) p.member = symbol<decltype(p.member)>(module, "SteamAPI_ISteamNetworkingSockets_" suffix)
|
|
BIND(listen, "CreateListenSocketP2P");
|
|
BIND(connect, "ConnectP2P");
|
|
BIND(accept, "AcceptConnection");
|
|
BIND(close, "CloseConnection");
|
|
BIND(close_listener, "CloseListenSocket");
|
|
BIND(get_info, "GetConnectionInfo");
|
|
BIND(real_time, "GetConnectionRealTimeStatus");
|
|
BIND(send_message, "SendMessageToConnection");
|
|
BIND(receive_messages, "ReceiveMessagesOnConnection");
|
|
BIND(run_callbacks, "RunCallbacks");
|
|
BIND(socket_pair, "CreateSocketPair");
|
|
#undef BIND
|
|
p.release_message =
|
|
symbol<decltype(p.release_message)>(module, "SteamAPI_SteamNetworkingMessage_t_Release");
|
|
SteamNetworkingIdentity identity{};
|
|
if (!symbol<bool (*)(void *, SteamNetworkingIdentity *)>(
|
|
module, "SteamAPI_ISteamNetworkingSockets_GetIdentity")(p.sockets, &identity) ||
|
|
identity.m_eType != k_ESteamNetworkingIdentityType_SteamID || !identity.GetSteamID64())
|
|
throw std::runtime_error("Steam identity is unavailable.");
|
|
p.state.local_id = identity.GetSteamID64();
|
|
if (!networking_utils)
|
|
throw std::runtime_error("Steam networking utilities are unavailable.");
|
|
// Older Steam adapters retain the existing single-lane send path.
|
|
try {
|
|
p.lanes.utils = networking_utils;
|
|
p.lanes.configure = symbol<decltype(p.lanes.configure)>(module,
|
|
"SteamAPI_ISteamNetworkingSockets_ConfigureConnectionLanes");
|
|
p.lanes.allocate = symbol<decltype(p.lanes.allocate)>(module,
|
|
"SteamAPI_ISteamNetworkingUtils_AllocateMessage");
|
|
p.lanes.send = symbol<decltype(p.lanes.send)>(module,
|
|
"SteamAPI_ISteamNetworkingSockets_SendMessages");
|
|
} catch (...) {
|
|
p.lanes = {};
|
|
}
|
|
p.utils = networking_utils;
|
|
try {
|
|
p.listen_ip = symbol<decltype(p.listen_ip)>(module, "SteamAPI_ISteamNetworkingSockets_CreateListenSocketIP");
|
|
p.connect_ip = symbol<decltype(p.connect_ip)>(module, "SteamAPI_ISteamNetworkingSockets_ConnectByIPAddress");
|
|
} catch (...) {
|
|
p.listen_ip = nullptr;
|
|
p.connect_ip = nullptr;
|
|
}
|
|
try {
|
|
p.debug_output = symbol<decltype(p.debug_output)>(module, "SteamAPI_ISteamNetworkingUtils_SetDebugOutputFunction");
|
|
} catch (...) {
|
|
p.debug_output = nullptr;
|
|
}
|
|
try {
|
|
p.relay_network = symbol<decltype(p.relay_network)>(module, "SteamAPI_ISteamNetworkingUtils_GetRelayNetworkStatus");
|
|
} catch (...) {
|
|
p.relay_network = nullptr;
|
|
}
|
|
try {
|
|
p.set_config = symbol<decltype(p.set_config)>(module, "SteamAPI_ISteamNetworkingUtils_SetConfigValue");
|
|
} catch (...) {
|
|
p.set_config = nullptr; // open connections keep their rate; new ones get the new one
|
|
}
|
|
symbol<void (*)(void *)>(module,
|
|
"SteamAPI_ISteamNetworkingUtils_InitRelayNetworkAccess")(networking_utils);
|
|
symbol<ESteamNetworkingAvailability (*)(void *)>(
|
|
module, "SteamAPI_ISteamNetworkingSockets_InitAuthentication")(p.sockets);
|
|
Impl *expected = nullptr;
|
|
if (!Impl::callback_owner.compare_exchange_strong(expected, &p) && expected != &p)
|
|
throw std::runtime_error("Another ReSkate transport already owns callbacks.");
|
|
p.state.ready = true;
|
|
p.state.detail = "Steam ready. Relay authentication may still be connecting.";
|
|
return true;
|
|
}
|
|
bool SteamTransport::open() {
|
|
auto &p = *impl_;
|
|
if (p.state.ready)
|
|
return true;
|
|
#ifdef _WIN32
|
|
try {
|
|
if (launcher::offline_mode())
|
|
throw std::runtime_error("Multiplayer is unavailable in offline mode. Start Steam and relaunch ReSkate.");
|
|
const auto module = GetModuleHandleW(L"steam_api64.dll");
|
|
if (!module)
|
|
throw std::runtime_error("Steam DLL is not loaded. Start ReSkate with Steam running.");
|
|
std::wstring path(32768, L'\0');
|
|
const auto length = GetModuleFileNameW(module, path.data(), static_cast<DWORD>(path.size()));
|
|
if (!length || length >= path.size())
|
|
throw std::runtime_error("Cannot verify Steam DLL location.");
|
|
path.resize(length);
|
|
launcher::validate_steam_api_file(std::filesystem::path(path));
|
|
const auto user = symbol<int (*)()>(module, "SteamAPI_GetHSteamUser")();
|
|
if (!user)
|
|
throw std::runtime_error(
|
|
"The game has not initialized Steam. Keep Steam online and restart ReSkate.");
|
|
void *utils = symbol<void *(*)()>(module, "SteamAPI_SteamUtils_v010")();
|
|
if (!utils || symbol<uint32 (*)(void *)>(module, "SteamAPI_ISteamUtils_GetAppID")(utils) != 3354750)
|
|
throw std::runtime_error("Steam app identity does not match skate.");
|
|
// Cosmetic lookup is optional; missing Friends support must not stop P2P.
|
|
try {
|
|
p.friends = symbol<void *(*)()>(module, "SteamAPI_SteamFriends_v017")();
|
|
p.persona_name =
|
|
symbol<decltype(p.persona_name)>(module, "SteamAPI_ISteamFriends_GetFriendPersonaName");
|
|
p.request_name =
|
|
symbol<decltype(p.request_name)>(module, "SteamAPI_ISteamFriends_RequestUserInformation");
|
|
} catch (...) {
|
|
p.friends = nullptr;
|
|
}
|
|
void *sockets = nullptr;
|
|
for (const char *name : {"SteamAPI_SteamNetworkingSockets_SteamAPI_v013",
|
|
"SteamAPI_SteamNetworkingSockets_SteamAPI_v012"}) {
|
|
try {
|
|
sockets = symbol<void *(*)()>(module, name)();
|
|
break;
|
|
} catch (...) {
|
|
}
|
|
}
|
|
if (!sockets) throw std::runtime_error("Missing Steam export: SteamAPI_SteamNetworkingSockets_SteamAPI");
|
|
return bind(module, sockets,
|
|
symbol<void *(*)()>(module, "SteamAPI_SteamNetworkingUtils_SteamAPI_v004")());
|
|
} catch (const std::exception &e) {
|
|
p.state.detail = e.what();
|
|
return false;
|
|
}
|
|
#else
|
|
// Client game path is Windows-only. Dedicated servers use open_game_server().
|
|
p.state.detail = "Client multiplayer is only supported on Windows.";
|
|
return false;
|
|
#endif
|
|
}
|
|
bool SteamTransport::open_game_server(void *library) {
|
|
auto &p = *impl_;
|
|
if (p.state.ready)
|
|
return true;
|
|
try {
|
|
const auto module = static_cast<NativeModule>(library);
|
|
const auto user = symbol<int (*)()>(module, "SteamGameServer_GetHSteamUser")();
|
|
if (!user)
|
|
throw std::runtime_error("The Steam game server is not initialized.");
|
|
const auto find = symbol<void *(*)(int, const char *)>(module, "SteamInternal_FindOrCreateGameServerInterface");
|
|
// Game servers have no friends list; names come from each player's hello.
|
|
p.friends = nullptr;
|
|
// Sockets v012 (Skate's Windows DLL) vs v013 (current SDK): same methods
|
|
// used here, v013 is a superset. Try new first, fall back to old.
|
|
void *sockets = nullptr;
|
|
for (const char *name : {"SteamAPI_SteamGameServerNetworkingSockets_SteamAPI_v013",
|
|
"SteamAPI_SteamGameServerNetworkingSockets_SteamAPI_v012"}) {
|
|
try {
|
|
sockets = symbol<void *(*)()>(module, name)();
|
|
break;
|
|
} catch (...) {
|
|
}
|
|
}
|
|
if (!sockets) throw std::runtime_error("Missing Steam export: SteamAPI_SteamGameServerNetworkingSockets_SteamAPI");
|
|
return bind(module, sockets, find(user, "SteamNetworkingUtils004"));
|
|
} catch (const std::exception &e) {
|
|
p.state.detail = e.what();
|
|
return false;
|
|
}
|
|
}
|
|
void SteamTransport::disconnect(std::uint64_t id, const char *reason) {
|
|
auto &p = *impl_;
|
|
const auto found = p.links.find(id);
|
|
if (found == p.links.end())
|
|
return;
|
|
const auto handle = found->second.handle;
|
|
p.links.erase(found);
|
|
// Closed from this side, unless Steam's own account of the end is already kept.
|
|
if (p.closed.size() >= 256) p.closed.clear();
|
|
p.closed.try_emplace(id, std::string("closed by this side: ") + (reason ? reason : ""));
|
|
if (handle && p.close)
|
|
p.close(p.sockets, handle, 1000, reason, false);
|
|
p.state.detail = reason;
|
|
p.publish_links();
|
|
}
|
|
void SteamTransport::stop() {
|
|
auto &p = *impl_;
|
|
const auto listener = p.listener;
|
|
p.listener = 0;
|
|
if (p.direct_listener && p.close_listener) p.close_listener(p.sockets, p.direct_listener);
|
|
p.direct_listener = 0;
|
|
p.state.hosting = false;
|
|
p.allowed.clear();
|
|
p.host_id = 0;
|
|
if (listener && p.close_listener)
|
|
p.close_listener(p.sockets, listener);
|
|
while (!p.links.empty())
|
|
disconnect(p.links.begin()->first, "Disconnected.");
|
|
// Drain pending incoming handles before discarding callbacks.
|
|
if (p.state.ready)
|
|
poll();
|
|
// Telemetry is measured a few times a second: without links, clear it now rather
|
|
// than show the old session's until the next measurement.
|
|
p.measure();
|
|
p.next_measure = 0;
|
|
}
|
|
bool SteamTransport::host(unsigned capacity) {
|
|
stop();
|
|
if (!open())
|
|
return false;
|
|
auto &p = *impl_;
|
|
if (capacity < 2 || capacity > max_players)
|
|
return false;
|
|
p.capacity = capacity;
|
|
auto options = p.options();
|
|
p.listener = p.listen(p.sockets, virtual_port, static_cast<int>(options.size()), options.data());
|
|
p.state.hosting = p.listener != 0;
|
|
p.state.detail = p.listener ? "Waiting for players to join." : "Steam could not open the P2P listener.";
|
|
return p.listener != 0;
|
|
}
|
|
bool SteamTransport::join(std::uint64_t id, std::uint32_t direct_ip, std::uint16_t direct_port) {
|
|
stop();
|
|
if (!open())
|
|
return false;
|
|
auto &p = *impl_;
|
|
if (id == p.state.local_id) {
|
|
p.state.detail = "Use Local Echo to test on one machine, or join from another Steam account.";
|
|
return false;
|
|
}
|
|
p.host_id = id;
|
|
p.allowed.insert(id);
|
|
p.capacity = max_players;
|
|
auto options = p.options();
|
|
// Guests listen only for identities admitted in the host's reliable roster.
|
|
// Failure to listen leaves the host-forwarded path usable.
|
|
p.listener = p.listen(p.sockets, virtual_port, static_cast<int>(options.size()), options.data());
|
|
// A dedicated server's own address first, when it has one (the header).
|
|
if (direct_ip && direct_port && p.connect_ip) {
|
|
SteamNetworkingIPAddr address{};
|
|
address.Clear();
|
|
address.SetIPv4(direct_ip, direct_port);
|
|
auto by_address = p.direct_options();
|
|
if (const auto connection = p.connect_ip(p.sockets, &address, static_cast<int>(by_address.size()), by_address.data())) {
|
|
p.closed.erase(id);
|
|
auto &link = p.links.emplace(id, Impl::Link{connection, GetTickCount64(), false, name(id),
|
|
p.lanes.setup(p.sockets, connection)}).first->second;
|
|
link.direct = true;
|
|
p.note_direct("Connecting straight to the server at " + std::to_string(direct_ip >> 24) + "." + std::to_string((direct_ip >> 16) & 255) +
|
|
"." + std::to_string((direct_ip >> 8) & 255) + "." + std::to_string(direct_ip & 255) + ":" + std::to_string(direct_port) + ".");
|
|
p.direct_note = "Connecting straight to the server...";
|
|
p.state.detail = p.direct_note;
|
|
p.publish_links();
|
|
return true;
|
|
}
|
|
}
|
|
const bool connected = connect_peer(id);
|
|
p.state.detail = connected ? "Connecting through Steam..." : "Steam rejected the connection request.";
|
|
return connected;
|
|
}
|
|
namespace {
|
|
// Steam calls this from its own threads.
|
|
std::mutex steam_debug_mutex;
|
|
std::vector<std::string> steam_debug_lines;
|
|
void steam_debug_output(ESteamNetworkingSocketsDebugOutputType, const char *text) {
|
|
std::lock_guard lock(steam_debug_mutex);
|
|
if (text && steam_debug_lines.size() < 400) steam_debug_lines.emplace_back(text);
|
|
}
|
|
} // namespace
|
|
bool SteamTransport::set_steam_debug(bool on) {
|
|
auto &p = *impl_;
|
|
if (!p.debug_output || !p.utils) return false;
|
|
p.debug_output(p.utils, on ? k_ESteamNetworkingSocketsDebugOutputType_Verbose : k_ESteamNetworkingSocketsDebugOutputType_None,
|
|
on ? &steam_debug_output : nullptr);
|
|
return true;
|
|
}
|
|
std::vector<std::string> SteamTransport::take_direct_notes() {
|
|
auto notes = std::exchange(impl_->direct_notes, {});
|
|
std::lock_guard lock(steam_debug_mutex);
|
|
for (auto &line : steam_debug_lines) notes.push_back("Steam: " + std::move(line));
|
|
steam_debug_lines.clear();
|
|
return notes;
|
|
}
|
|
void SteamTransport::set_packing(unsigned milliseconds) { impl_->packing_us = static_cast<int>(std::min(milliseconds, 50U) * 1000); }
|
|
bool SteamTransport::listen_direct(std::uint16_t port) {
|
|
auto &p = *impl_;
|
|
if (!p.state.hosting || !port || !p.listen_ip) return false;
|
|
if (p.direct_listener) return true;
|
|
SteamNetworkingIPAddr address{};
|
|
address.Clear(); // every address of this machine
|
|
address.m_port = port;
|
|
auto options = p.direct_options();
|
|
p.listen_rate = p.send_rate;
|
|
p.direct_listener = p.listen_ip(p.sockets, &address, static_cast<int>(options.size()), options.data());
|
|
return p.direct_listener != 0;
|
|
}
|
|
bool SteamTransport::connect_peer(std::uint64_t id) {
|
|
auto &p = *impl_;
|
|
if (!p.state.ready || p.state.hosting || !p.allowed.contains(id) || id == p.state.local_id)
|
|
return false;
|
|
if (p.links.contains(id))
|
|
return true;
|
|
SteamNetworkingIdentity identity{};
|
|
identity.SetSteamID64(id);
|
|
auto options = p.options();
|
|
const auto connection =
|
|
p.connect(p.sockets, &identity, virtual_port, static_cast<int>(options.size()), options.data());
|
|
p.closed.erase(id);
|
|
// The link's name only labels it in diagnostics: the one already read, if any, and poll
|
|
// fills it in. Asking Steam here waited on the Steam client for ten milliseconds and more,
|
|
// a hitch each time a player came into reach.
|
|
const auto known = p.names.find(id);
|
|
if (connection)
|
|
p.links.emplace(id, Impl::Link{connection, GetTickCount64(), false, known != p.names.end() ? known->second.text : std::string{},
|
|
p.lanes.setup(p.sockets, connection)});
|
|
p.publish_links();
|
|
return connection != 0;
|
|
}
|
|
void SteamTransport::allow_peers(std::span<const Member> members) {
|
|
auto &p = *impl_;
|
|
if (p.state.hosting || !p.host_id)
|
|
return;
|
|
p.allowed.clear();
|
|
p.allowed.insert(p.host_id);
|
|
for (const auto &m : members)
|
|
if (m.id != p.state.local_id)
|
|
p.allowed.insert(m.id);
|
|
for (auto it = p.links.begin(); it != p.links.end();) {
|
|
const auto id = (it++)->first;
|
|
if (!p.allowed.contains(id))
|
|
disconnect(id, "Player left the host roster.");
|
|
}
|
|
}
|
|
void SteamTransport::poll() {
|
|
auto &p = *impl_;
|
|
if (!p.state.ready)
|
|
return;
|
|
++p.frame;
|
|
p.run_callbacks(p.sockets);
|
|
const auto now = GetTickCount64();
|
|
// Link names only label the first connection in diagnostics; the session keeps its own.
|
|
// (From the names already read: asking Steam for every link here was a burst of its calls.)
|
|
if (now >= p.name_refresh) {
|
|
p.name_refresh = now + 30000;
|
|
for (auto &[id, link] : p.links)
|
|
if (const auto known = p.names.find(id); known != p.names.end() && !known->second.text.empty())
|
|
link.name = known->second.text;
|
|
}
|
|
std::deque<SteamNetConnectionStatusChangedCallback_t> events;
|
|
{
|
|
std::lock_guard lock(p.events_mutex);
|
|
events.swap(p.events);
|
|
}
|
|
for (const auto &event : events) {
|
|
if (event.m_info.m_eState != k_ESteamNetworkingConnectionState_Connecting ||
|
|
!event.m_info.m_hListenSocket) {
|
|
// A tracked connection changed state: read it below in this poll.
|
|
for (auto &entry : p.links)
|
|
if (entry.second.handle == event.m_hConn)
|
|
entry.second.recheck = true;
|
|
continue;
|
|
}
|
|
const auto id = event.m_info.m_identityRemote.GetSteamID64();
|
|
const auto existing = p.links.find(id);
|
|
if (existing != p.links.end() && existing->second.handle == event.m_hConn)
|
|
continue;
|
|
const bool direct = p.direct_listener && event.m_info.m_hListenSocket == p.direct_listener;
|
|
// Once a minute a player: one waiting for a place on a full server asks again every
|
|
// second or so, and each ask used to be a line.
|
|
if (direct) {
|
|
if (p.direct_asked.size() > 512) std::erase_if(p.direct_asked, [&](const auto &asked) { return now - asked.second.since >= 60000; });
|
|
auto &asked = p.direct_asked[id];
|
|
if (!asked.since || now - asked.since >= 60000) {
|
|
p.note_direct("Direct connection asked for by " + std::to_string(id) +
|
|
(asked.again ? " (and " + std::to_string(asked.again) + " more times in the last minute)." : "."));
|
|
asked = {now, 0};
|
|
} else {
|
|
++asked.again;
|
|
}
|
|
}
|
|
// (A connection by address names a Steam ID only when Steam has vouched for it.)
|
|
if ((event.m_info.m_hListenSocket != p.listener && !direct) || !p.listener || !id || id == p.state.local_id ||
|
|
(!p.state.hosting && !p.allowed.contains(id)) || p.links.size() >= p.capacity - 1 ||
|
|
existing != p.links.end() ||
|
|
event.m_info.m_identityRemote.m_eType != k_ESteamNetworkingIdentityType_SteamID) {
|
|
p.close(p.sockets, event.m_hConn, 4002, "ReSkate session full or unavailable", false);
|
|
continue;
|
|
}
|
|
if (p.accept(p.sockets, event.m_hConn) != k_EResultOK) {
|
|
p.close(p.sockets, event.m_hConn, 4003, "Cannot accept connection", false);
|
|
continue;
|
|
}
|
|
p.closed.erase(id);
|
|
p.links.emplace(id, Impl::Link{event.m_hConn, now, false, name(id),
|
|
p.lanes.setup(p.sockets, event.m_hConn)}).first->second.direct = direct;
|
|
if (p.send_rate != p.listen_rate) p.apply_rate(event.m_hConn);
|
|
}
|
|
// Connected links report changes through the status callback (above), so read their
|
|
// state only then, plus once a second in case a callback was dropped.
|
|
const bool sweep = now >= p.next_sweep;
|
|
if (sweep)
|
|
p.next_sweep = now + 1000;
|
|
for (auto it = p.links.begin(); it != p.links.end();) {
|
|
const auto id = it->first;
|
|
auto &link = (it++)->second;
|
|
if (link.connected && !link.recheck && !sweep)
|
|
continue;
|
|
link.recheck = false;
|
|
// A guest's direct attempt gets a few seconds; the relays take that long themselves.
|
|
const bool trying_direct = link.direct && !link.connected && !p.state.hosting;
|
|
if (trying_direct && now - link.connecting_since > 5000) {
|
|
p.fall_back(id, link, "no answer");
|
|
continue;
|
|
}
|
|
SteamNetConnectionInfo_t info{};
|
|
if (!p.get_info(p.sockets, link.handle, &info)) {
|
|
if (trying_direct) {
|
|
p.fall_back(id, link, "it went away");
|
|
continue;
|
|
}
|
|
disconnect(id, "Steam connection disappeared.");
|
|
continue;
|
|
}
|
|
if (trying_direct && info.m_eState == k_ESteamNetworkingConnectionState_Connected &&
|
|
(info.m_identityRemote.m_eType != k_ESteamNetworkingIdentityType_SteamID || info.m_identityRemote.GetSteamID64() != id)) {
|
|
// Something else answers at that address: not the server that was asked for.
|
|
p.fall_back(id, link, "another server answered there");
|
|
continue;
|
|
}
|
|
// (A server that answers and closes the connection itself, 1000 to 1999, has given its
|
|
// answer: full, banned, wrong password. That is not a route to try another way.)
|
|
if (trying_direct && !(info.m_eState == k_ESteamNetworkingConnectionState_ClosedByPeer && info.m_eEndReason >= 1000 && info.m_eEndReason < 2000) &&
|
|
(info.m_eState == k_ESteamNetworkingConnectionState_ClosedByPeer ||
|
|
info.m_eState == k_ESteamNetworkingConnectionState_ProblemDetectedLocally)) {
|
|
p.fall_back(id, link, "Steam reason " + std::to_string(info.m_eEndReason));
|
|
continue;
|
|
}
|
|
if (info.m_eState == k_ESteamNetworkingConnectionState_Connected) {
|
|
if (trying_direct) {
|
|
p.direct_note = "Connected straight to the server.";
|
|
p.note_direct(p.direct_note);
|
|
}
|
|
if (!link.connected && !link.prioritized) link.prioritized = p.lanes.setup(p.sockets, link.handle);
|
|
link.connected = true;
|
|
p.state.detail = "Steam connected.";
|
|
} else if (info.m_eState == k_ESteamNetworkingConnectionState_ClosedByPeer ||
|
|
info.m_eState == k_ESteamNetworkingConnectionState_ProblemDetectedLocally) {
|
|
const auto detail = bounded(info.m_szEndDebug, sizeof(info.m_szEndDebug));
|
|
// Steam's end reasons: 1xxx the application closed it, 3xxx a problem at this end,
|
|
// 4xxx one at the other end (4001: it stopped answering), 5xxx anything else.
|
|
if (p.closed.size() >= 256) p.closed.clear();
|
|
p.closed[id] = std::string(info.m_eState == k_ESteamNetworkingConnectionState_ClosedByPeer ? "closed by their game"
|
|
: "connection lost") +
|
|
", Steam reason " + std::to_string(info.m_eEndReason) + (detail.empty() ? std::string{} : " \"" + detail + "\"") +
|
|
"; last " + Impl::measured(link);
|
|
disconnect(id, detail.empty() ? "Steam connection closed." : detail.c_str());
|
|
} else if (now - link.connecting_since > 20000)
|
|
disconnect(id, "Steam connection timed out.");
|
|
}
|
|
if (now >= p.next_measure) {
|
|
p.next_measure = now + 250;
|
|
p.measure();
|
|
}
|
|
p.publish_links();
|
|
}
|
|
std::string SteamTransport::take_closed(std::uint64_t id) {
|
|
auto &p = *impl_;
|
|
const auto found = p.closed.find(id);
|
|
if (found == p.closed.end()) return {};
|
|
auto text = std::move(found->second);
|
|
p.closed.erase(found);
|
|
return text;
|
|
}
|
|
int SteamTransport::send_rate() const { return impl_->send_rate; }
|
|
bool SteamTransport::set_send_rate(int rate) {
|
|
auto &p = *impl_;
|
|
if (rate < min_send_rate || rate > max_send_rate) return false;
|
|
p.send_rate = rate;
|
|
bool all = true;
|
|
for (auto &[id, link] : p.links)
|
|
if (link.handle) all &= p.apply_rate(link.handle);
|
|
return all;
|
|
}
|
|
std::string SteamTransport::relay_status() const {
|
|
const auto &p = *impl_;
|
|
if (!p.relay_network || !p.utils) return "unknown (this Steam does not say)";
|
|
Impl::RelayNetwork status;
|
|
const auto available = p.relay_network(p.utils, &status);
|
|
status.message[sizeof(status.message) - 1] = 0;
|
|
std::string text = available == k_ESteamNetworkingAvailability_Current ? "OK"
|
|
: static_cast<int>(available) < 0 ? "FAILED"
|
|
: "not ready";
|
|
if (status.measuring) text += ", measuring pings to the relays";
|
|
if (status.message[0] && std::string_view(status.message) != "OK") text += std::string(": ") + status.message;
|
|
return text;
|
|
}
|
|
std::int64_t SteamTransport::pending(std::uint64_t id) const {
|
|
const auto found = impl_->links.find(id);
|
|
if (found == impl_->links.end() || !found->second.status_ok) return 0;
|
|
return std::int64_t{found->second.status.m_cbPendingReliable} + found->second.status.m_cbPendingUnreliable;
|
|
}
|
|
std::string SteamTransport::link_report(std::uint64_t id) {
|
|
auto &p = *impl_;
|
|
const auto found = p.links.find(id);
|
|
if (found == p.links.end()) return "no connection";
|
|
if (found->second.handle && found->second.connected) p.read_status(found->second);
|
|
return Impl::measured(found->second);
|
|
}
|
|
std::vector<TransportLink> SteamTransport::links() {
|
|
auto &p = *impl_;
|
|
std::vector<TransportLink> out;
|
|
out.reserve(p.links.size());
|
|
for (auto &[id, link] : p.links) {
|
|
TransportLink row;
|
|
row.id = id;
|
|
row.connected = link.connected;
|
|
if (link.handle && link.connected) p.read_status(link);
|
|
if (link.connected && link.status_ok) {
|
|
const auto &s = link.status;
|
|
row.measured = true;
|
|
row.ping_ms = s.m_nPing;
|
|
row.pending_bytes = s.m_cbPendingReliable + s.m_cbPendingUnreliable;
|
|
row.send_rate = s.m_nSendRateBytesPerSecond;
|
|
row.quality_local = s.m_flConnectionQualityLocal;
|
|
row.quality_remote = s.m_flConnectionQualityRemote;
|
|
row.out_bps = s.m_flOutBytesPerSec;
|
|
row.in_bps = s.m_flInBytesPerSec;
|
|
row.queue_us = queue_time(s);
|
|
row.direct = link.direct;
|
|
SteamNetConnectionInfo_t info{};
|
|
if (p.get_info && p.get_info(p.sockets, link.handle, &info)) {
|
|
// A location's ID is its name's characters packed into a number.
|
|
const auto named = [](SteamNetworkingPOPID id) {
|
|
std::string text;
|
|
for (const unsigned shift : {16U, 8U, 0U, 24U})
|
|
if (const auto c = static_cast<char>((id >> shift) & 0xff); c > 32 && c < 127) text.push_back(c);
|
|
return text;
|
|
};
|
|
row.relay = named(info.m_idPOPRelay);
|
|
row.remote_relay = named(info.m_idPOPRemote);
|
|
}
|
|
}
|
|
out.push_back(row);
|
|
}
|
|
return out;
|
|
}
|
|
std::string SteamTransport::name(std::uint64_t id) {
|
|
auto &p = *impl_;
|
|
if (!p.friends || !id)
|
|
return {};
|
|
// Each lookup is two calls into the Steam client. Names change rarely: ask again
|
|
// after 10 s, or after 1 s while Steam does not know the name yet.
|
|
// A name read before is given as it is while it is asked again, and only one of those a
|
|
// frame is asked: the roster asks for every player's every two seconds, and their names ran
|
|
// out together, so one frame asked Steam for all of them (ten milliseconds and more each).
|
|
const auto now = GetTickCount64();
|
|
if (const auto cached = p.names.find(id); cached != p.names.end()) {
|
|
if (now < cached->second.expires) return cached->second.text;
|
|
if (!cached->second.text.empty() && p.name_asked_frame == p.frame) return cached->second.text;
|
|
if (!cached->second.text.empty()) p.name_asked_frame = p.frame;
|
|
}
|
|
p.request_name(p.friends, id, true);
|
|
const auto *value = p.persona_name(p.friends, id);
|
|
auto text = value ? bounded(value, 128) : std::string{};
|
|
#ifdef _WIN32
|
|
while (!text.empty() && !MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(),
|
|
static_cast<int>(text.size()), nullptr, 0))
|
|
text.pop_back();
|
|
#else
|
|
// Minimal UTF-8 truncation: drop trailing continuation bytes (game servers
|
|
// never reach here anyway; names come from hello messages).
|
|
while (!text.empty() && (static_cast<unsigned char>(text.back()) & 0xC0) == 0x80) text.pop_back();
|
|
if (!text.empty() && (static_cast<unsigned char>(text.back()) & 0xC0) == 0xC0) text.pop_back();
|
|
#endif
|
|
for (auto &c : text)
|
|
if (static_cast<unsigned char>(c) < 32 || c == 127)
|
|
c = ' ';
|
|
if (text == "[unknown]")
|
|
text.clear();
|
|
if (p.names.size() >= 1024)
|
|
p.names.clear();
|
|
p.names[id] = {text, now + (text.empty() ? 1000U : 10000U)};
|
|
return text;
|
|
}
|
|
bool SteamTransport::send(std::uint64_t id, std::span<const std::uint8_t> bytes, bool reliable, bool fresh,
|
|
TrafficLane lane) {
|
|
auto &p = *impl_;
|
|
const auto found = p.links.find(id);
|
|
if (found == p.links.end() || !p.admit(found->second, bytes, fresh, lane))
|
|
return false;
|
|
const int flags = p.send_flags(reliable, lane);
|
|
const auto result = found->second.prioritized
|
|
? p.lanes.transmit(p.sockets, found->second.handle, bytes, flags, lane)
|
|
: p.send_message(p.sockets, found->second.handle, bytes.data(), static_cast<uint32>(bytes.size()), flags, nullptr);
|
|
return p.record(result, reliable, bytes);
|
|
}
|
|
void SteamTransport::send_batch(std::span<TransportSend> messages) {
|
|
auto &p = *impl_;
|
|
std::vector<SteamNetworkingMessage_t *> batch;
|
|
std::vector<std::size_t> owners;
|
|
batch.reserve(messages.size());
|
|
owners.reserve(messages.size());
|
|
for (std::size_t i = 0; i < messages.size(); ++i) {
|
|
auto &m = messages[i];
|
|
m.sent = false;
|
|
const auto found = p.links.find(m.id);
|
|
if (found == p.links.end() || !p.admit(found->second, m.bytes, m.fresh, m.lane))
|
|
continue;
|
|
const int flags = p.send_flags(m.reliable, m.lane);
|
|
const auto &link = found->second;
|
|
if (!link.prioritized || m.bytes.empty()) {
|
|
// Without lanes (older Steam adapters) each message goes out as send() sends it.
|
|
const auto result = link.prioritized
|
|
? p.lanes.transmit(p.sockets, link.handle, m.bytes, flags, m.lane)
|
|
: p.send_message(p.sockets, link.handle, m.bytes.data(), static_cast<uint32>(m.bytes.size()), flags,
|
|
nullptr);
|
|
m.sent = p.record(result, m.reliable, m.bytes);
|
|
continue;
|
|
}
|
|
auto *message = p.lanes.allocate(p.lanes.utils, static_cast<int>(m.bytes.size()));
|
|
if (!message) {
|
|
m.sent = p.record(k_EResultLimitExceeded, m.reliable, m.bytes);
|
|
continue;
|
|
}
|
|
std::memcpy(message->m_pData, m.bytes.data(), m.bytes.size());
|
|
message->m_conn = link.handle;
|
|
message->m_nFlags = flags;
|
|
message->m_idxLane = static_cast<uint16>(m.lane);
|
|
batch.push_back(message);
|
|
owners.push_back(i);
|
|
}
|
|
if (batch.empty())
|
|
return;
|
|
std::vector<int64> results(batch.size());
|
|
// As in SteamLanes::transmit: Steam owns/frees every message on success and failure.
|
|
p.lanes.send(p.sockets, static_cast<int>(batch.size()), batch.data(), results.data(), true);
|
|
for (std::size_t i = 0; i < batch.size(); ++i) {
|
|
auto &m = messages[owners[i]];
|
|
const auto result = results[i] > 0 ? k_EResultOK
|
|
: results[i] < 0 ? static_cast<EResult>(-results[i]) : k_EResultFail;
|
|
m.sent = p.record(result, m.reliable, m.bytes);
|
|
}
|
|
}
|
|
std::vector<TransportMessage> SteamTransport::receive() {
|
|
auto &p = *impl_;
|
|
std::vector<TransportMessage> result;
|
|
for (auto it = p.links.begin(); it != p.links.end();) {
|
|
const auto id = it->first;
|
|
const auto &link = (it++)->second;
|
|
if (!link.connected)
|
|
continue;
|
|
// Everything that has arrived, not one batch a call: a dedicated server sends a busy
|
|
// session's player a few thousand messages a second, and what a slow frame leaves
|
|
// behind would wait in Steam, later and later, taking memory until it is read.
|
|
bool failed{};
|
|
for (unsigned batch = 0; batch < 64 && !failed; ++batch) {
|
|
SteamNetworkingMessage_t *messages[128]{};
|
|
const int count = p.receive_messages(p.sockets, link.handle, messages, 128);
|
|
if (count < 0) {
|
|
failed = true;
|
|
break;
|
|
}
|
|
for (int i = 0; i < count; ++i) {
|
|
auto *message = messages[i];
|
|
if (!message)
|
|
continue;
|
|
struct Release {
|
|
Impl &p;
|
|
SteamNetworkingMessage_t *m;
|
|
~Release() { p.release_message(m); }
|
|
} release{p, message};
|
|
if (message->m_cbSize <= 0 || message->m_cbSize > static_cast<int>(max_packet) ||
|
|
!message->m_pData) {
|
|
++p.state.dropped;
|
|
++p.state.invalid_messages;
|
|
continue;
|
|
}
|
|
const auto *data = static_cast<const std::uint8_t *>(message->m_pData);
|
|
result.push_back({id, {data, data + message->m_cbSize},
|
|
message->m_usecTimeReceived > 0 ? static_cast<std::uint64_t>(message->m_usecTimeReceived) : 0});
|
|
++p.state.received;
|
|
p.state.received_bytes += static_cast<std::uint64_t>(message->m_cbSize);
|
|
}
|
|
if (count < 128) break;
|
|
}
|
|
if (failed) disconnect(id, "Steam receive failed.");
|
|
}
|
|
return result;
|
|
}
|
|
bool SteamTransport::socket_test() {
|
|
auto &p = *impl_;
|
|
if (!open())
|
|
return false;
|
|
HSteamNetConnection a{}, b{};
|
|
if (!p.socket_pair(p.sockets, &a, &b, false, nullptr, nullptr)) {
|
|
p.state.detail = "Steam socket-pair creation failed.";
|
|
return false;
|
|
}
|
|
struct Close {
|
|
Impl &p;
|
|
HSteamNetConnection a, b;
|
|
~Close() {
|
|
p.close(p.sockets, a, 1000, "Test complete", false);
|
|
p.close(p.sockets, b, 1000, "Test complete", false);
|
|
}
|
|
} close{p, a, b};
|
|
Packet probe;
|
|
probe.kind = PacketKind::away;
|
|
probe.session = 1;
|
|
probe.epoch = 1;
|
|
const auto bytes = encode(probe);
|
|
const bool prioritized = p.lanes.setup(p.sockets, a);
|
|
for (unsigned lane = 0; lane < (prioritized ? 3U : 1U); ++lane) {
|
|
const auto result = prioritized
|
|
? p.lanes.transmit(p.sockets, a, bytes, k_nSteamNetworkingSend_ReliableNoNagle, static_cast<TrafficLane>(lane))
|
|
: p.send_message(p.sockets, a, bytes.data(), static_cast<uint32>(bytes.size()),
|
|
k_nSteamNetworkingSend_ReliableNoNagle, nullptr);
|
|
if (result != k_EResultOK) {
|
|
p.state.detail = "Steam socket-pair send failed.";
|
|
return false;
|
|
}
|
|
SteamNetworkingMessage_t *message{};
|
|
const int count = p.receive_messages(p.sockets, b, &message, 1);
|
|
if (count != 1 || !message) {
|
|
p.state.detail = "Socket-pair receive not ready; run the test again.";
|
|
return false;
|
|
}
|
|
const bool ok = message->m_cbSize == static_cast<int>(bytes.size()) && message->m_pData &&
|
|
message->m_idxLane == lane && std::memcmp(message->m_pData, bytes.data(), bytes.size()) == 0;
|
|
p.release_message(message);
|
|
if (!ok) {
|
|
p.state.detail = "Steam socket-pair payload or lane mismatch.";
|
|
return false;
|
|
}
|
|
}
|
|
p.state.detail = prioritized ? "Steam socket-pair round trip passed on all three priority lanes."
|
|
: "Steam socket-pair round trip passed (single-lane fallback).";
|
|
return true;
|
|
}
|
|
const TransportStatus &SteamTransport::status() const { return impl_->state; }
|
|
} // namespace dingosdk::multiplayer
|