mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 15:07:06 +02:00
Poses and skater sound between a dedicated server and its players use new formats. Lobbies and the links between games are unchanged. Poses (pose_codec.h, pose_batch.h): - A pose is rounded once (rotations to 0.08 degrees, positions to a millimetre) and sent as its differences from a pose of the same player the receiver has acknowledged. Several players' poses share one message. - The receiver acknowledges messages once a tick; the sender builds only on acknowledged poses, or on the whole pose it sent reliably. Loss costs the poses in that packet and nothing more. - Fingers are not sent to players further than "finger_distance" (25 m). - Games upload their own poses the same way, and the server acknowledges. - About 300 B a pose at full rate against about 660 B before. - The server no longer rounds rotations by distance: it saved little. Sound (sound_codec.h): - Each sample is sent as what changed since the one before, numbers rounded to 1/1024. One reliable message a tick carries every skater a player can hear. A stream is sent whole at its start and every two seconds. - About 106 B a message against 368 B before, on a recording of real play. Direct connections: - "connection": "relay" (default) or "direct", with "direct_address" and "direct_port". A direct server listens on UDP and is listed with its address; players still sign in through Steam. - Games connect straight to servers that offer it and fall back to the relays after 5 s. Setting and "mp direct-connections" to turn it off. Server: - "bone_scale_limit" is 1 by default: resized body parts are off unless a server allows them. - Logs Steam's relay network status at start-up and in "net", and watches the Steam sign-in. - "pack_ms" (10): how long Steam may hold a message to pack it with others. - "steam_debug": logs what Steam's networking says, for connection faults. - "relay_everything": sends poses and sound through the server even between games that have linked, for testing with few players. - "net" shows pose sizes by rate, the share sent without fingers, and whole poses sent. Game: - Only ReSkate's nametags are shown; the game's own are removed. - "mp pose-dump <seconds>" records the local skater's poses and sound. Tests: pose_codec_tests covers exact round trips, chains of differences, held fingers, damaged input, the sender and receiver over a link that delays, loses and reorders, and the sound format; with a recording as its argument it prints sizes on real play. Protocol 44: servers and games must both update.
297 lines
14 KiB
C++
297 lines
14 KiB
C++
#include "steam_server_browser.h"
|
|
#include "Extension/Multiplayer/Net/protocol.h"
|
|
#include "Extension/Multiplayer/developer_identity.h"
|
|
#include "Engine/Core/Log/logging.h"
|
|
#include "Engine/Core/Text/word_filter.h"
|
|
#include <Windows.h>
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <map>
|
|
#include <charconv>
|
|
#include <cstring>
|
|
|
|
namespace dingosdk::multiplayer {
|
|
namespace {
|
|
constexpr std::uint32_t skate_app = 3354750;
|
|
template <class T> T symbol(HMODULE module, const char *name) {
|
|
#pragma warning(push)
|
|
#pragma warning(disable : 4191)
|
|
return reinterpret_cast<T>(GetProcAddress(module, name));
|
|
#pragma warning(pop)
|
|
}
|
|
// matchmakingtypes.h (CSteamID is pack(1) there, so the ID is unaligned).
|
|
#pragma pack(push, 1)
|
|
struct PackedSteamId {
|
|
std::uint64_t value;
|
|
};
|
|
#pragma pack(pop)
|
|
struct ServerItem {
|
|
std::uint16_t connection_port, query_port;
|
|
std::uint32_t ip;
|
|
int ping;
|
|
bool had_successful_response, do_not_refresh;
|
|
char game_dir[32], map[32], description[64];
|
|
std::uint32_t app_id;
|
|
int players, max_players, bots;
|
|
bool password, secure;
|
|
std::uint32_t time_last_played;
|
|
int server_version;
|
|
char name[64], tags[128];
|
|
PackedSteamId steam_id;
|
|
};
|
|
static_assert(sizeof(ServerItem) == 372);
|
|
struct Filter {
|
|
char key[256], value[256];
|
|
};
|
|
// ISteamMatchmakingServerListResponse. Steam calls it from the game's own
|
|
// callback pump, on whichever thread that runs; rows are read by polling
|
|
// GetServerDetails instead, so it only records completion.
|
|
class Response {
|
|
public:
|
|
virtual void ServerResponded(void *, int) {}
|
|
virtual void ServerFailedToRespond(void *, int) {}
|
|
virtual void RefreshComplete(void *, int) { done = true; }
|
|
std::atomic<bool> done{};
|
|
};
|
|
struct Api {
|
|
HMODULE module{};
|
|
void *(*servers)(){};
|
|
void *(*request)(void *, std::uint32_t, Filter **, std::uint32_t, Response *){};
|
|
void *(*lan)(void *, std::uint32_t, Response *){};
|
|
ServerItem *(*details)(void *, void *, int){};
|
|
int (*count)(void *, void *){};
|
|
bool (*refreshing)(void *, void *){};
|
|
void (*release)(void *, void *){};
|
|
bool open() {
|
|
if (module) return servers != nullptr;
|
|
module = GetModuleHandleW(L"steam_api64.dll");
|
|
if (!module) return false;
|
|
servers = symbol<decltype(servers)>(module, "SteamAPI_SteamMatchmakingServers_v002");
|
|
request = symbol<decltype(request)>(module, "SteamAPI_ISteamMatchmakingServers_RequestInternetServerList");
|
|
lan = symbol<decltype(lan)>(module, "SteamAPI_ISteamMatchmakingServers_RequestLANServerList");
|
|
details = symbol<decltype(details)>(module, "SteamAPI_ISteamMatchmakingServers_GetServerDetails");
|
|
count = symbol<decltype(count)>(module, "SteamAPI_ISteamMatchmakingServers_GetServerCount");
|
|
refreshing = symbol<decltype(refreshing)>(module, "SteamAPI_ISteamMatchmakingServers_IsRefreshing");
|
|
release = symbol<decltype(release)>(module, "SteamAPI_ISteamMatchmakingServers_ReleaseRequest");
|
|
if (!request || !details || !count || !refreshing || !release) servers = nullptr;
|
|
return servers != nullptr;
|
|
}
|
|
};
|
|
Api &api() {
|
|
static Api value;
|
|
return value;
|
|
}
|
|
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);
|
|
}
|
|
// A listing's text as it is shown: whole UTF-8 characters, no control characters, at most
|
|
// `limit` bytes.
|
|
std::string tidy(std::string_view text, std::size_t limit) {
|
|
auto result = clean_chat_text(text);
|
|
if (result.size() > limit) {
|
|
auto cut = limit;
|
|
while (cut && (static_cast<unsigned char>(result[cut]) & 0xC0) == 0x80) --cut;
|
|
result.resize(cut);
|
|
}
|
|
return result;
|
|
}
|
|
} // namespace
|
|
|
|
std::optional<MultiplayerLobby> read_server_tags(std::string_view tags, std::uint64_t steam_id) {
|
|
if (!game_server_steam_id(steam_id)) return {};
|
|
MultiplayerLobby row;
|
|
row.id = row.owner = steam_id;
|
|
row.dedicated = true;
|
|
bool reskate{}, version{};
|
|
std::uint64_t secret{};
|
|
const auto integer = [](std::string_view text, int &out) {
|
|
const auto result = std::from_chars(text.data(), text.data() + text.size(), out);
|
|
return result.ec == std::errc{} && result.ptr == text.data() + text.size();
|
|
};
|
|
while (!tags.empty()) {
|
|
const auto comma = tags.find(',');
|
|
const auto tag = tags.substr(0, comma);
|
|
tags = comma == std::string_view::npos ? std::string_view{} : tags.substr(comma + 1);
|
|
if (tag == "reskate") { reskate = true; continue; }
|
|
if (tag.empty()) continue;
|
|
const auto value = tag.substr(1);
|
|
switch (tag[0]) {
|
|
case 'v': version = value == std::to_string(protocol_version); break;
|
|
case 'k': {
|
|
const auto result = std::from_chars(value.data(), value.data() + value.size(), secret, 16);
|
|
if (result.ec != std::errc{} || result.ptr != value.data() + value.size()) secret = 0;
|
|
break;
|
|
}
|
|
case 'p': if (!integer(value, row.players)) row.players = 0; break;
|
|
case 'c': if (!integer(value, row.capacity)) row.capacity = 0; break;
|
|
case 'w': row.password_required = value == "1"; break;
|
|
case 'd': {
|
|
// A port, or "a.b.c.d:port" when the server is reached at another address than it is listed under.
|
|
const auto colon = value.find(':');
|
|
std::uint32_t address{};
|
|
bool ok = true;
|
|
if (colon != std::string_view::npos) {
|
|
auto rest = value.substr(0, colon);
|
|
unsigned parts{};
|
|
while (ok && parts < 4) {
|
|
const auto dot = rest.find('.');
|
|
int part{};
|
|
ok = integer(rest.substr(0, dot), part) && part >= 0 && part <= 255;
|
|
address = (address << 8) | static_cast<std::uint32_t>(part & 255);
|
|
++parts;
|
|
if (dot == std::string_view::npos) break;
|
|
rest = rest.substr(dot + 1);
|
|
}
|
|
ok = ok && parts == 4 && address;
|
|
}
|
|
const auto port = colon == std::string_view::npos ? value : value.substr(colon + 1);
|
|
if (!ok || !integer(port, row.direct_port) || row.direct_port < 1 || row.direct_port > 65535) row.direct_port = 0;
|
|
else row.direct_ip = address; // 0: the address the server is listed under
|
|
break;
|
|
}
|
|
case 'm': row.map = std::string(value); break;
|
|
// The name is last and may itself contain anything but commas.
|
|
case 'n': row.name = std::string(value) + (tags.empty() ? "" : "," + std::string(tags)); tags = {}; break;
|
|
default: break;
|
|
}
|
|
}
|
|
// Anyone can list a server: a row's numbers are held to what a server can have, and its
|
|
// text is cleaned (tidy) before it is shown or logged.
|
|
if (!reskate || !version || !secret || row.capacity < 1 || row.capacity > static_cast<int>(max_remote_players)) return {};
|
|
row.players = std::clamp(row.players, 0, row.capacity);
|
|
row.code = format_invite({steam_id, secret});
|
|
row.name = tidy(row.name, 64);
|
|
row.map = tidy(row.map, 128);
|
|
if (row.name.empty()) row.name = "ReSkate server";
|
|
return row;
|
|
}
|
|
|
|
SteamServerBrowser::~SteamServerBrowser() { release(); }
|
|
void SteamServerBrowser::release() {
|
|
for (auto &search : searches_) {
|
|
if (search.request && api().release) api().release(servers_, search.request);
|
|
delete static_cast<Response *>(search.response);
|
|
}
|
|
searches_.clear();
|
|
}
|
|
const MultiplayerLobby *SteamServerBrowser::find(std::uint64_t id) const {
|
|
for (const auto &row : rows_)
|
|
if (row.id == id) return &row;
|
|
return nullptr;
|
|
}
|
|
void SteamServerBrowser::refresh(std::uint64_t now) {
|
|
if (!api().open()) return;
|
|
// Steam stops answering a game that asks often (an empty list, for minutes): one search at a
|
|
// time, a quarter of a minute apart, and three quarters after one that came back empty.
|
|
// Opening or refreshing the browser sooner shows what the last search found.
|
|
if (started_ && (!searches_.empty() || now - started_ < (internet_listed_ ? 15000000 : 45000000))) return;
|
|
servers_ = api().servers();
|
|
if (!servers_) return;
|
|
release();
|
|
// What the last search found stays until this one has an internet list of its own; what
|
|
// it says about a server replaces the old copy.
|
|
++search_;
|
|
internet_listed_ = false;
|
|
for (auto &[id, entry] : found_) entry.answered = false;
|
|
// The internet list, filtered by Steam on the tags; and the LAN list, whose
|
|
// servers answer directly (live map and ping) and show up at once.
|
|
static Filter filter{"gametagsand", {}};
|
|
const auto wanted = "reskate,v" + std::to_string(protocol_version);
|
|
std::memcpy(filter.value, wanted.c_str(), wanted.size() + 1);
|
|
Filter *filters[] = {&filter};
|
|
for (const bool lan : {false, true}) {
|
|
auto *response = new Response;
|
|
void *request = lan ? (api().lan ? api().lan(servers_, skate_app, response) : nullptr)
|
|
: api().request(servers_, skate_app, filters, 1, response);
|
|
if (request) searches_.push_back({request, response, !lan});
|
|
else delete response;
|
|
}
|
|
started_ = now;
|
|
next_poll_ = now;
|
|
}
|
|
void SteamServerBrowser::read(std::uint64_t now) {
|
|
for (const auto &search : searches_) {
|
|
const int count = api().count(servers_, search.request);
|
|
if (search.internet && count > 0) internet_listed_ = true;
|
|
for (int i = 0; i < count && i < 512; ++i) {
|
|
const auto *item = api().details(servers_, search.request, i);
|
|
if (!item) continue;
|
|
auto row = read_server_tags(bounded(item->tags, sizeof item->tags), item->steam_id.value);
|
|
if (!row) continue;
|
|
if (row->direct_port && !row->direct_ip) row->direct_ip = item->ip; // the address Steam lists the server at
|
|
auto &entry = found_[row->id];
|
|
entry.search = search_;
|
|
entry.listed = now;
|
|
if (!search.internet) entry.lan = true;
|
|
// A direct answer is live; the tags come from Steam's master list, which
|
|
// can lag behind a change of map.
|
|
const bool answered = item->had_successful_response;
|
|
if (answered) {
|
|
if (const auto name = tidy(bounded(item->name, sizeof item->name), 64); !name.empty()) row->name = name;
|
|
if (const auto map = tidy(bounded(item->map, sizeof item->map), 128); !map.empty()) row->map = map;
|
|
row->ping = item->ping;
|
|
}
|
|
if (answered || !entry.answered) {
|
|
entry.row = std::move(*row);
|
|
entry.answered = answered;
|
|
}
|
|
const std::pair address{item->ip, item->connection_port};
|
|
if (item->ip && std::find(entry.addresses.begin(), entry.addresses.end(), address) == entry.addresses.end())
|
|
entry.addresses.push_back(address);
|
|
}
|
|
}
|
|
// A restarted anonymous server gets a new, higher Steam ID, while Steam lists the old
|
|
// one for a while. Only one can hold an address and port: keep the newest, or the one
|
|
// signed in with a login token (its ID never changes, and is lower than any anonymous one).
|
|
// Servers named with bad words, or with characters a server name cannot have, are
|
|
// never shown (a server refuses such a name too, but anyone can list one).
|
|
std::vector<MultiplayerLobby> rows;
|
|
for (const auto &[id, entry] : found_) {
|
|
if (!valid_server_name(entry.row.name) || text::contains_bad_words(entry.row.name)) continue;
|
|
if (blocked_server(id) || (server_tokens_required() && !persistent_server_steam_id(id) && !entry.lan)) continue;
|
|
const bool replaced = std::any_of(found_.begin(), found_.end(), [&](const auto &other) {
|
|
const bool kept = persistent_server_steam_id(id), other_kept = persistent_server_steam_id(other.first);
|
|
return (kept != other_kept ? other_kept : other.first > id) && std::any_of(entry.addresses.begin(), entry.addresses.end(), [&](const auto &a) {
|
|
return std::find(other.second.addresses.begin(), other.second.addresses.end(), a) != other.second.addresses.end();
|
|
});
|
|
});
|
|
if (!replaced) rows.push_back(entry.row);
|
|
}
|
|
rows_ = std::move(rows);
|
|
}
|
|
void SteamServerBrowser::tick(std::uint64_t now) {
|
|
if (searches_.empty() || now < next_poll_) return;
|
|
next_poll_ = now + 250000;
|
|
read(now);
|
|
// Pings of servers behind a NAT never answer; stop waiting after 15 s.
|
|
// Steam can hold an internet search back for seconds before it starts (it does when another
|
|
// was made shortly before), and it is not "refreshing" while it waits: an internet search
|
|
// with nothing listed yet is given six seconds before that counts as finished.
|
|
const bool done = std::all_of(searches_.begin(), searches_.end(), [&](const auto &search) {
|
|
if (static_cast<Response *>(search.response)->done) return true;
|
|
const std::uint64_t patience = search.internet && !internet_listed_ ? 6000000 : 1000000;
|
|
return now - started_ > patience && !api().refreshing(servers_, search.request);
|
|
}) || now - started_ > 15000000;
|
|
if (done) {
|
|
release();
|
|
// An internet list with servers in it is the whole truth: what it left out is gone.
|
|
// An empty one is Steam not answering (see the header), and it has stayed that way for
|
|
// minutes for a player who searched often: keep what was listed in the last quarter
|
|
// of an hour. A server that closed meanwhile only fails to join.
|
|
const auto before = found_.size();
|
|
std::erase_if(found_, [&](const auto &entry) {
|
|
return entry.second.search != search_ && (internet_listed_ || now - entry.second.listed > 900000000);
|
|
});
|
|
if (found_.size() != before) read(now);
|
|
std::string list;
|
|
for (const auto &row : rows_)
|
|
list += (list.empty() ? ": " : "; ") + row.name + (official_server(row.id) ? " [official]" : "") + " (" + row.map + ", " + std::to_string(row.players) + "/" +
|
|
std::to_string(row.capacity) + (row.ping >= 0 ? ", " + std::to_string(row.ping) + " ms" : std::string{}) + ")";
|
|
logging::log(logging::Level::info, logging::Channel::runtime, "Server browser: {} ReSkate server{} found{}.",
|
|
rows_.size(), rows_.size() == 1 ? "" : "s", list);
|
|
}
|
|
}
|
|
} // namespace dingosdk::multiplayer
|