mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 17:27:19 +02:00
Merge remote-tracking branch 'origin/main' into codex/fix-fakie-forward-boost
This commit is contained in:
commit
ac44591057
62 files changed
+1221
-140
No files matched your search
+6
-2
@@ -84,6 +84,10 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS)
|
||||
dingosdk_lz4 dingosdk_zstd)
|
||||
endif()
|
||||
add_test(NAME server_config COMMAND dingosdk_server_config_tests)
|
||||
add_executable(dingosdk_server_release_tests Server/Test/server_release_tests.cpp Server/server_release.cpp)
|
||||
target_include_directories(dingosdk_server_release_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
target_link_libraries(dingosdk_server_release_tests PRIVATE dingosdk_json)
|
||||
add_test(NAME server_release COMMAND dingosdk_server_release_tests)
|
||||
add_executable(dingosdk_global_bans_tests Server/Test/global_bans_tests.cpp Server/global_bans.cpp
|
||||
Extension/Multiplayer/developer_identity.cpp)
|
||||
target_include_directories(dingosdk_global_bans_tests PRIVATE "${PROJECT_SOURCE_DIR}")
|
||||
@@ -140,8 +144,8 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32)
|
||||
add_executable(dingosdk_multiplayer_tests
|
||||
Extension/Multiplayer/Test/protocol_tests.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Remote/playback_buffers.cpp
|
||||
Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Steam/steam_server_browser.cpp Server/steam_server.cpp
|
||||
Extension/Throwdowns/throwdown_wire.cpp)
|
||||
target_link_libraries(dingosdk_multiplayer_tests PRIVATE dingosdk_lz4 dingosdk_zstd dingosdk_logging dingosdk_word_filter)
|
||||
Extension/Throwdowns/throwdown_wire.cpp Extension/Multiplayer/developer_identity.cpp)
|
||||
target_link_libraries(dingosdk_multiplayer_tests PRIVATE dingosdk_lz4 dingosdk_zstd dingosdk_logging dingosdk_word_filter dingosdk_json)
|
||||
add_test(NAME multiplayer_protocol COMMAND dingosdk_multiplayer_tests)
|
||||
add_executable(dingosdk_throwdown_relay_tests Extension/Throwdowns/Test/throwdown_relay_tests.cpp
|
||||
Extension/Throwdowns/throwdown_relay.cpp Extension/Throwdowns/throwdown_wire.cpp)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#pragma once
|
||||
#include <charconv>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
@@ -27,4 +28,21 @@ inline std::optional<ObjectPlacement> parse_object_placement(std::string_view ar
|
||||
: current == ObjectPlacement::host_only ? ObjectPlacement::nobody : ObjectPlacement::everyone;
|
||||
return {};
|
||||
}
|
||||
// How many objects each player may have placed in a session, set by the host or the server:
|
||||
// 0 is no limit beyond the protocol's own (max_object_limit). The host and a server's admins
|
||||
// are not held to it. A player over it keeps what everyone already sees of theirs; only the
|
||||
// objects past the limit are left out.
|
||||
inline constexpr unsigned max_object_limit = 1024;
|
||||
// What a server or a host starts with, until they choose otherwise.
|
||||
inline constexpr unsigned default_object_limit = 100;
|
||||
inline constexpr bool valid_object_limit(std::uint64_t value) noexcept { return value <= max_object_limit; }
|
||||
// Accepts a number from 1 to max_object_limit, and off|none|0 for no limit.
|
||||
inline std::optional<unsigned> parse_object_limit(std::string_view argument) noexcept {
|
||||
if (argument == "off" || argument == "none") return 0U;
|
||||
unsigned value{};
|
||||
const auto parsed = std::from_chars(argument.data(), argument.data() + argument.size(), value);
|
||||
if (argument.empty() || parsed.ec != std::errc{} || parsed.ptr != argument.data() + argument.size() || !valid_object_limit(value))
|
||||
return {};
|
||||
return value;
|
||||
}
|
||||
} // namespace dingosdk
|
||||
@@ -13,16 +13,16 @@
|
||||
|
||||
namespace dingosdk {
|
||||
constexpr unsigned max_map_rotation = 1440; // minutes a server's rotation keeps one map, at most
|
||||
// A dedicated server's name: 1 to 64 letters, digits, spaces and - _ [ ] ( ), with a
|
||||
// A dedicated server's name: 1 to 64 letters, digits, spaces and - _ / [ ] ( ), with a
|
||||
// letter or digit among them and no space at either end. A server refuses any other
|
||||
// name, and the server browser does not show one.
|
||||
inline constexpr char server_name_rule[] = "1 to 64 letters, numbers, spaces and - _ [ ] ( )";
|
||||
inline constexpr char server_name_rule[] = "1 to 64 letters, numbers, spaces and - _ / [ ] ( )";
|
||||
[[nodiscard]] constexpr bool valid_server_name(std::string_view name) noexcept {
|
||||
if (name.empty() || name.size() > 64 || name.front() == ' ' || name.back() == ' ') return false;
|
||||
bool named{};
|
||||
for (const auto c : name) {
|
||||
const bool word = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9');
|
||||
if (!word && std::string_view(" -_[]()").find(c) == std::string_view::npos) return false;
|
||||
if (!word && std::string_view(" -_/[]()").find(c) == std::string_view::npos) return false;
|
||||
named |= word;
|
||||
}
|
||||
return named;
|
||||
@@ -34,8 +34,19 @@ struct MultiplayerLobby {
|
||||
int players = 1, capacity = multiplayer_player_limit;
|
||||
// A dedicated server rather than a player's lobby: joined by its code.
|
||||
bool dedicated{};
|
||||
// A dedicated server the ReSkate team runs (developer_identity.h): first in the browser.
|
||||
bool official{};
|
||||
int ping = -1; // ms, when the server answered directly
|
||||
// Steam friends skating there now, by name (steam_social.h).
|
||||
std::vector<std::string> friends;
|
||||
};
|
||||
// "Ana, Ben and 2 more": the friends in a server, as a browser row has room for.
|
||||
inline std::string lobby_friends_text(const MultiplayerLobby &lobby, std::size_t named = 2) {
|
||||
std::string text;
|
||||
for (std::size_t i = 0; i < lobby.friends.size() && i < named; ++i) text += (i ? ", " : "") + lobby.friends[i];
|
||||
if (lobby.friends.size() > named) text += " and " + std::to_string(lobby.friends.size() - named) + " more";
|
||||
return text;
|
||||
}
|
||||
struct MultiplayerPlayer {
|
||||
std::uint64_t id{};
|
||||
std::string name;
|
||||
@@ -86,7 +97,7 @@ struct MultiplayerChatLine {
|
||||
std::string name, text;
|
||||
bool local{}; // sent by this player
|
||||
// The sender's role, as their nametag shows it: its colour (IM_COL32 layout, 0 = none)
|
||||
// and a tag shown in a box before the name ("Dev", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend" or empty).
|
||||
// and a tag shown in a box before the name ("Dev", "Staff", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend" or empty).
|
||||
std::uint32_t color{};
|
||||
std::string tag;
|
||||
// With the chat filter on, `text` is masked and this is the line as sent (same length), so
|
||||
@@ -119,6 +130,9 @@ struct MultiplayerModel {
|
||||
std::vector<MultiplayerBan> bans;
|
||||
unsigned tps = multiplayer_default_tps;
|
||||
ObjectPlacement object_placement = ObjectPlacement::everyone;
|
||||
// Objects each player may have placed in this session (0: no limit), the limit this
|
||||
// player is held to (0 for the host and a server's admins), and how many they have placed.
|
||||
unsigned object_limit{}, object_limit_own{}, objects_placed{};
|
||||
// Host setting: whether guests may use noclip / No Bail (the host and server admins always may).
|
||||
bool guest_noclip{true}, guest_no_bail{true}, guest_boosts{true};
|
||||
// Host setting: guests skate with the host's physics tuning (on a dedicated server: the
|
||||
@@ -142,7 +156,7 @@ struct MultiplayerModel {
|
||||
float chat_bubbles_distance{40.f};
|
||||
float chat_bubbles_duration{5.f};
|
||||
int chat_bubbles_history{3};
|
||||
// Local: the tag the ReSkate backend gives this player ("Dev", "Creator", "Centrix" or "Homie"; empty
|
||||
// Local: the tag the ReSkate backend gives this player ("Dev", "Staff", "Creator", "Centrix" or "Homie"; empty
|
||||
// for most players) and its role colour, and whether they show it, and the animated items
|
||||
// that come with it, to everyone.
|
||||
std::string identity_tag;
|
||||
@@ -150,15 +164,16 @@ struct MultiplayerModel {
|
||||
bool identity_tag_shown{true}, identity_items_shown{true};
|
||||
// Local: how each of this player's marked cosmetics is coloured, for the Special page: what
|
||||
// they wear in each slot ("Top", "Shoes", ...), then the parts of their board. mode: 0 what
|
||||
// their list gives (`identity_animation`: "RAINBOW", "RED", "BLUE" or "GOLD"), 1 off, 2 a gradient
|
||||
// between the two colours they picked, 3 the first of them alone. speed: 0 normal, 1 slow,
|
||||
// 2 fast.
|
||||
// their list gives (`identity_animation`: "RAINBOW", "GREEN", "RED", "BLUE" or "GOLD"), 1 off, 2 a gradient
|
||||
// between the two colours they picked, 3 the first of them alone, 4 the rainbow (offered
|
||||
// when `identity_rainbow`: to the staff). speed: 0 normal, 1 slow, 2 fast.
|
||||
struct IdentityStyle {
|
||||
std::string name;
|
||||
int mode{}, speed{};
|
||||
std::array<float, 3> from{}, to{};
|
||||
};
|
||||
std::vector<IdentityStyle> identity_styles;
|
||||
bool identity_rainbow{};
|
||||
std::string identity_animation;
|
||||
float voice_range = default_voice_range; // how far the host (or server) forwards proximity voice
|
||||
// In a dedicated server's session: the server is the host but not a player.
|
||||
|
||||
@@ -122,24 +122,28 @@ inline std::pair<std::array<std::uint8_t, 3>, std::array<std::uint8_t, 3>> stand
|
||||
if (mark == IdentityList::content_creator) return {{0x88, 0x23, 0x23}, {0xff, 0x48, 0x70}};
|
||||
if (mark == IdentityList::homie) return {{0xad, 0x6c, 0x1c}, {0xff, 0xe6, 0x67}};
|
||||
if (mark == IdentityList::centrix) return {{0x1f, 0x7b, 0xff}, {0xff, 0xff, 0xff}};
|
||||
if (mark == IdentityList::staff) return {{0x10, 0x80, 0x38}, {0x6a, 0xf0, 0x8c}};
|
||||
return {{0x6e, 0x20, 0xff}, {0xb4, 0x78, 0xff}};
|
||||
}
|
||||
// What a cosmetic does for a player on `mark`'s list (nobody else's does anything) with the
|
||||
// style they chose on the Special page. Left standard, a developer's cycles the rainbow, a
|
||||
// content creator's goes from deep red through bright red to a reddish pink and back, and a
|
||||
// homie's from deep gold through bright gold to yellow and back, and Centrix's from blue
|
||||
// through light blue to white and back. No style asks for the rainbow:
|
||||
// it is a developer's standard, and nobody else's.
|
||||
// homie's from deep gold through bright gold to yellow and back, Centrix's from blue
|
||||
// through light blue to white and back, and the staff's from deep green through bright green
|
||||
// to a light one and back. The rainbow is a developer's standard, and a style a developer or
|
||||
// a member of staff can pick (rainbow_style); nobody else's item takes it.
|
||||
inline ItemAnimation item_animation(std::optional<multiplayer::IdentityList> mark, const multiplayer::MarkStyle &style) noexcept {
|
||||
using multiplayer::IdentityList;
|
||||
using multiplayer::MarkMode;
|
||||
ItemAnimation out;
|
||||
if (!mark || style.mode == MarkMode::off) return out;
|
||||
out.on = true;
|
||||
out.rainbow = style.mode == MarkMode::standard && *mark == IdentityList::developer;
|
||||
out.rainbow = (style.mode == MarkMode::standard && *mark == IdentityList::developer) ||
|
||||
(multiplayer::rainbow_style(style) && (*mark == IdentityList::developer || *mark == IdentityList::staff));
|
||||
const std::uint64_t normal = out.rainbow ? 8000 : 3000;
|
||||
out.period = style.speed == 1 ? normal * 2 : style.speed == 2 ? normal / 2 : normal;
|
||||
if (style.mode == MarkMode::solid) {
|
||||
if (out.rainbow) {
|
||||
} else if (style.mode == MarkMode::solid) {
|
||||
out.stops.fill(material_color(style.from));
|
||||
} else if (style.mode == MarkMode::gradient) {
|
||||
const auto from = material_color(style.from), to = material_color(style.to);
|
||||
@@ -150,7 +154,9 @@ inline ItemAnimation item_animation(std::optional<multiplayer::IdentityList> mar
|
||||
constexpr std::array<Color, 3> red{{{.2f, .004f, .01f}, {.8f, .03f, .03f}, {.8f, .05f, .13f}}};
|
||||
constexpr std::array<Color, 3> gold{{{.34f, .12f, .006f}, {.8f, .5f, .06f}, {.8f, .64f, .11f}}};
|
||||
constexpr std::array<Color, 3> blue{{{.01f, .16f, .8f}, {.3f, .5f, .8f}, {.8f, .8f, .8f}}};
|
||||
out.stops = *mark == IdentityList::content_creator ? red : *mark == IdentityList::centrix ? blue : gold;
|
||||
constexpr std::array<Color, 3> green{{{.006f, .2f, .04f}, {.05f, .8f, .2f}, {.35f, .8f, .4f}}};
|
||||
out.stops = *mark == IdentityList::content_creator ? red : *mark == IdentityList::centrix ? blue :
|
||||
*mark == IdentityList::staff ? green : gold;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -34,15 +34,17 @@ inline constexpr std::uint32_t nametag_white = 0xffffffffU, nametag_developer =
|
||||
nametag_creator = 0xff6464ffU, // red
|
||||
nametag_homie = 0xff7ae2ffU, // gold
|
||||
nametag_centrix = 0xffff7b1fU, // blue (#1F7BFF)
|
||||
nametag_staff = 0xff71cc2eU, // green (#2ECC71)
|
||||
nametag_admin = 0xffc674ffU, // pink
|
||||
nametag_host = 0xffffc85eU, // blue
|
||||
nametag_friend = 0xff8ae07bU; // green
|
||||
// Developers, content creators, homies and Centrix shimmer from these to their role colour
|
||||
// (Centrix between white and its blue).
|
||||
// Developers, staff, content creators, homies and Centrix shimmer from these to their role
|
||||
// colour (Centrix between white and its blue).
|
||||
inline constexpr std::uint32_t nametag_developer_start = 0xffff206eU, // #6E20FF, IM_COL32 layout
|
||||
nametag_creator_start = 0xff2e10c8U, // #C8102E
|
||||
nametag_homie_start = 0xff008ae0U, // #E08A00
|
||||
nametag_centrix_start = 0xffffffffU; // white
|
||||
nametag_centrix_start = 0xffffffffU, // white
|
||||
nametag_staff_start = 0xff3a6b0bU; // #0B6B3A
|
||||
|
||||
// Client thread, once per rendered frame: everyone to label and the local skater's position
|
||||
// (for distances). Also reads whether the game is hiding its own nametags right now.
|
||||
|
||||
@@ -558,6 +558,8 @@ std::vector<std::uint8_t> encode(const Packet &p, bool compact_pose, std::uint32
|
||||
if (!valid_multiplayer_tps(p.tps)) throw std::invalid_argument("Invalid session TPS");
|
||||
w.integer(p.tps, 1);
|
||||
w.integer(static_cast<std::uint8_t>(p.object_placement), 1);
|
||||
if (!valid_object_limit(p.object_limit)) throw std::invalid_argument("Invalid object limit");
|
||||
w.integer(p.object_limit, 2);
|
||||
w.integer(p.force_world_layers, 1);
|
||||
// One mode per world-layer catalog row; both peers read the same catalog
|
||||
// from the same game build.
|
||||
@@ -864,6 +866,9 @@ std::optional<Packet> decode(std::span<const std::uint8_t> bytes) noexcept {
|
||||
const auto placement = r.integer(1);
|
||||
if (!valid_object_placement(placement)) return {};
|
||||
p.object_placement = static_cast<ObjectPlacement>(placement);
|
||||
const auto object_limit = r.integer(2);
|
||||
if (!valid_object_limit(object_limit)) return {};
|
||||
p.object_limit = static_cast<unsigned>(object_limit);
|
||||
const auto forced = r.integer(1);
|
||||
if (forced > 1) return {};
|
||||
p.force_world_layers = forced != 0;
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace dingosdk::multiplayer {
|
||||
constexpr std::size_t max_skater_bones = 512, max_board_bones = 64;
|
||||
constexpr std::size_t max_packet = 24576;
|
||||
constexpr std::size_t packet_header_size = 64;
|
||||
constexpr std::uint16_t protocol_version = 42;
|
||||
constexpr std::uint16_t protocol_version = 43;
|
||||
constexpr std::size_t max_throwdown_message = 4096;
|
||||
// Packet::tuning: the host's SkatePhysicsTuning differences (Extension/Skater/physics_tuning.h).
|
||||
constexpr std::size_t max_physics_tuning = 16384;
|
||||
@@ -94,8 +94,8 @@ enum class PartyAction : std::uint8_t {
|
||||
};
|
||||
bool valid_party_request(PartyAction action, std::uint64_t player) noexcept;
|
||||
// Steam accounts in the public universe. Players are individual accounts; a
|
||||
// dedicated server signs in anonymously as a game server (type 3 or 4) and gets
|
||||
// a new ID each time it starts.
|
||||
// dedicated server is a game server: anonymous (type 4) with a new ID each time
|
||||
// it starts, or signed in with a login token (type 3) with the same ID always.
|
||||
inline bool individual_steam_id(std::uint64_t id) noexcept {
|
||||
return (id >> 56) == 1 && ((id >> 52) & 15) == 1 && (id & 0xffffffffULL);
|
||||
}
|
||||
@@ -103,6 +103,9 @@ inline bool game_server_steam_id(std::uint64_t id) noexcept {
|
||||
const auto type = (id >> 52) & 15;
|
||||
return (id >> 56) == 1 && (type == 3 || type == 4) && (id & 0xffffffffULL);
|
||||
}
|
||||
inline bool persistent_server_steam_id(std::uint64_t id) noexcept {
|
||||
return game_server_steam_id(id) && ((id >> 52) & 15) == 3;
|
||||
}
|
||||
// A player's own name as sent in their hello: at most this many bytes.
|
||||
constexpr std::size_t max_member_name = 64;
|
||||
constexpr std::size_t max_admin_text = 320;
|
||||
@@ -168,6 +171,8 @@ struct Packet {
|
||||
// lock their editor and native tools from it; the host enforces it by
|
||||
// freezing guest layouts (see publish_guest_objects).
|
||||
ObjectPlacement object_placement = ObjectPlacement::everyone;
|
||||
// Objects each player may have placed (object_placement.h); 0: no limit.
|
||||
unsigned object_limit{};
|
||||
// Bumped each time the host deletes all guest objects. Guests delete their
|
||||
// own session objects when it changes after their first roster.
|
||||
std::uint32_t object_clears{};
|
||||
|
||||
@@ -43,6 +43,12 @@ inline constexpr std::array<std::string_view, 12> mark_item_names{
|
||||
"Top", "Bottoms", "Shoes", "Socks", "Hat", "Glasses", "Outfit", "Costume", "Deck", "Grip tape", "Trucks", "Wheels"};
|
||||
inline constexpr std::size_t mark_items = mark_item_names.size(), skater_mark_items = 8;
|
||||
using MarkStyles = std::array<MarkStyle, mark_items>;
|
||||
// The rainbow, as a style a developer or a member of staff picks (a developer's standard is
|
||||
// the rainbow already). Players on builds from before it could be picked must be able to read
|
||||
// the style, so it travels as one they know: a solid colour, with this in the second colour a
|
||||
// solid does not use. They show the solid colour; nobody else's item takes the rainbow from it.
|
||||
inline constexpr std::array<std::uint8_t, 3> rainbow_marker{0x52, 0x42, 0x57}; // "RBW"
|
||||
inline bool rainbow_style(const MarkStyle &style) noexcept { return style.mode == MarkMode::solid && style.to == rainbow_marker; }
|
||||
inline bool valid_mark_style(const MarkStyle &style) noexcept {
|
||||
return style.mode <= MarkMode::solid && style.speed <= 2;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,16 @@ inline bool valid_object_chunk(const ObjectChunk &chunk) {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
// What of an owner's uploaded `layout` everyone else is shown under a limit of `limit` objects
|
||||
// (0: all of it). The objects already shown (`shared`) keep their place, so placing one too
|
||||
// many never takes away one that is there; the rest fill what room is left, oldest ID first.
|
||||
inline std::vector<NetworkObject> limited_layout(std::vector<NetworkObject> layout, const std::map<std::uint64_t, NetworkObject> &shared,
|
||||
std::size_t limit) {
|
||||
if (!limit || layout.size() <= limit) return layout;
|
||||
std::stable_partition(layout.begin(), layout.end(), [&](const NetworkObject &object) { return shared.contains(object.id); });
|
||||
layout.resize(limit);
|
||||
return layout;
|
||||
}
|
||||
// Per-owner state. A multipart replacement/delta becomes visible only after all
|
||||
// parts validate. Reliable delivery keeps parts ordered; stale revisions cannot
|
||||
// resurrect deleted objects. No wire IDs are native entity handles.
|
||||
|
||||
@@ -73,6 +73,8 @@ void stop(Session &s, std::string reason) {
|
||||
s.voice_policy = {};
|
||||
s.tps = multiplayer_default_tps;
|
||||
s.object_placement = ObjectPlacement::everyone;
|
||||
s.object_limit = 0;
|
||||
set_lobby_object_limit(0);
|
||||
s.server_admin = false;
|
||||
s.server_bans.clear();
|
||||
s.server_ban_total = 0;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "session_internal.h"
|
||||
#include "Extension/Multiplayer/Steam/steam_social.h"
|
||||
#include "Extension/Multiplayer/Hud/native_indicators.h"
|
||||
#include "Extension/Multiplayer/Hud/native_player_ui.h"
|
||||
#include "Extension/Multiplayer/Hud/custom_nametags.h"
|
||||
@@ -26,6 +27,10 @@ void apply_distances(Session &s, const MultiplayerDistances &distances) {
|
||||
// previous band's hysteresis or waiting for its old send deadline.
|
||||
for (auto &peer : active_peers(s)) peer.pose_delivery = {};
|
||||
}
|
||||
void apply_object_limit(Session &s, unsigned limit) {
|
||||
s.object_limit = limit;
|
||||
set_lobby_object_limit(s.mode == Mode::host || s.server_admin ? 0 : limit);
|
||||
}
|
||||
void apply_object_placement(Session &s, ObjectPlacement policy) {
|
||||
s.object_placement = policy;
|
||||
// On a dedicated server "host only" means its admins.
|
||||
@@ -287,6 +292,21 @@ std::string edit_object_placement(Session &s, std::string_view argument) {
|
||||
: *policy == ObjectPlacement::host_only ? "Only you can place objects. Guests' objects are frozen."
|
||||
: "Object placement is disabled for everyone. Existing objects stay.";
|
||||
}
|
||||
std::string edit_object_limit(Session &s, std::string_view argument) {
|
||||
if (s.mode != Mode::host) return "Only the session host can change the object limit.";
|
||||
const auto limit = parse_object_limit(argument);
|
||||
if (!limit) return "Use a number of objects from 1 to " + std::to_string(max_object_limit) + ", or off.";
|
||||
// Guests' games stop them at the limit when the roster arrives; enforcement is the host
|
||||
// showing everyone no more than that of each guest's layout (publish_guest_objects).
|
||||
apply_object_limit(s, *limit);
|
||||
for (auto &peer : active_peers(s)) peer.shared_from = 0; // look at every layout again
|
||||
s.roster_dirty = true;
|
||||
load_host_preferences(s);
|
||||
s.host_preferences.object_limit = *limit;
|
||||
save_host_preferences(s);
|
||||
return *limit ? "Each guest can place up to " + std::to_string(*limit) + " objects."
|
||||
: std::string("Guests can place as many objects as they like.");
|
||||
}
|
||||
// Removes every guest's objects for everyone, whatever the placement policy.
|
||||
// The host's own objects are its saved park and are left alone.
|
||||
std::string clear_guest_objects(Session &s, std::string_view) {
|
||||
@@ -350,19 +370,28 @@ std::string edit_own_items(Session &, std::string_view argument) {
|
||||
std::string edit_mark_style(Session &, std::string_view argument) {
|
||||
std::istringstream in{std::string(argument)};
|
||||
unsigned item{}, mode{}, from{}, to{}, speed{};
|
||||
if (!(in >> item >> mode >> std::hex >> from >> to >> std::dec >> speed) || item >= mark_items || mode > 3 ||
|
||||
if (!(in >> item >> mode >> std::hex >> from >> to >> std::dec >> speed) || item >= mark_items || mode > 4 ||
|
||||
from > 0xffffff || to > 0xffffff || speed > 2)
|
||||
return "That is not a cosmetic style.";
|
||||
// Mode 4, the rainbow, is the staff's to pick (a developer's standard is it already). It is
|
||||
// kept, and sent, as a solid colour older builds can show (rainbow_style).
|
||||
const auto social = steam_social_snapshot();
|
||||
const auto mark = social ? identity_mark(social->local.id) : std::nullopt;
|
||||
if (mode == 4 && mark != IdentityList::staff) return "The rainbow is not one of your styles.";
|
||||
const auto colour = [](unsigned value) {
|
||||
return std::array<std::uint8_t, 3>{static_cast<std::uint8_t>(value >> 16), static_cast<std::uint8_t>(value >> 8),
|
||||
static_cast<std::uint8_t>(value)};
|
||||
};
|
||||
auto styles = developer_hoodie_detail::own_styles.load();
|
||||
styles[item] = {static_cast<MarkMode>(mode), colour(from), colour(to), static_cast<std::uint8_t>(speed)};
|
||||
styles[item] = {static_cast<MarkMode>(mode == 4 ? 3 : mode), colour(from), colour(to), static_cast<std::uint8_t>(speed)};
|
||||
if (mode == 4) styles[item].from = developer_hoodie_detail::standard_picks(*mark).first, styles[item].to = rainbow_marker;
|
||||
// A solid colour whose unused second colour happens to be the rainbow's marker is not one.
|
||||
else if (rainbow_style(styles[item])) styles[item].to[2] ^= 1;
|
||||
developer_hoodie_detail::own_styles.store(styles);
|
||||
profile_runtime::set_local_values({{"IdentityStyles", Json(developer_hoodie_detail::mark_styles_text(styles))}});
|
||||
return std::string(mark_item_names[item]) +
|
||||
(mode == 1 ? ": off." : mode == 2 ? ": your gradient." : mode == 3 ? ": your color." : ": back to the usual.");
|
||||
(mode == 1 ? ": off." : mode == 2 ? ": your gradient." : mode == 3 ? ": your color." : mode == 4 ? ": rainbow."
|
||||
: ": back to the usual.");
|
||||
}
|
||||
// Hidden chat still receives lines, so showing it again brings back the conversation.
|
||||
std::string edit_chat_visible(Session &s, std::string_view argument) {
|
||||
@@ -531,7 +560,7 @@ bool own_mark_command(std::string_view action) {
|
||||
bool queue_command(std::string_view action, std::string_view argument, std::string_view password) {
|
||||
if (launcher::offline_mode() && !own_mark_command(action)) return false;
|
||||
if ((action != "host" && action != "host-config" && action != "join" && action != "join-lobby" && action != "join-friend-lobby" && action != "stop" &&
|
||||
action != "distances" && action != "object-placement" && action != "kick" && action != "clear-objects" &&
|
||||
action != "distances" && action != "object-placement" && action != "object-limit" && action != "kick" && action != "clear-objects" &&
|
||||
action != "nametags" && action != "nametag-style" && action != "chat-visible" && action != "chat-filter" &&
|
||||
action != "chat-bubbles" && action != "chat-bubbles-own" && action != "chat-bubbles-distance" &&
|
||||
action != "chat-bubbles-duration" && action != "chat-bubbles-history" &&
|
||||
@@ -571,7 +600,7 @@ std::string command(std::string_view action, std::string_view argument, std::str
|
||||
// their own: the same menu actions, sent to the server. "server" sends
|
||||
// any server console command.
|
||||
if (dedicated_host(s) && (action == "server" || (s.server_admin &&
|
||||
(action == "distances" || action == "object-placement" || action == "voice-allow" || action == "voice-range" ||
|
||||
(action == "distances" || action == "object-placement" || action == "object-limit" || action == "voice-allow" || action == "voice-range" ||
|
||||
action == "clear-objects" || action == "kick" || action == "ban" || action == "unban" ||
|
||||
action == "world-layer-sync" || action == "noclip-allow" || action == "nobail-allow" ||
|
||||
action == "tpall" || action == "tphere" || action == "boosts-allow" || action == "tuning-enforce")))) {
|
||||
@@ -706,7 +735,7 @@ std::string command(std::string_view action, std::string_view argument, std::str
|
||||
// Host-only settings check the mode themselves; guests get a refusal.
|
||||
using Setting = std::string (*)(Session &, std::string_view);
|
||||
static constexpr std::pair<std::string_view, Setting> settings[] = {
|
||||
{"object-placement", edit_object_placement}, {"kick", kick_player},
|
||||
{"object-placement", edit_object_placement}, {"object-limit", edit_object_limit}, {"kick", kick_player},
|
||||
{"noclip-allow", edit_guest_noclip}, {"nobail-allow", edit_guest_no_bail},
|
||||
{"boosts-allow", edit_guest_boosts}, {"tuning-enforce", edit_enforce_tuning},
|
||||
{"score-check", edit_score_check},
|
||||
@@ -759,8 +788,12 @@ std::string command(std::string_view action, std::string_view argument, std::str
|
||||
return s.lobbies.status().browser;
|
||||
}
|
||||
if (action == "join-lobby" || action == "join-friend-lobby") {
|
||||
if (s.mode != Mode::off) {
|
||||
s.status = "Leave your current session before joining another lobby.";
|
||||
// A guest can hop straight to a server or lobby they pick in the browser: they leave
|
||||
// this one first. A host ends everyone's session by leaving, so they end it themselves;
|
||||
// and a Steam friend's join offer, which can arrive unasked, never replaces a session.
|
||||
if (s.mode != Mode::off && (s.mode != Mode::join || action != "join-lobby")) {
|
||||
s.status = s.mode == Mode::join ? "Leave your current session before joining another lobby."
|
||||
: "End your session before joining another lobby.";
|
||||
publish(s);
|
||||
return s.status;
|
||||
}
|
||||
@@ -770,10 +803,27 @@ std::string command(std::string_view action, std::string_view argument, std::str
|
||||
const auto result = std::from_chars(argument.data(), argument.data() + argument.size(), id);
|
||||
if (result.ec != std::errc{} || result.ptr != argument.data() + argument.size() || !id)
|
||||
return "Invalid lobby selection. Refresh the browser.";
|
||||
// A dedicated server's row carries its join code.
|
||||
if (s.mode == Mode::join) {
|
||||
if (id == s.joined_public_lobby) return "You are already there.";
|
||||
stop(s, "Switching servers...");
|
||||
}
|
||||
// A dedicated server's row carries its join code. It is a public listing like a
|
||||
// lobby's: friends are told the player is there, and can follow them in.
|
||||
if (const auto *server = s.servers.find(id)) {
|
||||
const auto code = server->code;
|
||||
return command("join", code, input.password);
|
||||
auto reply = command("join", code, input.password);
|
||||
if (s.mode == Mode::join) {
|
||||
s.joined_public_lobby = id;
|
||||
publish(s);
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
// A friend on a server this game has not listed yet: only the list has its code.
|
||||
if (game_server_steam_id(id)) {
|
||||
s.servers.refresh(now_us());
|
||||
s.status = "That server is not in your server list yet. Open the server browser and join it there.";
|
||||
publish(s);
|
||||
return s.status;
|
||||
}
|
||||
if (!s.transport.open())
|
||||
return s.transport.status().detail;
|
||||
@@ -845,6 +895,7 @@ std::string command(std::string_view action, std::string_view argument, std::str
|
||||
invitation = parse_invite(argument);
|
||||
if (!invitation)
|
||||
return refuse("Invalid join code. Paste the complete SteamID-session code from the host.");
|
||||
if (blocked_server(invitation->steam_id)) return refuse(std::string(blocked_server_notice));
|
||||
}
|
||||
stop(s, "Starting multiplayer...");
|
||||
s.chat.clear(); // A new session starts with an empty chat.
|
||||
@@ -887,6 +938,7 @@ std::string command(std::string_view action, std::string_view argument, std::str
|
||||
apply_distances(s, remembered.distances);
|
||||
s.voice_range = remembered.voice_range;
|
||||
apply_object_placement(s, remembered.placement);
|
||||
apply_object_limit(s, remembered.object_limit);
|
||||
apply_guest_tools(s, remembered.guest_noclip, remembered.guest_no_bail, remembered.guest_boosts);
|
||||
s.enforce_tuning = remembered.enforce_tuning;
|
||||
s.score_check = remembered.score_check;
|
||||
|
||||
@@ -146,6 +146,7 @@ struct Session {
|
||||
std::uint64_t next_publish{}, last_client_log{}, next_party_update{};
|
||||
MultiplayerDistances distances;
|
||||
ObjectPlacement object_placement = ObjectPlacement::everyone;
|
||||
unsigned object_limit{}; // objects each player may have placed; 0: no limit
|
||||
// What guests may use: the host's choice, or the host's roster for a guest.
|
||||
bool guest_noclip = true, guest_no_bail = true, guest_boosts = true;
|
||||
// Host: guests skate with its physics tuning. Guest: the host's roster says so (a
|
||||
@@ -242,6 +243,7 @@ struct Session {
|
||||
std::string lobby_name;
|
||||
MultiplayerDistances distances;
|
||||
ObjectPlacement placement = ObjectPlacement::everyone;
|
||||
unsigned object_limit = default_object_limit;
|
||||
float voice_range = default_voice_range;
|
||||
bool guest_noclip = true, guest_no_bail = true, guest_boosts = true;
|
||||
bool enforce_tuning = true;
|
||||
@@ -363,7 +365,7 @@ void refresh_friends(Session &s);
|
||||
void add_chat(Session &s, std::uint64_t sender, std::string name, std::string text, bool local = false, bool marks = true);
|
||||
// The colour and badge of one of the backend's categories.
|
||||
std::pair<std::uint32_t, std::string> mark_role(IdentityList list);
|
||||
// A player's role colour and badge ("Dev", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend" or none), shown in chat
|
||||
// A player's role colour and badge ("Dev", "Staff", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend" or none), shown in chat
|
||||
// and on their nametag. `local`: the local player.
|
||||
std::pair<std::uint32_t, std::string> player_role(Session &s, std::uint64_t id, bool local, bool marks = true);
|
||||
// Sends one line from this player; returns why not when it cannot.
|
||||
@@ -433,6 +435,8 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now);
|
||||
// session_commands.cpp
|
||||
void apply_distances(Session &s, const MultiplayerDistances &distances);
|
||||
void apply_object_placement(Session &s, ObjectPlacement policy);
|
||||
// The session's limit, and this game's own share of it: none while hosting or as a server's admin.
|
||||
void apply_object_limit(Session &s, unsigned limit);
|
||||
// Stores what guests may use and applies it to the local player (the host and a dedicated
|
||||
// server's admins are exempt).
|
||||
void apply_guest_tools(Session &s, bool noclip, bool no_bail, bool boosts);
|
||||
|
||||
@@ -202,6 +202,7 @@ void apply_roster(Session &s, const Packet &p, std::uint64_t now) {
|
||||
for (auto &peer : active_peers(s)) peer.pose_delivery = {};
|
||||
}
|
||||
apply_object_placement(s, p.object_placement);
|
||||
apply_object_limit(s, p.object_limit); // after server_admin, which exempts an admin
|
||||
apply_guest_tools(s, p.guest_noclip, p.guest_no_bail, p.guest_boosts);
|
||||
s.enforce_tuning = p.enforce_tuning;
|
||||
s.server_votes = dedicated_host(s) ? p.server_votes : 0;
|
||||
|
||||
@@ -26,6 +26,8 @@ void publish_guest_objects(Session &s) {
|
||||
if (peer.handshaken && peer.objects.revision() != peer.shared_from) {
|
||||
auto layout = peer.objects.layout();
|
||||
std::erase_if(layout, [&](const auto &object) { return peer.cleared.contains(object.id); });
|
||||
// The host's limit on each guest's objects, which a patched client cannot place past.
|
||||
layout = limited_layout(std::move(layout), peer.shared.objects(), s.object_limit);
|
||||
peer.shared.replace(layout);
|
||||
peer.shared_from = peer.objects.revision();
|
||||
}
|
||||
@@ -429,6 +431,7 @@ void send_roster(Session &s, std::uint64_t now) {
|
||||
p.voice_range = s.voice_range;
|
||||
p.distances = s.distances;
|
||||
p.object_placement = s.object_placement;
|
||||
p.object_limit = s.object_limit;
|
||||
p.guest_noclip = s.guest_noclip;
|
||||
p.guest_no_bail = s.guest_no_bail;
|
||||
p.guest_boosts = s.guest_boosts;
|
||||
|
||||
@@ -50,6 +50,8 @@ void load_host_preferences(Session &s) {
|
||||
p.voice_range = value->get<float>();
|
||||
if (const auto value = number("Host.ObjectPlacement"); value && *value >= 0 && valid_object_placement(static_cast<std::uint64_t>(*value)))
|
||||
p.placement = static_cast<ObjectPlacement>(*value);
|
||||
if (const auto value = number("Host.ObjectLimit"); value && *value >= 0 && valid_object_limit(static_cast<std::uint64_t>(*value)))
|
||||
p.object_limit = static_cast<unsigned>(*value);
|
||||
p.guest_noclip = profile_runtime::local_preference("Host.GuestNoclip").value_or(true);
|
||||
p.guest_no_bail = profile_runtime::local_preference("Host.GuestNoBail").value_or(true);
|
||||
p.guest_boosts = profile_runtime::local_preference("Host.GuestBoosts").value_or(true);
|
||||
@@ -70,6 +72,7 @@ void save_host_preferences(const Session &s) {
|
||||
{"Host.Distance.HalfReturn", static_cast<std::int64_t>(p.distances.half_rate_return)},
|
||||
{"Host.Distance.LowStart", static_cast<std::int64_t>(p.distances.low_rate_start)},
|
||||
{"Host.ObjectPlacement", static_cast<std::int64_t>(p.placement)},
|
||||
{"Host.ObjectLimit", static_cast<std::int64_t>(p.object_limit)},
|
||||
{"Host.GuestNoclip", p.guest_noclip},
|
||||
{"Host.GuestNoBail", p.guest_no_bail},
|
||||
{"Host.GuestBoosts", p.guest_boosts},
|
||||
@@ -147,6 +150,9 @@ void publish(Session &s, const NativeFrame *local) {
|
||||
view.tps = s.tps;
|
||||
view.distances = s.distances;
|
||||
view.object_placement = s.object_placement;
|
||||
view.object_limit = s.object_limit;
|
||||
view.object_limit_own = s.mode == Mode::host || s.server_admin ? 0 : s.object_limit; // as apply_object_limit gives this game
|
||||
view.objects_placed = s.mode == Mode::off ? 0 : static_cast<unsigned>(s.local_objects.objects().size());
|
||||
view.guest_noclip = s.guest_noclip;
|
||||
view.guest_no_bail = s.guest_no_bail;
|
||||
view.guest_boosts = s.guest_boosts;
|
||||
@@ -177,18 +183,22 @@ void publish(Session &s, const NativeFrame *local) {
|
||||
std::tie(view.identity_tag_colour, view.identity_tag) = mark_role(*mark);
|
||||
view.identity_animation =
|
||||
*mark == IdentityList::developer ? "RAINBOW" : *mark == IdentityList::content_creator ? "RED" :
|
||||
*mark == IdentityList::centrix ? "BLUE" : "GOLD";
|
||||
*mark == IdentityList::centrix ? "BLUE" : *mark == IdentityList::staff ? "GREEN" : "GOLD";
|
||||
// A developer's standard is the rainbow already.
|
||||
view.identity_rainbow = *mark == IdentityList::staff;
|
||||
const auto styles = developer_hoodie_detail::own_styles.load();
|
||||
const auto standard = developer_hoodie_detail::standard_picks(*mark);
|
||||
view.identity_styles.resize(styles.size());
|
||||
for (std::size_t i = 0; i < styles.size(); ++i) {
|
||||
// A cosmetic never given colours shows its list's own in the pickers.
|
||||
const bool picked = styles[i].mode == MarkMode::gradient || styles[i].mode == MarkMode::solid ||
|
||||
styles[i].from != styles[i].to || styles[i].from != std::array<std::uint8_t, 3>{};
|
||||
// The rainbow has no colours of its own to show either.
|
||||
const bool rainbow = view.identity_rainbow && rainbow_style(styles[i]);
|
||||
const bool picked = !rainbow && (styles[i].mode == MarkMode::gradient || styles[i].mode == MarkMode::solid ||
|
||||
styles[i].from != styles[i].to || styles[i].from != std::array<std::uint8_t, 3>{});
|
||||
const auto &from = picked ? styles[i].from : standard.first, &to = picked ? styles[i].to : standard.second;
|
||||
auto &shown = view.identity_styles[i];
|
||||
shown.name = mark_item_names[i];
|
||||
shown.mode = static_cast<int>(styles[i].mode);
|
||||
shown.mode = rainbow ? 4 : static_cast<int>(styles[i].mode);
|
||||
shown.speed = styles[i].speed;
|
||||
for (std::size_t part = 0; part < 3; ++part)
|
||||
shown.from[part] = static_cast<float>(from[part]) / 255.f, shown.to[part] = static_cast<float>(to[part]) / 255.f;
|
||||
@@ -261,7 +271,16 @@ void publish(Session &s, const NativeFrame *local) {
|
||||
view.browser_status = lobby.browser;
|
||||
// Dedicated servers first: they are always up and never a stranger's own session.
|
||||
view.lobbies = s.servers.rows();
|
||||
// Looked up as the list is shown: the team's list can arrive, or change, after a server was found.
|
||||
for (auto &server : view.lobbies) server.official = official_server(server.id);
|
||||
view.lobbies.insert(view.lobbies.end(), lobby.rows.begin(), lobby.rows.end());
|
||||
// Steam friends by the public server or lobby their game says they are in.
|
||||
if (const auto social = steam_social_snapshot())
|
||||
for (const auto &player : social->friends) {
|
||||
if (!player.session) continue;
|
||||
const auto row = std::find_if(view.lobbies.begin(), view.lobbies.end(), [&](const auto &entry) { return entry.id == player.session; });
|
||||
if (row != view.lobbies.end() && row->friends.size() < 16) row->friends.push_back(player.name.empty() ? std::string("A friend") : player.name);
|
||||
}
|
||||
view.status = s.status;
|
||||
view.native_status = s.native_status;
|
||||
view.audio_captured = captured_audio_frames();
|
||||
@@ -540,6 +559,7 @@ std::pair<std::uint32_t, std::string> mark_role(IdentityList list) {
|
||||
case IdentityList::developer: return {nametag_developer, "Dev"};
|
||||
case IdentityList::content_creator: return {nametag_creator, "Creator"};
|
||||
case IdentityList::centrix: return {nametag_centrix, "Centrix"};
|
||||
case IdentityList::staff: return {nametag_staff, "Staff"};
|
||||
default: return {nametag_homie, "Homie"};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ struct Api {
|
||||
LobbyCallback lobby_callback;
|
||||
PresenceCallback presence_callback;
|
||||
std::uint64_t next_open{}, next_presence{};
|
||||
std::string owned, previous;
|
||||
std::string owned, previous, session;
|
||||
bool reported_error{};
|
||||
void open() {
|
||||
const auto loaded = GetModuleHandleW(L"steam_api64.dll");
|
||||
@@ -139,6 +139,10 @@ void tick_steam_friend_join() noexcept {
|
||||
if (now >= api->next_presence) {
|
||||
api->next_presence = now + 2000;
|
||||
api->presence(steam_join_presence(model()));
|
||||
// Only written when it changes; cleared (an empty value removes the key) on leaving.
|
||||
if (auto session = steam_session_presence(model()); session != api->session &&
|
||||
api->set(api->friends, steam_session_key.data(), session.c_str()))
|
||||
api->session = std::move(session);
|
||||
}
|
||||
} catch (const std::exception& error) {
|
||||
if (!api->reported_error) {
|
||||
|
||||
@@ -19,6 +19,21 @@ inline std::string steam_join_presence(const MultiplayerModel& model) {
|
||||
model.players >= model.capacity || (model.hosting ? !model.public_host || !model.lobby_listed : !model.connected)) return {};
|
||||
return std::string(steam_join_prefix) + std::to_string(model.public_lobby);
|
||||
}
|
||||
// The public server or lobby the player is in, told to Steam friends so their server lists
|
||||
// can show who is where. Unlike the join offer above it is set in a full or password-protected
|
||||
// session too; a private (code-only) session is never told. The value is the listing's id.
|
||||
inline constexpr std::string_view steam_session_key = "reskate_session";
|
||||
inline std::optional<std::uint64_t> steam_session_target(std::string_view text) {
|
||||
std::uint64_t id{};
|
||||
const auto parsed = std::from_chars(text.data(), text.data() + text.size(), id);
|
||||
if (text.empty() || parsed.ec != std::errc{} || parsed.ptr != text.data() + text.size() || !id) return {};
|
||||
return id;
|
||||
}
|
||||
inline std::string steam_session_presence(const MultiplayerModel& model) {
|
||||
if (!model.active || model.echo || !model.public_lobby || (model.hosting ? !model.public_host || !model.lobby_listed : !model.connected))
|
||||
return {};
|
||||
return std::to_string(model.public_lobby);
|
||||
}
|
||||
// Optional Steam integration, ticked on the client thread. Steam callbacks only
|
||||
// enqueue requests; normal lobby verification runs before opening any P2P link.
|
||||
void tick_steam_friend_join() noexcept;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#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>
|
||||
@@ -161,7 +162,11 @@ void SteamServerBrowser::refresh(std::uint64_t now) {
|
||||
servers_ = api().servers();
|
||||
if (!servers_) return;
|
||||
release();
|
||||
found_.clear();
|
||||
// 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", {}};
|
||||
@@ -172,21 +177,25 @@ void SteamServerBrowser::refresh(std::uint64_t now) {
|
||||
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});
|
||||
if (request) searches_.push_back({request, response, !lan});
|
||||
else delete response;
|
||||
}
|
||||
started_ = now;
|
||||
next_poll_ = now;
|
||||
}
|
||||
void SteamServerBrowser::read() {
|
||||
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;
|
||||
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;
|
||||
@@ -204,15 +213,18 @@ void SteamServerBrowser::read() {
|
||||
entry.addresses.push_back(address);
|
||||
}
|
||||
}
|
||||
// A restarted 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.
|
||||
// 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) {
|
||||
return other.first > id && std::any_of(entry.addresses.begin(), entry.addresses.end(), [&](const auto &a) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -223,17 +235,29 @@ void SteamServerBrowser::read() {
|
||||
void SteamServerBrowser::tick(std::uint64_t now) {
|
||||
if (searches_.empty() || now < next_poll_) return;
|
||||
next_poll_ = now + 250000;
|
||||
read();
|
||||
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) {
|
||||
return static_cast<Response *>(search.response)->done ||
|
||||
(now - started_ > 1000000 && !api().refreshing(servers_, search.request));
|
||||
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): keep what was listed in the
|
||||
// last two minutes.
|
||||
const auto before = found_.size();
|
||||
std::erase_if(found_, [&](const auto &entry) {
|
||||
return entry.second.search != search_ && (internet_listed_ || now - entry.second.listed > 120000000);
|
||||
});
|
||||
if (found_.size() != before) read(now);
|
||||
std::string list;
|
||||
for (const auto &row : rows_)
|
||||
list += (list.empty() ? ": " : "; ") + row.name + " (" + row.map + ", " + std::to_string(row.players) + "/" +
|
||||
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);
|
||||
|
||||
@@ -15,6 +15,12 @@ std::optional<MultiplayerLobby> read_server_tags(std::string_view tags, std::uin
|
||||
// Dedicated ReSkate servers from Steam's server list for Skate. Rows come from
|
||||
// Steam's master list, so a server shows even when a NAT hides its query port;
|
||||
// what a server answers directly (name, ping) replaces the tag copy.
|
||||
// Not shown: a server the ReSkate team has blocked, and, while the team requires login
|
||||
// tokens (developer_identity.h), one that signed in to Steam without one, unless it is on
|
||||
// the player's own network.
|
||||
// Steam answers a search made soon after another with an empty internet list (seen
|
||||
// 2026-10-06: every refresh within about 40 s of a full one). A search that lists no
|
||||
// internet server therefore keeps the servers the last one found, for a while.
|
||||
class SteamServerBrowser {
|
||||
public:
|
||||
~SteamServerBrowser();
|
||||
@@ -27,19 +33,24 @@ class SteamServerBrowser {
|
||||
private:
|
||||
struct Search {
|
||||
void *request{}, *response{};
|
||||
bool internet{};
|
||||
};
|
||||
struct Found {
|
||||
MultiplayerLobby row;
|
||||
// Every address it was listed at: its public one, and a LAN one nearby.
|
||||
std::vector<std::pair<std::uint32_t, std::uint16_t>> addresses;
|
||||
bool answered{};
|
||||
std::uint64_t search{}, listed{}; // the search that last listed it, and when
|
||||
bool lan{}; // answered on the player's own network
|
||||
};
|
||||
void *servers_{};
|
||||
std::vector<Search> searches_; // the internet and LAN lists
|
||||
std::map<std::uint64_t, Found> found_;
|
||||
std::uint64_t started_{}, next_poll_{};
|
||||
std::uint64_t search_{}; // counts refreshes
|
||||
bool internet_listed_{}; // this search's internet list had a server in it
|
||||
std::vector<MultiplayerLobby> rows_;
|
||||
void release();
|
||||
void read();
|
||||
void read(std::uint64_t now);
|
||||
};
|
||||
} // namespace dingosdk::multiplayer
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "steam_social.h"
|
||||
#include "steam_friend_join.h"
|
||||
#include "Engine/Core/Platform/launcher_support.h"
|
||||
#include <Windows.h>
|
||||
#include <algorithm>
|
||||
@@ -37,6 +38,7 @@ struct Api {
|
||||
int (*presence)(void *, std::uint64_t){};
|
||||
struct Game { std::uint64_t id{}; std::uint32_t ip{}; std::uint16_t port{}, query{}; std::uint64_t lobby{}; };
|
||||
bool (*game)(void *, std::uint64_t, Game *){};
|
||||
const char *(*rich)(void *, std::uint64_t, const char *){};
|
||||
void load() {
|
||||
const auto loaded = GetModuleHandleW(L"steam_api64.dll");
|
||||
if (!loaded || loaded == module) return;
|
||||
@@ -52,7 +54,7 @@ struct Api {
|
||||
#define BIND(member, suffix) member = symbol<decltype(member)>(loaded, "SteamAPI_ISteamFriends_" suffix)
|
||||
BIND(self_name, "GetPersonaName"); BIND(count, "GetFriendCount"); BIND(at, "GetFriendByIndex");
|
||||
BIND(friend_name, "GetFriendPersonaName"); BIND(presence, "GetFriendPersonaState");
|
||||
BIND(game, "GetFriendGamePlayed");
|
||||
BIND(game, "GetFriendGamePlayed"); BIND(rich, "GetFriendRichPresence");
|
||||
#undef BIND
|
||||
module = loaded;
|
||||
}
|
||||
@@ -138,7 +140,14 @@ std::shared_ptr<const SteamSocialSnapshot> steam_social_snapshot() {
|
||||
const auto presence = api.presence(refresh.friends, id);
|
||||
Api::Game game;
|
||||
const bool playing = api.game(refresh.friends, id, &game) && (game.id & 0xffffff) == 3354750;
|
||||
result.friends.push_back({id, name(api.friend_name(refresh.friends, id)), presence > 0 && presence < 7, playing});
|
||||
// Where they skate: what their game says for this, or (a build before it did) the
|
||||
// joinable session it offers friends.
|
||||
std::uint64_t session{};
|
||||
if (playing) {
|
||||
session = steam_session_target(name(api.rich(refresh.friends, id, steam_session_key.data()))).value_or(0);
|
||||
if (!session) session = steam_join_target(name(api.rich(refresh.friends, id, "connect"))).value_or(0);
|
||||
}
|
||||
result.friends.push_back({id, name(api.friend_name(refresh.friends, id)), presence > 0 && presence < 7, playing, session});
|
||||
}
|
||||
if (refresh.index < refresh.count) return current;
|
||||
// The list changed during the pass, so an index may have shifted past a friend:
|
||||
|
||||
@@ -9,6 +9,10 @@ struct SteamSocialPlayer {
|
||||
std::uint64_t id{};
|
||||
std::string name;
|
||||
bool online{}, playing{};
|
||||
// The public server or lobby a friend is skating in (its browser row's id), when they
|
||||
// are in one: what their game tells Steam friends (steam_friend_join.h). 0: none, a
|
||||
// private session, or a build that does not say.
|
||||
std::uint64_t session{};
|
||||
bool operator==(const SteamSocialPlayer &) const = default;
|
||||
};
|
||||
struct SteamSocialSnapshot {
|
||||
@@ -18,5 +22,6 @@ struct SteamSocialSnapshot {
|
||||
};
|
||||
// Read the game's existing Steam session. Never initializes Steam, changes
|
||||
// presence, or sends friend/invite requests. Poll only on the client thread.
|
||||
// The local player has no `session` here: the session model knows theirs.
|
||||
std::shared_ptr<const SteamSocialSnapshot> steam_social_snapshot();
|
||||
}
|
||||
@@ -44,6 +44,28 @@ void parsing() {
|
||||
R"({"updated":"2026-10-04","categories":{"moderator":["76561198000000005"],"dev":["76561198000000001"]}})");
|
||||
check(partial[0] == std::vector{dev} && partial[1].empty() && partial[2].empty(),
|
||||
"An unknown category was read, or a missing one was not empty");
|
||||
// The team's own servers come beside them too: servers signed in with a login token, whose
|
||||
// Steam ID stays. Nobody is on it when the answer leaves it out.
|
||||
const auto servers = parse_identity_lists(
|
||||
R"({"categories":{},"official_servers":["85568392924040002","85568392924040001","85568392924040002"]})");
|
||||
check(servers[static_cast<std::size_t>(L::official_server)] == std::vector<std::uint64_t>{85568392924040001ULL, 85568392924040002ULL} &&
|
||||
bans[static_cast<std::size_t>(L::official_server)].empty(),
|
||||
"The official server list was not read");
|
||||
for (const char *wrong : {R"({"categories":{},"official_servers":["76561198000000001"]})", // a player
|
||||
R"({"categories":{},"official_servers":["90071992551410001"]})", // an anonymous server
|
||||
R"({"categories":{},"official_servers":["85568392920039424"]})", // account 0
|
||||
R"({"categories":{"dev":["85568392924040001"]}})"}) // a server as a player
|
||||
check(rejected(wrong), "A list with the wrong kind of Steam ID was accepted: " + std::string(wrong));
|
||||
// Blocked servers: any server's ID, an anonymous one too; and the rule about login tokens.
|
||||
const auto blocks = parse_identity_lists(R"({"categories":{},"blocked_servers":["90071992551410001","85568392924040001"]})");
|
||||
check(blocks[static_cast<std::size_t>(L::blocked_server)] == std::vector<std::uint64_t>{85568392924040001ULL, 90071992551410001ULL} &&
|
||||
blocks[static_cast<std::size_t>(L::official_server)].empty() && servers[static_cast<std::size_t>(L::blocked_server)].empty(),
|
||||
"The blocked server list was not read");
|
||||
check(rejected(R"({"categories":{},"blocked_servers":["76561198000000001"]})"), "A player was accepted as a blocked server");
|
||||
check(parse_server_tokens_required(R"({"categories":{},"server_tokens_required":true})") &&
|
||||
!parse_server_tokens_required(R"({"categories":{},"server_tokens_required":false})") &&
|
||||
!parse_server_tokens_required(R"({"categories":{}})") && !parse_server_tokens_required(R"({"server_tokens_required":"yes"})"),
|
||||
"The login token rule was not read");
|
||||
// The ends of the range players' SteamID64s come from.
|
||||
check(!rejected(R"({"categories":{"dev":["76561197960265729","76561202255233023"]}})"), "A valid SteamID64 was refused");
|
||||
|
||||
@@ -91,6 +113,30 @@ void lookup() {
|
||||
identity_mark(dev) == L::developer && identity_mark(stranger) == L::centrix && identity_mark(creator) == L::centrix &&
|
||||
identity_mark(homie) == L::homie && !reskate_banned(stranger),
|
||||
"Centrix is not marked after a developer and before a content creator");
|
||||
// Staff: its own list too, marked after a developer and before everyone else.
|
||||
const auto staff = parse_identity_lists(R"({"categories":{"staff":["76561198000000005"],"centrix":["76561198000000005"]}})");
|
||||
check(staff[static_cast<std::size_t>(L::staff)] == std::vector{stranger} && staff[static_cast<std::size_t>(L::centrix)] == std::vector{stranger} &&
|
||||
staff[static_cast<std::size_t>(L::developer)].empty(),
|
||||
"The staff list was not read as its own");
|
||||
check(publish_identity_lists({{{dev}, {homie}, {creator}, {stranger}, {}, {}, {dev, homie, stranger}}}) &&
|
||||
identity_mark(dev) == L::developer && identity_mark(stranger) == L::staff && identity_mark(homie) == L::staff &&
|
||||
identity_mark(creator) == L::content_creator,
|
||||
"Staff is not marked after a developer and before the other lists");
|
||||
{
|
||||
// Their items go green, and the rainbow is a style they (and a developer) can pick: it
|
||||
// travels as a solid colour older builds show, and nobody else's item takes it.
|
||||
const MarkStyle usual{}, rainbow{MarkMode::solid, {0x10, 0x80, 0x38}, rainbow_marker, 0};
|
||||
const auto green = dingosdk::developer_hoodie_detail::item_animation(L::staff, usual);
|
||||
check(green.on && !green.rainbow && green.stops[1][1] > green.stops[1][0] && green.stops[1][1] > green.stops[1][2],
|
||||
"The staff's items are not green");
|
||||
check(valid_mark_style(rainbow) && rainbow_style(rainbow) && !rainbow_style(usual) &&
|
||||
!rainbow_style({MarkMode::gradient, {}, rainbow_marker, 0}),
|
||||
"The rainbow style is not a solid colour with its marker");
|
||||
check(dingosdk::developer_hoodie_detail::item_animation(L::staff, rainbow).rainbow && dingosdk::developer_hoodie_detail::item_animation(L::developer, rainbow).rainbow,
|
||||
"A member of staff or a developer could not pick the rainbow");
|
||||
const auto homies = dingosdk::developer_hoodie_detail::item_animation(L::homie, rainbow);
|
||||
check(homies.on && !homies.rainbow && homies.stops[0] == homies.stops[2], "Someone else's item took the rainbow");
|
||||
}
|
||||
check(publish_identity_lists({{{dev}, {dev, homie, creator}, {dev, creator}}}), "The lists did not go back");
|
||||
// Their tag and their items are each their own to hide.
|
||||
check(own_tag_shown() && own_items_shown(), "A player's tag or items start hidden");
|
||||
|
||||
@@ -29,6 +29,9 @@ void remove_remote_network_objects(std::uint64_t owner, std::uint64_t epoch) {
|
||||
std::string network_object_status() { return {}; }
|
||||
bool simulated_placement_allowed = true;
|
||||
void set_lobby_object_placement_allowed(bool allowed) { simulated_placement_allowed = allowed; }
|
||||
unsigned simulated_object_limit{};
|
||||
void set_lobby_object_limit(unsigned limit) noexcept { simulated_object_limit = limit; }
|
||||
unsigned lobby_object_limit() noexcept { return simulated_object_limit; }
|
||||
unsigned simulated_guest_wipes{};
|
||||
bool clear_lobby_guest_objects() { ++simulated_guest_wipes; return true; }
|
||||
}
|
||||
@@ -411,6 +414,16 @@ void join_tick_checks() {
|
||||
tick(0, 0, true, map, nullptr);
|
||||
check(guest.mode == Mode::join && guest.host_id == host.host_id && guest.joined_public_lobby == 9001,
|
||||
"A Steam join click must not replace an active session");
|
||||
// Picking the server they are on in the browser changes nothing either; picking
|
||||
// another one leaves this session for it.
|
||||
check(queue_command("join-lobby", "9001", {}), "A browser pick should enter the normal queue");
|
||||
tick(0, 0, true, map, nullptr);
|
||||
check(guest.mode == Mode::join && guest.host_id == host.host_id && guest.joined_public_lobby == 9001,
|
||||
"Picking the current server replaced the session");
|
||||
check(queue_command("join-lobby", "9002", {}), "A browser hop should enter the normal queue");
|
||||
tick(0, 0, true, map, nullptr);
|
||||
check(guest.joined_public_lobby != 9001 && guest.status != "Leave your current session before joining another lobby.",
|
||||
"A guest could not hop to another server from the browser");
|
||||
}
|
||||
}
|
||||
stop(guest, "Timeout checks");
|
||||
|
||||
@@ -721,6 +721,7 @@ void dedicated_server_codec() {
|
||||
roster.members = {{server, 10, "My server"}, {player, 20, "Skater", true}, {other, 30, "Other"}};
|
||||
roster.voice_range = 450;
|
||||
roster.guest_noclip = false;
|
||||
roster.object_limit = 50;
|
||||
Packet teleport;
|
||||
teleport.kind = PacketKind::teleport; teleport.session = 9; teleport.epoch = 10; teleport.map = 11; teleport.source = server;
|
||||
teleport.teleport = {612.5f, 199.25f, -1075.75f};
|
||||
@@ -744,6 +745,47 @@ void dedicated_server_codec() {
|
||||
"Server roster, admin flags or voice range lost");
|
||||
check(decoded && !decoded->guest_noclip && decoded->guest_no_bail && decoded->guest_boosts,
|
||||
"Guest noclip / No Bail / boost permissions lost");
|
||||
// The limit on each player's objects rides in the roster too; one past the protocol's own is refused.
|
||||
check(decoded && decoded->object_limit == 50, "The object limit was lost");
|
||||
{
|
||||
auto unlimited = roster;
|
||||
unlimited.object_limit = 0;
|
||||
const auto back = decode(encode(unlimited));
|
||||
check(back && back->object_limit == 0, "No object limit did not stay none");
|
||||
auto over = roster;
|
||||
over.object_limit = dingosdk::max_object_limit + 1;
|
||||
bool refused{};
|
||||
try {
|
||||
encode(over);
|
||||
} catch (const std::exception &) {
|
||||
refused = true;
|
||||
}
|
||||
check(refused, "An object limit past the protocol's was sent");
|
||||
check(dingosdk::parse_object_limit("25") == 25U && dingosdk::parse_object_limit("off") == 0U &&
|
||||
dingosdk::parse_object_limit("1024") == 1024U && !dingosdk::parse_object_limit("1025") &&
|
||||
!dingosdk::parse_object_limit("") && !dingosdk::parse_object_limit("-1") && !dingosdk::parse_object_limit("ten"),
|
||||
"Object limits were not read");
|
||||
// What a host shows of a layout under a limit: what is already shown stays, the rest fills up in order.
|
||||
const auto object = [](std::uint64_t id) {
|
||||
dingosdk::NetworkObject value;
|
||||
value.id = id;
|
||||
value.item = "own_bk_test";
|
||||
return value;
|
||||
};
|
||||
const auto ids = [](const std::vector<dingosdk::NetworkObject> &layout) {
|
||||
std::vector<std::uint64_t> out;
|
||||
for (const auto &entry : layout) out.push_back(entry.id);
|
||||
return out;
|
||||
};
|
||||
const std::vector<dingosdk::NetworkObject> uploaded{object(1), object(2), object(3), object(4), object(5)};
|
||||
std::map<std::uint64_t, dingosdk::NetworkObject> shown{{4, object(4)}, {5, object(5)}};
|
||||
check(ids(limited_layout(uploaded, {}, 0)) == std::vector<std::uint64_t>{1, 2, 3, 4, 5} &&
|
||||
ids(limited_layout(uploaded, {}, 9)) == std::vector<std::uint64_t>{1, 2, 3, 4, 5},
|
||||
"A layout within the limit was cut");
|
||||
check(ids(limited_layout(uploaded, {}, 3)) == std::vector<std::uint64_t>{1, 2, 3}, "A layout over the limit was not cut to it");
|
||||
check(ids(limited_layout(uploaded, shown, 3)) == std::vector<std::uint64_t>{4, 5, 1},
|
||||
"Objects already shown lost their place to newer ones");
|
||||
}
|
||||
// Parties ride in the roster: each has one leader and at least two members; a server is in none.
|
||||
auto party = roster;
|
||||
party.members[1].party = party.members[2].party = 7;
|
||||
|
||||
@@ -34,6 +34,26 @@ int main() {
|
||||
check(steam_join_presence(model) == "+reskate_lobby 9001", "Connected guests can advertise their verified public lobby");
|
||||
model.connected = false;
|
||||
check(steam_join_presence(model).empty(), "Guests cannot advertise before admission");
|
||||
// Where a player skates, for friends' server lists: any public session, joinable or not.
|
||||
check(steam_session_target("9001") == 9001, "A session id parses");
|
||||
for (const auto* value : {"", "0", "-1", "9001 ", "+reskate_lobby 9001", "18446744073709551616", "lobby"})
|
||||
check(!steam_session_target(value), "Malformed session values must be ignored");
|
||||
model = open;
|
||||
check(steam_session_presence(model) == "9001", "A public host tells friends its listing");
|
||||
model.password_required = true; model.players = model.capacity; model.local_ready = false;
|
||||
check(steam_session_presence(model) == "9001", "A full, protected or loading public session is still told");
|
||||
model = open; model.public_host = false;
|
||||
check(steam_session_presence(model).empty(), "A private host is never told");
|
||||
model = open; model.public_lobby = 0;
|
||||
check(steam_session_presence(model).empty(), "A session joined by a private code is never told");
|
||||
model = open; model.lobby_listed = false;
|
||||
check(steam_session_presence(model).empty(), "A closed listing is not told");
|
||||
model = open; model.active = false;
|
||||
check(steam_session_presence(model).empty(), "An ended session is not told");
|
||||
model = open; model.hosting = model.public_host = model.lobby_listed = false;
|
||||
check(steam_session_presence(model).empty(), "A guest is not told before admission");
|
||||
model.connected = true;
|
||||
check(steam_session_presence(model) == "9001", "A connected guest tells friends the server they are on");
|
||||
std::cout << "Steam friend join checks passed.\n";
|
||||
} catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "developer_identity.h"
|
||||
#include "Engine/Core/Json/json.h"
|
||||
#include "Extension/Multiplayer/Net/protocol.h"
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <charconv>
|
||||
@@ -10,24 +11,41 @@
|
||||
// them from the backend its own way: developer_identity_fetch.cpp, Server/global_bans.cpp.
|
||||
namespace dingosdk::multiplayer {
|
||||
namespace {
|
||||
// The API's name for each category, in IdentityList order; the ban list follows them.
|
||||
constexpr std::array<std::string_view, 4> categories{"dev", "homie", "content_creator", "centrix"};
|
||||
// The API's name for each category of players. The ban list and the servers come beside them.
|
||||
constexpr std::array<std::pair<std::string_view, IdentityList>, 5> categories{{
|
||||
{"dev", IdentityList::developer}, {"staff", IdentityList::staff}, {"homie", IdentityList::homie},
|
||||
{"content_creator", IdentityList::content_creator}, {"centrix", IdentityList::centrix}}};
|
||||
constexpr auto banned = static_cast<std::size_t>(IdentityList::banned);
|
||||
constexpr auto servers = static_cast<std::size_t>(IdentityList::official_server);
|
||||
constexpr auto blocked = static_cast<std::size_t>(IdentityList::blocked_server);
|
||||
|
||||
std::atomic<std::shared_ptr<const IdentityLists>> current;
|
||||
|
||||
// A player's SteamID64 is 76561197960265728 plus a 32-bit account number, and
|
||||
// nobody has account 0. The API sends them as strings.
|
||||
std::optional<std::uint64_t> number(const Json &entry) {
|
||||
std::uint64_t id{};
|
||||
if (!entry.is_string()) return std::nullopt;
|
||||
const auto &text = entry.string();
|
||||
const auto parsed = std::from_chars(text.data(), text.data() + text.size(), id);
|
||||
return parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size() ? std::optional(id) : std::nullopt;
|
||||
}
|
||||
std::uint64_t steam_id(const Json &entry) {
|
||||
constexpr std::uint64_t base = 76561197960265728ULL, last = base + 0xffffffffULL;
|
||||
std::uint64_t id{};
|
||||
if (entry.is_string()) {
|
||||
const auto &text = entry.string();
|
||||
const auto parsed = std::from_chars(text.data(), text.data() + text.size(), id);
|
||||
if (parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size() && id > base && id <= last) return id;
|
||||
}
|
||||
if (const auto id = number(entry); id && *id > base && *id <= last) return *id;
|
||||
throw std::runtime_error("an entry is not a player's SteamID64");
|
||||
}
|
||||
// Only a server signed in with a login token keeps its ID; an anonymous one's is gone at its
|
||||
// next start, so listing it would mean nothing.
|
||||
std::uint64_t server_id(const Json &entry) {
|
||||
if (const auto id = number(entry); id && persistent_server_steam_id(*id)) return *id;
|
||||
throw std::runtime_error("an entry is not the SteamID64 of a server with a login token");
|
||||
}
|
||||
// A block can name an anonymous server too: its ID is all there is to hold on to.
|
||||
std::uint64_t any_server_id(const Json &entry) {
|
||||
if (const auto id = number(entry); id && game_server_steam_id(*id)) return *id;
|
||||
throw std::runtime_error("an entry is not the SteamID64 of a server");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
IdentityLists parse_identity_lists(std::string_view json) {
|
||||
@@ -36,17 +54,24 @@ IdentityLists parse_identity_lists(std::string_view json) {
|
||||
throw std::runtime_error("the answer has no categories");
|
||||
const auto &listed = answer.at("categories");
|
||||
IdentityLists lists;
|
||||
const auto read = [](const Json &entries, std::vector<std::uint64_t> &ids) {
|
||||
const auto read = [](const Json &entries, std::vector<std::uint64_t> &ids, std::uint64_t (*id)(const Json &) = steam_id) {
|
||||
if (!entries.is_array()) throw std::runtime_error("a list of players is not a list");
|
||||
for (const auto &entry : entries) ids.push_back(steam_id(entry));
|
||||
for (const auto &entry : entries) ids.push_back(id(entry));
|
||||
std::sort(ids.begin(), ids.end());
|
||||
ids.erase(std::unique(ids.begin(), ids.end()), ids.end());
|
||||
};
|
||||
for (std::size_t index = 0; index < categories.size(); ++index)
|
||||
if (listed.contains(categories[index])) read(listed.at(categories[index]), lists[index]);
|
||||
for (const auto &[name, list] : categories)
|
||||
if (listed.contains(name)) read(listed.at(name), lists[static_cast<std::size_t>(list)]);
|
||||
if (answer.contains("banned")) read(answer.at("banned"), lists[banned]);
|
||||
if (answer.contains("official_servers")) read(answer.at("official_servers"), lists[servers], server_id);
|
||||
if (answer.contains("blocked_servers")) read(answer.at("blocked_servers"), lists[blocked], any_server_id);
|
||||
return lists;
|
||||
}
|
||||
bool parse_server_tokens_required(std::string_view json) {
|
||||
const auto answer = Json::parse(json);
|
||||
return answer.is_object() && answer.contains("server_tokens_required") && answer.at("server_tokens_required").is_boolean() &&
|
||||
answer.at("server_tokens_required").get<bool>();
|
||||
}
|
||||
bool publish_identity_lists(IdentityLists lists) {
|
||||
const auto previous = current.load();
|
||||
if (previous && *previous == lists) return false;
|
||||
|
||||
@@ -13,7 +13,11 @@ namespace dingosdk::multiplayer {
|
||||
// api.reskate.dev, so they change without a release. Steam authenticates these
|
||||
// identities. Each category has its tag in chat and on nametags (player_role)
|
||||
// and its animation on the marked hoodie and board (developer_hoodie_material.h).
|
||||
enum class IdentityList : std::uint8_t { developer, homie, content_creator, centrix, banned, count };
|
||||
// The last list is not of players: the dedicated servers the ReSkate team runs, which the
|
||||
// server browser puts first. A server is on it by the Steam ID its login token gives it.
|
||||
// And one of servers the team has blocked: the browser does not show them and the game does
|
||||
// not join them.
|
||||
enum class IdentityList : std::uint8_t { developer, homie, content_creator, centrix, banned, official_server, staff, blocked_server, count };
|
||||
using IdentityLists = std::array<std::vector<std::uint64_t>, static_cast<std::size_t>(IdentityList::count)>;
|
||||
inline constexpr std::string_view identity_lists_url = "https://api.reskate.dev/api/v1/steam-ids";
|
||||
|
||||
@@ -26,10 +30,11 @@ void refresh_identity_lists() noexcept;
|
||||
bool identity_listed(std::uint64_t id, IdentityList list) noexcept;
|
||||
// Shared by the developer tag and the rainbow hoodie and board.
|
||||
inline bool reskate_developer(std::uint64_t id) noexcept { return identity_listed(id, IdentityList::developer); }
|
||||
// The category a player's tag and items come from: a developer's before Centrix's before a
|
||||
// content creator's before a homie's. Most players are in none.
|
||||
// The category a player's tag and items come from: a developer's before the staff's before
|
||||
// Centrix's before a content creator's before a homie's. Most players are in none.
|
||||
inline std::optional<IdentityList> identity_mark(std::uint64_t id) noexcept {
|
||||
for (const auto list : {IdentityList::developer, IdentityList::centrix, IdentityList::content_creator, IdentityList::homie})
|
||||
for (const auto list : {IdentityList::developer, IdentityList::staff, IdentityList::centrix, IdentityList::content_creator,
|
||||
IdentityList::homie})
|
||||
if (identity_listed(id, list)) return list;
|
||||
return std::nullopt;
|
||||
}
|
||||
@@ -47,11 +52,27 @@ inline void show_own_items(bool shown) noexcept { own_items.store(shown, std::me
|
||||
// so a banned player's own game still lets them try to join. Nobody is banned while the lists
|
||||
// cannot be read.
|
||||
inline bool reskate_banned(std::uint64_t id) noexcept { return identity_listed(id, IdentityList::banned); }
|
||||
// A dedicated server the ReSkate team runs. Steam vouches for the ID: only the holder of the
|
||||
// server's login token can sign in as it.
|
||||
inline bool official_server(std::uint64_t id) noexcept { return identity_listed(id, IdentityList::official_server); }
|
||||
inline bool blocked_server(std::uint64_t id) noexcept { return identity_listed(id, IdentityList::blocked_server); }
|
||||
inline constexpr std::string_view blocked_server_notice = "This server has been blocked by the ReSkate team.";
|
||||
// Whether the server browser shows only dedicated servers signed in with a Steam login token
|
||||
// (protocol.h: persistent_server_steam_id), which the backend switches on and off beside the
|
||||
// lists ("server_tokens_required"). Such a server keeps its Steam ID, so a block holds on it.
|
||||
// Players' own lobbies, servers on the player's network and servers joined by code are not
|
||||
// affected. Off until the backend says otherwise.
|
||||
inline std::atomic<bool> server_tokens_rule{};
|
||||
inline bool server_tokens_required() noexcept { return server_tokens_rule.load(std::memory_order_relaxed); }
|
||||
// What the API's answer says about that; false when it says nothing. Throws on an answer
|
||||
// that is not JSON.
|
||||
bool parse_server_tokens_required(std::string_view json);
|
||||
inline constexpr std::string_view banned_notice = "You are banned from ReSkate multiplayer.";
|
||||
|
||||
// The API's answer, {"categories":{"dev":["7656119..."],"homie":[],...},"banned":[]},
|
||||
// as sorted lists. A category this build does not know is ignored and a list the
|
||||
// answer leaves out is empty; an entry that is not a player's SteamID64 throws.
|
||||
// The API's answer, {"categories":{"dev":["7656119..."],"homie":[],...},"banned":[],
|
||||
// "official_servers":["8556839..."],"blocked_servers":[]}, as sorted lists. A category this build does not know
|
||||
// is ignored and a list the answer leaves out is empty; an entry that is not a player's
|
||||
// SteamID64 (for the servers: a token server's) throws.
|
||||
IdentityLists parse_identity_lists(std::string_view json);
|
||||
// Puts sorted lists in use. False when they are the ones already in use.
|
||||
bool publish_identity_lists(IdentityLists lists);
|
||||
|
||||
@@ -23,6 +23,7 @@ void fetch() noexcept {
|
||||
L"ReSkate-Identity/1", &download);
|
||||
if (answer) {
|
||||
auto lists = parse_identity_lists(*answer);
|
||||
server_tokens_rule = parse_server_tokens_required(*answer);
|
||||
const auto developers = lists[0].size(), homies = lists[1].size(), creators = lists[2].size(),
|
||||
bans = lists[static_cast<std::size_t>(IdentityList::banned)].size();
|
||||
if (publish_identity_lists(std::move(lists)) || failing)
|
||||
|
||||
@@ -5,10 +5,26 @@
|
||||
#include <atomic>
|
||||
|
||||
namespace dingosdk {
|
||||
namespace { std::atomic<bool> placement_allowed{true}; }
|
||||
namespace {
|
||||
std::atomic<bool> placement_allowed{true};
|
||||
std::atomic<unsigned> object_limit{};
|
||||
}
|
||||
bool lobby_object_placement_allowed() noexcept { return placement_allowed.load(std::memory_order_acquire); }
|
||||
void set_lobby_object_limit(unsigned limit) noexcept { object_limit.store(limit, std::memory_order_release); }
|
||||
unsigned lobby_object_limit() noexcept { return object_limit.load(std::memory_order_acquire); }
|
||||
using namespace profile_runtime;
|
||||
using namespace profile_runtime::park_editor_detail;
|
||||
// Not for callers that hold the native mutex: they have the layout, and count it themselves.
|
||||
std::size_t lobby_object_count() {
|
||||
std::lock_guard lock(local_runtime().native_mutex);
|
||||
auto &r = placements_runtime();
|
||||
const auto found = r.document.maps.find(r.map);
|
||||
return found == r.document.maps.end() ? 0 : found->second.size();
|
||||
}
|
||||
bool lobby_object_limit_reached() {
|
||||
const auto limit = lobby_object_limit();
|
||||
return limit && lobby_object_count() >= limit;
|
||||
}
|
||||
void set_lobby_object_guest(bool guest) {
|
||||
std::lock_guard lock(local_runtime().native_mutex);
|
||||
auto &r = placements_runtime();
|
||||
|
||||
@@ -105,6 +105,9 @@ std::string edit_local_park(std::string_view operation, std::string_view map, st
|
||||
auto object = supplied;
|
||||
if (r.next_id == UINT64_MAX || target.size() >= 1024)
|
||||
throw std::runtime_error("Park object limit reached.");
|
||||
// The session's own limit on each player (the mutex is held: count here).
|
||||
if (const auto limit = lobby_object_limit(); limit && target.size() >= limit)
|
||||
throw std::runtime_error(object_limit_notice);
|
||||
object.id = r.next_id++;
|
||||
if (!e.assets || std::none_of(e.assets->begin(), e.assets->end(),
|
||||
[&](const auto &item) { return item.key == object.item; }))
|
||||
|
||||
@@ -66,6 +66,11 @@ bool queue_local_park_paste(const EditorPasteRequest &request) {
|
||||
auto target = r.document.maps[r.map];
|
||||
if (target.size() + request.objects.size() > 1024 || r.next_id > UINT64_MAX - request.objects.size())
|
||||
return false;
|
||||
// The session's own limit on each player: copies that would pass it are not made.
|
||||
if (const auto limit = lobby_object_limit(); limit && target.size() + request.objects.size() > limit) {
|
||||
e.status = object_limit_notice;
|
||||
return false;
|
||||
}
|
||||
auto next_id = r.next_id;
|
||||
for (const auto © : request.objects) {
|
||||
if (std::none_of(e.assets->begin(), e.assets->end(),
|
||||
@@ -103,6 +108,10 @@ bool queue_local_park_preview(const EditorPreviewRequest &request) {
|
||||
profile::PlacedObject object;
|
||||
std::uint64_t entity{};
|
||||
if (placing) {
|
||||
if (const auto limit = lobby_object_limit(); limit && layout.size() >= limit) {
|
||||
e.status = object_limit_notice;
|
||||
return false;
|
||||
}
|
||||
if (layout.size() >= 1024 || r.next_id == UINT64_MAX || !e.assets ||
|
||||
std::none_of(e.assets->begin(), e.assets->end(),
|
||||
[&](const auto &asset) { return asset.key == request.item; }))
|
||||
|
||||
@@ -12,6 +12,14 @@ void set_park_mod_list_visible(bool visible) noexcept;
|
||||
// on disconnect; existing placed objects remain in the world.
|
||||
void set_lobby_object_placement_allowed(bool enabled);
|
||||
bool lobby_object_placement_allowed() noexcept;
|
||||
// The session's limit on this player's objects (object_placement.h), 0 for none: the host's
|
||||
// own game and a server admin's are given none. Adding an object is refused at the limit.
|
||||
void set_lobby_object_limit(unsigned limit) noexcept;
|
||||
unsigned lobby_object_limit() noexcept;
|
||||
// Objects this player has on the current map, and whether one more would pass the limit.
|
||||
std::size_t lobby_object_count();
|
||||
bool lobby_object_limit_reached();
|
||||
inline constexpr char object_limit_notice[] = "You have placed as many objects as this session allows each player.";
|
||||
bool queue_local_park_surface(const EditorSurfaceRequest &);
|
||||
bool queue_local_park_preview(const EditorPreviewRequest &);
|
||||
bool queue_local_park_selection(const EditorSelectionRequest &);
|
||||
|
||||
@@ -311,6 +311,14 @@ void placement_message_hook(std::uintptr_t manager, const void* message) {
|
||||
PlacementBatch batch;
|
||||
batch.native = true;
|
||||
{ PreserveError preserve; read(reinterpret_cast<std::uintptr_t>(message) + 0xc, batch.type); }
|
||||
if (placement_adds_objects(batch.type) && lobby_object_placement_allowed() && lobby_object_limit_reached()) {
|
||||
// As below: the request never reaches the server, so nothing is created.
|
||||
PreserveError preserve;
|
||||
placements_runtime().status = object_limit_notice;
|
||||
dingosdk::logging::event(dingosdk::logging::Channel::objects, dingosdk::Json{
|
||||
{"event", "local_placement_over_limit"}, {"type", batch.type}, {"limit", lobby_object_limit()}}.dump().c_str());
|
||||
return;
|
||||
}
|
||||
if (placement_adds_objects(batch.type) && !lobby_object_placement_allowed()) {
|
||||
// Drop the request before the server sees it: no entity is created.
|
||||
PreserveError preserve;
|
||||
|
||||
@@ -57,6 +57,7 @@ int main() {
|
||||
check(missing_tabs({1, 2, 3, 4}, 8, false) == std::vector<unsigned>{0},
|
||||
"The Multiplayer page does not wait for the overlay's tool callbacks");
|
||||
MultiplayerModel model;
|
||||
const BrowserOptions by_name{{}, false, Sort::name};
|
||||
model.local_id = 77; model.map = "Levels/San_Vansterdam";
|
||||
for (unsigned i = 0; i < 14; ++i) {
|
||||
MultiplayerLobby lobby;
|
||||
@@ -66,7 +67,7 @@ int main() {
|
||||
lobby.players = static_cast<int>(i + 1); lobby.capacity = 16;
|
||||
model.lobbies.push_back(lobby);
|
||||
}
|
||||
auto result = browse(model, {});
|
||||
auto result = browse(model, by_name);
|
||||
check(result.total == 14 && result.lobbies.size() == 14, "All matching lobbies remain scrollable");
|
||||
check(result.lobbies.front()->id == 100 && result.lobbies[6]->id == 112 && result.lobbies.back()->id == 113,
|
||||
"Stable ordering of duplicate names across the full list");
|
||||
@@ -78,6 +79,27 @@ int main() {
|
||||
check(browse(model, options).total == 5, "Combine name and current-map filters");
|
||||
options = {}; options.sort = Sort::players;
|
||||
check(browse(model, options).lobbies.front()->players == 14, "Most populated first");
|
||||
// The team's own servers lead the list under every sort, in that sort's order among themselves.
|
||||
model.lobbies[2].dedicated = model.lobbies[2].official = true;
|
||||
model.lobbies[5].dedicated = model.lobbies[5].official = true;
|
||||
result = browse(model, options);
|
||||
check(result.lobbies[0]->id == 105 && result.lobbies[1]->id == 102 && result.lobbies[2]->players == 14,
|
||||
"Official servers are not first when sorting by players");
|
||||
result = browse(model, by_name);
|
||||
check(result.lobbies[0]->id == 102 && result.lobbies[1]->id == 105 && result.lobbies[2]->id == 100,
|
||||
"Official servers are not first when sorting by name");
|
||||
// Then the servers friends are in, which a search for a friend's name finds too.
|
||||
model.lobbies[9].friends = {"Ana"};
|
||||
result = browse(model, by_name);
|
||||
check(result.lobbies[0]->id == 102 && result.lobbies[1]->id == 105 && result.lobbies[2]->id == 109 && result.lobbies[3]->id == 100,
|
||||
"A friend's server is not next after the official ones");
|
||||
BrowserOptions by_friend;
|
||||
by_friend.query = "ANA";
|
||||
result = browse(model, by_friend);
|
||||
check(result.total == 1 && result.lobbies.front()->id == 109, "A friend's name did not find their server");
|
||||
model.lobbies[9].friends.clear();
|
||||
model.lobbies[2].dedicated = model.lobbies[2].official = false;
|
||||
model.lobbies[5].dedicated = model.lobbies[5].official = false;
|
||||
options.query = "missing";
|
||||
result = browse(model, options);
|
||||
check(result.total == 0 && result.lobbies.empty(), "Empty search");
|
||||
@@ -87,9 +109,19 @@ int main() {
|
||||
check(!can_join(model, lobby), "Full lobby must not join");
|
||||
lobby.players = 1; lobby.owner = model.local_id;
|
||||
check(!can_join(model, lobby), "Own lobby must not join");
|
||||
lobby.owner = 123; model.active = true;
|
||||
check(!can_join(model, lobby), "Active session must not join");
|
||||
model.active = false; model.lobby_joining = true;
|
||||
// A guest hops straight to another server; a host ends their session first, and nobody
|
||||
// joins the one they are in.
|
||||
lobby.owner = 123; model.active = true; model.hosting = true;
|
||||
check(!can_join(model, lobby), "A host must end their session before joining");
|
||||
model.hosting = false; model.public_lobby = 999;
|
||||
check(can_join(model, lobby), "A guest could not hop to another server");
|
||||
model.public_lobby = lobby.id;
|
||||
check(!can_join(model, lobby), "The server a player is in must not join");
|
||||
lobby.friends = {"Ana", "Ben", "Cy", "Di"};
|
||||
check(lobby_friends_text(lobby) == "Ana, Ben and 2 more" && lobby_friends_text(lobby, 4) == "Ana, Ben, Cy, Di",
|
||||
"Friends in a server were not named");
|
||||
lobby.friends.clear();
|
||||
model.active = false; model.public_lobby = 0; model.lobby_joining = true;
|
||||
check(!can_join(model, lobby), "Pending connection must not join");
|
||||
MultiplayerPlayer player;
|
||||
player.id = 88; player.epoch = 7; player.connected = true; player.name = "Skater";
|
||||
|
||||
@@ -128,7 +128,9 @@ void process_actions(const Context& context, const MultiplayerModel& model) {
|
||||
} else {
|
||||
std::string command = cmd, argument = request.argument, password;
|
||||
if (cmd == "host" || cmd == "join" || cmd == "join-lobby") {
|
||||
if (model.active || model.lobby_joining) { s.feedback = "Leave the current session before connecting."; continue; }
|
||||
// From a session they joined a player can hop to a server in the list (can_join).
|
||||
const bool hop = cmd == "join-lobby" && model.active && !model.hosting && !model.echo;
|
||||
if ((model.active && !hop) || model.lobby_joining) { s.feedback = "Leave the current session before connecting."; continue; }
|
||||
if (cmd == "host") {
|
||||
if (!model.local_ready) { s.feedback = "Load a map before hosting."; continue; }
|
||||
const auto name = input_text(context, "host-name");
|
||||
@@ -166,7 +168,7 @@ void process_actions(const Context& context, const MultiplayerModel& model) {
|
||||
SecureZeroMemory(password.data(), password.size());
|
||||
s.feedback = "Password is too long. Use a shorter password."; continue;
|
||||
}
|
||||
if ((cmd == "kick" || cmd == "object-placement" || cmd == "clear-objects" || cmd == "world-layer-sync" ||
|
||||
if ((cmd == "kick" || cmd == "object-placement" || cmd == "object-limit" || cmd == "clear-objects" || cmd == "world-layer-sync" ||
|
||||
cmd == "voice-allow" || cmd == "noclip-allow" || cmd == "nobail-allow" || cmd == "boosts-allow" ||
|
||||
cmd == "tuning-enforce") &&
|
||||
!model.hosting) {
|
||||
@@ -206,9 +208,10 @@ void render_section(const Context& context, const MultiplayerModel& model, Secti
|
||||
for (const auto* lobby : result.lobbies) {
|
||||
const auto id = std::to_string(lobby->id);
|
||||
const bool available = menu_view::can_join(model, *lobby);
|
||||
const auto title = menu_view::caption(lobby->name) + (lobby->dedicated ? " [SERVER]" : "") + "\n" + menu_view::caption(menu_view::map_name(lobby->map, levels), 32) +
|
||||
const auto title = menu_view::caption(lobby->name) + (lobby->official ? " [OFFICIAL]" : lobby->dedicated ? " [SERVER]" : "") + "\n" + menu_view::caption(menu_view::map_name(lobby->map, levels), 32) +
|
||||
" / " + std::to_string(lobby->players) + "/" + std::to_string(lobby->capacity) +
|
||||
" skaters / " + (lobby->players >= lobby->capacity ? "FULL" : lobby->password_required ? "PASSWORD" : "OPEN");
|
||||
" skaters / " + (lobby->players >= lobby->capacity ? "FULL" : lobby->password_required ? "PASSWORD" : "OPEN") +
|
||||
(lobby->friends.empty() ? std::string{} : " / WITH " + menu_view::caption(lobby_friends_text(*lobby), 40));
|
||||
add_button(context, main, "lobby-" + id, title, available ? "join-lobby" : "", id);
|
||||
}
|
||||
s.row_width = side_width;
|
||||
@@ -220,7 +223,8 @@ void render_section(const Context& context, const MultiplayerModel& model, Secti
|
||||
add_button(context, side, "refresh", model.lobby_searching ? "Searching..." : "Refresh lobbies",
|
||||
model.lobby_searching ? "" : "browse", "", true);
|
||||
add_text(context, side, "browser-status", s.feedback.empty() ? model.browser_status : s.feedback);
|
||||
if (!idle) add_text(context, side, "browser-connected", "Leave your session before joining another.");
|
||||
if (!idle) add_text(context, side, "browser-connected", model.active && !model.hosting && !model.echo
|
||||
? "Pick another server to leave this one and join it." : "End your session before joining another.");
|
||||
} else if (section == Section::host) {
|
||||
add_text(context, main, "host-heading", "MAKE IT YOUR SESSION");
|
||||
add_text(context, main, "host-name-label", "LOBBY NAME");
|
||||
@@ -378,6 +382,15 @@ void render_section(const Context& context, const MultiplayerModel& model, Secti
|
||||
? std::string("Admins only") : std::string(object_placement_name(model.object_placement));
|
||||
add_button(context, side, "editor", "Object placement: " + placement,
|
||||
model.hosting ? "object-placement" : "", "next", false, 136.f);
|
||||
{
|
||||
// The same round numbers as the overlay's list, one press each.
|
||||
static constexpr std::array<unsigned, 7> limits{0, 10, 25, 50, 100, 250, 500};
|
||||
const auto found = std::find(limits.begin(), limits.end(), model.object_limit);
|
||||
const auto next = limits[found == limits.end() ? 0 : static_cast<std::size_t>(found - limits.begin() + 1) % limits.size()];
|
||||
add_button(context, side, "object-limit",
|
||||
"Objects per player: " + (model.object_limit ? std::to_string(model.object_limit) : std::string("No limit")),
|
||||
model.hosting ? "object-limit" : "", next ? std::to_string(next) : std::string("off"), false, 136.f);
|
||||
}
|
||||
if (model.hosting)
|
||||
add_button(context, side, "clear-objects", "Delete all guest objects", "clear-objects", {}, false, 136.f);
|
||||
add_button(context, side, "guest-noclip", std::string("Guest noclip: ") + (model.guest_noclip ? "Allowed" : "Off"),
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
|
||||
namespace dingosdk::multiplayer::menu_view {
|
||||
enum class Sort { name, players, map };
|
||||
struct BrowserOptions { std::string query; bool same_map{}; Sort sort{}; };
|
||||
// The busiest servers first, until the player picks another order.
|
||||
struct BrowserOptions { std::string query; bool same_map{}; Sort sort = Sort::players; };
|
||||
|
||||
// Owned pages: Multiplayer and Custom Stuff (needs the overlay's tool callbacks).
|
||||
inline constexpr unsigned page_count = 2, tools_page = 1;
|
||||
@@ -47,7 +48,9 @@ struct BrowserResults {
|
||||
std::size_t total{};
|
||||
};
|
||||
inline bool can_join(const MultiplayerModel& model, const MultiplayerLobby& lobby) {
|
||||
return !model.active && !model.lobby_joining && lobby.players < lobby.capacity && lobby.owner != model.local_id;
|
||||
// From a session they joined a player can hop straight to another; a host ends theirs first.
|
||||
const bool free = !model.active || (!model.hosting && !model.echo && lobby.id != model.public_lobby);
|
||||
return free && !model.lobby_joining && lobby.players < lobby.capacity && lobby.owner != model.local_id;
|
||||
}
|
||||
inline std::string player_identity(const MultiplayerPlayer& player) {
|
||||
return std::to_string(player.id) + " " + std::to_string(player.epoch);
|
||||
@@ -66,10 +69,16 @@ inline BrowserResults browse(const MultiplayerModel& model, const BrowserOptions
|
||||
// Servers advertise their map's name rather than its destination.
|
||||
if (options.same_map && lobby.map != model.map &&
|
||||
!(lobby.dedicated && map_name(lobby.map, levels) == map_name(model.map, levels))) continue;
|
||||
if (!query.empty() && fold(lobby.name + " " + map_name(lobby.map, levels) + " " + lobby.map).find(query) == std::string::npos) continue;
|
||||
auto text = lobby.name + " " + map_name(lobby.map, levels) + " " + lobby.map;
|
||||
for (const auto& name : lobby.friends) text += " " + name;
|
||||
if (!query.empty() && fold(text).find(query) == std::string::npos) continue;
|
||||
result.lobbies.push_back(&lobby);
|
||||
}
|
||||
std::sort(result.lobbies.begin(), result.lobbies.end(), [&](const auto* a, const auto* b) {
|
||||
// The ReSkate team's own servers lead the list, however the rest is sorted.
|
||||
if (a->official != b->official) return a->official;
|
||||
// Then where friends are.
|
||||
if (a->friends.empty() != b->friends.empty()) return !a->friends.empty();
|
||||
if (options.sort == Sort::players && a->players != b->players) return a->players > b->players;
|
||||
if (options.sort == Sort::map) {
|
||||
const auto an = fold(map_name(a->map, levels)), bn = fold(map_name(b->map, levels));
|
||||
|
||||
@@ -49,7 +49,7 @@ LobbyRows &lobby_rows() {
|
||||
bool same_lobby(const MultiplayerLobby &a, const MultiplayerLobby &b) {
|
||||
return a.id == b.id && a.owner == b.owner && a.name == b.name && a.map == b.map && a.code == b.code &&
|
||||
a.password_required == b.password_required && a.players == b.players && a.capacity == b.capacity &&
|
||||
a.dedicated == b.dedicated && a.ping == b.ping;
|
||||
a.dedicated == b.dedicated && a.official == b.official && a.ping == b.ping && a.friends == b.friends;
|
||||
}
|
||||
bool rows_current(const LobbyRows &cache, const SkateMenu &menu, const Model &model) {
|
||||
const auto &mp = model.multiplayer;
|
||||
@@ -91,12 +91,19 @@ const std::vector<Row> &lobby_list(const SkateMenu &menu, const Model &model) {
|
||||
row.map_key = lowercase(row.map);
|
||||
if (menu.multiplayer_same_map_only && !row.same_map) continue;
|
||||
if (menu.multiplayer_dedicated_only && !lobby.dedicated) continue;
|
||||
if (!filter.empty() && (row.host_key + " " + row.map_key + " " + lowercase(lobby.map)).find(filter) == std::string::npos)
|
||||
continue;
|
||||
if (!filter.empty()) {
|
||||
auto text = row.host_key + " " + row.map_key + " " + lowercase(lobby.map);
|
||||
for (const auto &name : lobby.friends) text += " " + lowercase(name);
|
||||
if (text.find(filter) == std::string::npos) continue;
|
||||
}
|
||||
rows.push_back(std::move(row));
|
||||
}
|
||||
std::sort(rows.begin(), rows.end(), [&](const Row &a, const Row &b) {
|
||||
const auto &first = mp.lobbies[a.lobby], &second = mp.lobbies[b.lobby];
|
||||
// The ReSkate team's own servers lead the list, however the rest is sorted.
|
||||
if (first.official != second.official) return first.official;
|
||||
// Then where friends are.
|
||||
if (first.friends.empty() != second.friends.empty()) return !first.friends.empty();
|
||||
switch (menu.multiplayer_lobby_sort) {
|
||||
case 1:
|
||||
if (first.players != second.players) return first.players > second.players;
|
||||
@@ -325,9 +332,18 @@ void join_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks
|
||||
ImGui::PushID(id.c_str());
|
||||
const auto top = ImGui::GetCursorScreenPos();
|
||||
const float width = ImGui::GetContentRegionAvail().x;
|
||||
const bool joinable = !mp.active && !mp.lobby_joining && !row.self && !row.full;
|
||||
skate_theme::rough_rect(draw, top, ImVec2(top.x + width, top.y + tile_height), skate_theme::tile,
|
||||
// From a session they joined a player can hop straight to another; a host ends theirs first.
|
||||
const bool here = mp.active && lobby.id == mp.public_lobby;
|
||||
const bool hop = mp.active && !mp.hosting && !mp.echo && !here;
|
||||
const bool joinable = (!mp.active || hop) && !mp.lobby_joining && !row.self && !row.full;
|
||||
// The team's own servers are ReSkate blue, and the ones friends are skating in green.
|
||||
const bool with_friends = !lobby.friends.empty() && !lobby.official;
|
||||
skate_theme::rough_rect(draw, top, ImVec2(top.x + width, top.y + tile_height),
|
||||
lobby.official ? skate_theme::official_tile : with_friends ? skate_theme::friends_tile : skate_theme::tile,
|
||||
static_cast<unsigned>(i + 41), px(1));
|
||||
if (lobby.official || with_friends)
|
||||
draw->AddRect(top, ImVec2(top.x + width, top.y + tile_height), lobby.official ? skate_theme::official : skate_theme::good, 0, 0,
|
||||
px(1.5f));
|
||||
if (row.same_map) draw->AddRectFilled(top, ImVec2(top.x + px(4), top.y + tile_height), skate_theme::blue);
|
||||
|
||||
// Right: Join, then the player count and tags leading into it.
|
||||
@@ -336,7 +352,7 @@ void join_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks
|
||||
ImGui::BeginDisabled(!joinable);
|
||||
skate_theme::push_primary_button();
|
||||
ImGui::PushFont(menu.bold);
|
||||
if (ImGui::Button(row.self ? "YOURS" : row.full ? "FULL" : "JOIN", ImVec2(join_width, 0))) {
|
||||
if (ImGui::Button(row.self ? "YOURS" : here ? "HERE" : row.full ? "FULL" : "JOIN", ImVec2(join_width, 0))) {
|
||||
if (lobby.password_required) prompt_password(menu, &lobby);
|
||||
else send_private(menu, "join-lobby", id, menu.multiplayer_join_password, false);
|
||||
}
|
||||
@@ -344,11 +360,14 @@ void join_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks
|
||||
skate_theme::pop_primary_button();
|
||||
ImGui::EndDisabled();
|
||||
if (!joinable && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
|
||||
ImGui::SetTooltip("%s", row.self ? "This is your lobby." : row.full ? "This server is full."
|
||||
ImGui::SetTooltip("%s", row.self ? "This is your lobby." : here ? "You are skating here."
|
||||
: row.full ? "This server is full."
|
||||
: mp.lobby_joining ? "A lobby join is in progress."
|
||||
: "Leave your current session before joining.");
|
||||
} else if (joinable && !row.same_map && ImGui::IsItemHovered()) {
|
||||
ImGui::SetTooltip("Joining loads %s for you.", row.map.c_str());
|
||||
: "End your session before joining another.");
|
||||
} else if (joinable && ImGui::IsItemHovered()) {
|
||||
if (hop && row.same_map) ImGui::SetTooltip("Leaves your current session and joins this one.");
|
||||
else if (hop) ImGui::SetTooltip("Leaves your current session, loads %s and joins this one.", row.map.c_str());
|
||||
else if (!row.same_map) ImGui::SetTooltip("Joining loads %s for you.", row.map.c_str());
|
||||
}
|
||||
const auto players = std::to_string(lobby.players) + " / " + std::to_string(lobby.capacity);
|
||||
const auto players_size = menu.bold->CalcTextSizeA(px(18), FLT_MAX, 0, players.c_str());
|
||||
@@ -362,11 +381,11 @@ void join_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks
|
||||
tag(menu, "PASSWORD", skate_theme::warning);
|
||||
}
|
||||
{
|
||||
const char *kind = lobby.dedicated ? "DEDICATED" : "PLAYER";
|
||||
const char *kind = lobby.official ? "OFFICIAL" : lobby.dedicated ? "DEDICATED" : "PLAYER";
|
||||
const auto size = menu.bold->CalcTextSizeA(px(12), FLT_MAX, 0, kind);
|
||||
right -= size.x + px(14) + px(12);
|
||||
ImGui::SetCursorScreenPos(ImVec2(right, top.y + (tile_height - ImGui::GetFrameHeight()) * .5f));
|
||||
tag(menu, kind, lobby.dedicated ? skate_theme::blue : skate_theme::tile_light);
|
||||
tag(menu, kind, lobby.official ? skate_theme::official : lobby.dedicated ? skate_theme::blue : skate_theme::tile_light);
|
||||
}
|
||||
|
||||
// Left: lobby name over its map.
|
||||
@@ -376,12 +395,21 @@ void join_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks
|
||||
ImGui::PushFont(menu.bold);
|
||||
ImGui::PushClipRect(ImGui::GetCursorScreenPos(),
|
||||
ImVec2(ImGui::GetCursorScreenPos().x + text_width, top.y + tile_height), true);
|
||||
if (lobby.official || with_friends)
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, lobby.official ? skate_theme::official_text : skate_theme::friends_text);
|
||||
ImGui::TextUnformatted(lobby.name.c_str());
|
||||
if (lobby.official || with_friends) ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::grey_text);
|
||||
ImGui::TextUnformatted((row.map + (row.same_map ? " - your map" : "") +
|
||||
(lobby.ping >= 0 ? " - " + std::to_string(lobby.ping) + " ms" : std::string{})).c_str());
|
||||
ImGui::PopStyleColor();
|
||||
if (!lobby.friends.empty()) {
|
||||
ImGui::SameLine(0, 0);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::good);
|
||||
ImGui::TextUnformatted((" - with " + lobby_friends_text(lobby)).c_str());
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
ImGui::PopClipRect();
|
||||
ImGui::EndGroup();
|
||||
|
||||
|
||||
@@ -399,12 +399,20 @@ void special_page(SkateMenu &menu, const Model &model, const CallbacksV3 &) {
|
||||
ImGui::PushID(item);
|
||||
begin_card(menu, title.c_str(), title.c_str());
|
||||
// The options in the order shown; a style's mode is 0 what the list gives, 1 off, 2 a
|
||||
// gradient between the player's two colours, 3 their one colour.
|
||||
static constexpr std::array<int, 4> modes{0, 2, 3, 1};
|
||||
int option = static_cast<int>(std::find(modes.begin(), modes.end(), style.mode) - modes.begin()) % 4;
|
||||
// gradient between the player's two colours, 3 their one colour, 4 the rainbow (the
|
||||
// staff's to pick).
|
||||
field(menu, "Color");
|
||||
if (choice(menu, "mode", option, {mp.identity_animation.c_str(), "GRADIENT", "SOLID", "OFF"}, shown))
|
||||
send(modes[static_cast<std::size_t>(option)], style.speed);
|
||||
if (mp.identity_rainbow) {
|
||||
static constexpr std::array<int, 5> modes{0, 4, 2, 3, 1};
|
||||
int option = static_cast<int>(std::find(modes.begin(), modes.end(), style.mode) - modes.begin()) % 5;
|
||||
if (choice(menu, "mode", option, {mp.identity_animation.c_str(), "RAINBOW", "GRADIENT", "SOLID", "OFF"}, shown))
|
||||
send(modes[static_cast<std::size_t>(option)], style.speed);
|
||||
} else {
|
||||
static constexpr std::array<int, 4> modes{0, 2, 3, 1};
|
||||
int option = static_cast<int>(std::find(modes.begin(), modes.end(), style.mode) - modes.begin()) % 4;
|
||||
if (choice(menu, "mode", option, {mp.identity_animation.c_str(), "GRADIENT", "SOLID", "OFF"}, shown))
|
||||
send(modes[static_cast<std::size_t>(option)], style.speed);
|
||||
}
|
||||
if (style.mode == 2 || style.mode == 3) {
|
||||
const bool gradient = style.mode == 2;
|
||||
field(menu, gradient ? "Colors" : "Pick", gradient ? "The two colors it moves between. Click one to change it."
|
||||
@@ -436,7 +444,7 @@ void special_page(SkateMenu &menu, const Model &model, const CallbacksV3 &) {
|
||||
}
|
||||
}
|
||||
// A colour that stands still has no speed.
|
||||
if (style.mode == 0 || style.mode == 2) {
|
||||
if (style.mode == 0 || style.mode == 2 || style.mode == 4) {
|
||||
int speed = style.speed;
|
||||
field(menu, "Speed");
|
||||
if (choice(menu, "speed", speed, {"NORMAL", "SLOW", "FAST"}, shown)) send(style.mode, speed);
|
||||
|
||||
@@ -608,6 +608,28 @@ void session_page(SkateMenu &menu, const Model &model) {
|
||||
: "Only the host can build. Your editor and object tools are locked.")
|
||||
: mp.dedicated ? "Nobody can build. Existing objects stay."
|
||||
: "Nobody can build, including the host. Existing objects stay.");
|
||||
// How many objects each player may have placed: a few round numbers, and whatever the
|
||||
// server's file or console set that is not one of them.
|
||||
{
|
||||
static constexpr std::array<unsigned, 7> limits{0, 10, 25, 50, 100, 250, 500};
|
||||
const auto found = std::find(limits.begin(), limits.end(), mp.object_limit);
|
||||
const std::string custom = std::to_string(mp.object_limit);
|
||||
std::array<const char *, 8> labels{"No limit", "10", "25", "50", "100", "250", "500", custom.c_str()};
|
||||
int picked = static_cast<int>(found - limits.begin());
|
||||
field(menu, "Objects per player");
|
||||
ImGui::BeginDisabled(!controls_session(mp));
|
||||
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||
if (ImGui::Combo("##object-limit", &picked, labels.data(), found == limits.end() ? 8 : 7) && picked < 7)
|
||||
send_private(menu, "object-limit", picked ? std::to_string(limits[static_cast<std::size_t>(picked)]) : std::string("off"), unused, false);
|
||||
ImGui::EndDisabled();
|
||||
const char *exempt = mp.dedicated ? "Admins" : "The host";
|
||||
if (!mp.object_limit) note("Everyone can place as many objects as they like.");
|
||||
else if (mp.object_limit_own)
|
||||
note(("You have placed " + std::to_string(mp.objects_placed) + " of " + std::to_string(mp.object_limit_own) +
|
||||
" objects. Delete one to place another once you reach the limit.").c_str());
|
||||
else note((std::string("Each player can have ") + std::to_string(mp.object_limit) + " objects placed. " + exempt +
|
||||
(mp.dedicated ? " are not limited." : " is not limited.")).c_str());
|
||||
}
|
||||
if (controls_session(mp)) {
|
||||
note(mp.dedicated ? "Enforced by the server, so modified clients cannot get around it."
|
||||
: "Enforced by the host, so modified clients cannot get around it.");
|
||||
|
||||
@@ -16,6 +16,7 @@ inline constexpr std::array nametag_gradients{
|
||||
NametagGradient{multiplayer::nametag_creator_start, multiplayer::nametag_creator},
|
||||
NametagGradient{multiplayer::nametag_homie_start, multiplayer::nametag_homie},
|
||||
NametagGradient{multiplayer::nametag_centrix_start, multiplayer::nametag_centrix},
|
||||
NametagGradient{multiplayer::nametag_staff_start, multiplayer::nametag_staff},
|
||||
};
|
||||
inline const NametagGradient* nametag_gradient(ImU32 colour) noexcept {
|
||||
for (const auto& gradient : nametag_gradients)
|
||||
|
||||
@@ -312,7 +312,7 @@ struct Nametag {
|
||||
std::array<float, 3> position{}; // above the skater's head, world space
|
||||
std::string name;
|
||||
std::uint32_t color{0xffffffffU}; // R, G, B, A bytes (IM_COL32)
|
||||
std::string tag; // role badge before the name ("Dev", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend")
|
||||
std::string tag; // role badge before the name ("Dev", "Staff", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend")
|
||||
float distance{}; // metres from the local skater
|
||||
bool talking{};
|
||||
// Recent chat lines to show as bubbles above the head, oldest first ("" = none).
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
|
||||
// The role badge ("Dev", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend") before a player's name, in chat
|
||||
// The role badge ("Dev", "Staff", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend") before a player's name, in chat
|
||||
// and on nametags: a pill in the role's colour (animated for the roles in nametag_gradient.h) with dark text.
|
||||
namespace dingosdk::overlay::detail {
|
||||
inline float role_badge_width(ImFont *font, float size, const std::string &tag) {
|
||||
|
||||
@@ -31,7 +31,7 @@ struct SkateMenu {
|
||||
std::optional<int> map_rotation_pending; // the rotation slider, likewise
|
||||
double map_rotation_until{};
|
||||
bool ban_confirm_on_server{}; // the Ban popup bans from a dedicated server
|
||||
int multiplayer_lobby_sort = 0;
|
||||
int multiplayer_lobby_sort = 1; // most players first
|
||||
float multiplayer_code_height = 0; // what the join-by-code card took last frame
|
||||
// Bans: the manual add form, and the player a Ban button is asking about.
|
||||
std::array<char, 24> ban_id{};
|
||||
|
||||
@@ -16,6 +16,13 @@ inline constexpr ImU32 tile_light = IM_COL32(61, 62, 66, 240);
|
||||
inline constexpr ImU32 blue = IM_COL32(1, 131, 255, 255);
|
||||
inline constexpr ImU32 blue_hover = IM_COL32(40, 152, 255, 255);
|
||||
inline constexpr ImU32 blue_active = IM_COL32(0, 110, 215, 255);
|
||||
// The ReSkate team's own servers: the logo's blue, a server tile tinted with it, and their name on it.
|
||||
inline constexpr ImU32 official = IM_COL32(0, 145, 255, 255);
|
||||
inline constexpr ImU32 official_tile = IM_COL32(8, 44, 82, 240);
|
||||
inline constexpr ImU32 official_text = IM_COL32(110, 190, 255, 255);
|
||||
// A server Steam friends are skating in: a tile tinted green, and its name on it.
|
||||
inline constexpr ImU32 friends_tile = IM_COL32(14, 58, 32, 240);
|
||||
inline constexpr ImU32 friends_text = IM_COL32(120, 226, 156, 255);
|
||||
inline constexpr ImU32 black = IM_COL32(0, 0, 0, 255);
|
||||
inline constexpr ImU32 white = IM_COL32(245, 245, 245, 255);
|
||||
inline constexpr ImU32 grey_text = IM_COL32(150, 152, 158, 255);
|
||||
|
||||
+10
-4
@@ -564,10 +564,17 @@ std::string steam_persona_name() {
|
||||
// injection (seen as "Cannot sample the executable primary thread", Windows
|
||||
// error 5, once the suspended thread tears down). dbghelp.dll exports
|
||||
// MiniDumpWriteDump under the same name, so a copy of it beside Skate.exe under
|
||||
// the API set's file name satisfies the import. On Windows the schema is used
|
||||
// instead and a same-named file is ignored, so creating it there is harmless.
|
||||
// the API set's file name satisfies the import. On Windows the schema handles
|
||||
// this import without a shim, so do not create one there.
|
||||
void ensure_minidump_apiset_shim(const fs::path& game_directory) noexcept {
|
||||
try {
|
||||
// Windows resolves this API set itself. Only Wine/Proton needs a file
|
||||
// beside Skate.exe; copying a system DLL under an API-set name on
|
||||
// Windows creates an unnecessary executable that looks suspicious.
|
||||
const auto ntdll = GetModuleHandleW(L"ntdll.dll");
|
||||
const auto kernel32 = GetModuleHandleW(L"kernel32.dll");
|
||||
if (!(ntdll && GetProcAddress(ntdll, "wine_get_version")) &&
|
||||
!(kernel32 && GetProcAddress(kernel32, "wine_get_dos_file_name"))) return;
|
||||
const auto shim = game_directory / L"api-ms-win-core-debug-minidump-l1-1-0.dll";
|
||||
std::error_code ec;
|
||||
if (fs::exists(shim, ec)) return;
|
||||
@@ -601,8 +608,7 @@ DWORD start_game(const Session& session, const launcher::LaunchOptions& options,
|
||||
throw std::runtime_error(
|
||||
"Skate.exe, ReSkate.dll, and the original steam_api64.dll must be beside ReSkateLauncher.exe");
|
||||
|
||||
// Satisfy Skate.exe's MiniDumpWriteDump API-set import before it is created,
|
||||
// so the game loads under Wine/Proton instead of aborting in the loader.
|
||||
// Satisfy Skate.exe's MiniDumpWriteDump API-set import under Wine/Proton.
|
||||
ensure_minidump_apiset_shim(paths.directory);
|
||||
|
||||
logging::write(logging::Level::info, logging::Channel::launcher, L"Validating exact Skate.exe SHA-256 and PE identity");
|
||||
|
||||
+27
-5
@@ -1,15 +1,16 @@
|
||||
# ReSkate dedicated server on Linux
|
||||
|
||||
Native x86_64 build. Same lobby protocol as `ReSkateServer.exe`; needs no game install.
|
||||
Self-update is disabled on Linux (V1) — update by replacing the binary.
|
||||
A release build updates itself, like the Windows server: see [Updating](#updating).
|
||||
|
||||
## Requirements
|
||||
|
||||
- 64-bit Linux (tested: CachyOS/Arch, Ubuntu 22.04+ should work).
|
||||
- `cmake >= 3.24`, `g++ >= 12` (C++20), `libssl-dev` (OpenSSL), `libcurl` headers optional (not needed for V1).
|
||||
- `curl` on the machine that runs the server: it is how the server reads the ReSkate team's
|
||||
global ban list (`api.reskate.dev`). Without it the server says so in its log and enforces
|
||||
only its own bans. Not needed with `"global_bans": false`.
|
||||
global ban list (`api.reskate.dev`) and how it downloads its own updates. Without it the
|
||||
server says so in its log, enforces only its own bans and stays on its version.
|
||||
- `tar` (with gzip) on that machine too, to unpack an update.
|
||||
- Steam shared libs beside the binary: `libsteam_api.so` + `steamclient.so`
|
||||
(Valve proprietary, not in git — but bundled in the CI/release assets,
|
||||
like the Windows zip bundles its DLLs; otherwise fetch them, see below).
|
||||
@@ -110,10 +111,31 @@ sudo systemctl enable --now reskate-server
|
||||
The server writes its own `ReSkateServer.log` next to the binary; the console
|
||||
output goes to the journal (`journalctl -u reskate-server -f`).
|
||||
|
||||
## Updating
|
||||
|
||||
A server from a release tarball updates itself the way the Windows one does: at startup and
|
||||
every half hour it asks GitHub for the latest release, and when that holds a newer Linux
|
||||
server it installs it once nobody is on, then carries on as the new version. `update` in
|
||||
the console does it straight away. `"auto_update": false` in `ReSkateServer.json`, or
|
||||
`--no-update`, turns it off. A server you built yourself never replaces itself.
|
||||
|
||||
- The release's `launcher.json` pins the tarball and the `ReSkateServer` inside it by
|
||||
SHA-256; nothing else is installed. The download is `curl` over HTTPS and the unpacking
|
||||
is `tar`, so both must be on the machine.
|
||||
- Everything in the tarball is replaced in the server's folder (the server, the Steam
|
||||
libraries, the readmes, `world-layers.json`). Your own files are not in it and are kept:
|
||||
`ReSkateServer.json`, `Mods`, the logs.
|
||||
- The user the server runs as must be able to write to the server's folder. With the
|
||||
systemd unit below that is `reskate` and `/opt/reskate-server`:
|
||||
`sudo chown -R reskate: /opt/reskate-server`.
|
||||
- The updated server takes over the same process, so systemd (or your terminal) keeps the
|
||||
server it started; nothing restarts the service.
|
||||
- A release made before this (1.1.3 and older) has no Linux entry in `launcher.json`: the
|
||||
server logs that the latest release has no Linux server and stays as it is. To get onto
|
||||
a self-updating version from one of those, replace the files once by hand.
|
||||
|
||||
## Notes / limits (V1)
|
||||
|
||||
- `auto_update` / `update` command: `updates_enabled()==false` on Linux.
|
||||
`check_for_update` reports “self-update is not supported on Linux”.
|
||||
- `--export-world-layers` on Linux: the reader works, the scanner needs the
|
||||
Windows game (`Data/layout.toc` + CAS) and `oo2core_9_win64.dll` (Oodle).
|
||||
Oodle blocks throw `Oodle CAS data is only supported on Windows` (caught,
|
||||
|
||||
+14
-1
@@ -42,7 +42,7 @@ straight away (players are told to rejoin). Turn this off with
|
||||
|
||||
ReSkateServer.json
|
||||
------------------
|
||||
name Shown in the browser: 1-64 letters, numbers, spaces and - _ [ ] ( ).
|
||||
name Shown in the browser: 1-64 letters, numbers, spaces and - _ / [ ] ( ).
|
||||
map The map everyone skates, named like the game's load command:
|
||||
"San Vansterdam", "Isle of Grom", "Super Ultra Mega Resort",
|
||||
"Stadium 1", or a custom map such as "bbcity" (see Custom maps).
|
||||
@@ -58,6 +58,15 @@ max_players 1-249.
|
||||
password Empty for anyone; otherwise players type it to join.
|
||||
welcome A chat line sent to each player as they join.
|
||||
listed false hides the server; players then need the code.
|
||||
steam_token A Steam game server login token, or empty (default). Without one
|
||||
the server signs in anonymously and gets a new Steam ID every
|
||||
start. With one it keeps the same Steam ID, printed at startup.
|
||||
Make a token at steamcommunity.com/dev/managegameservers with
|
||||
App ID 3354750; each running server needs its own. Keep it
|
||||
private: anyone with it can sign in as your server.
|
||||
The ReSkate team can set the in-game server browser to show
|
||||
only servers that have one; the server says so in its log when
|
||||
that hides it. Players can always join with the code.
|
||||
auto_update Install new ReSkate releases when nobody is on (default true).
|
||||
global_bans Turn away players the ReSkate team has banned from multiplayer
|
||||
(default true). The list is read from api.reskate.dev at startup
|
||||
@@ -110,6 +119,9 @@ voice_chat Allow voice chat.
|
||||
voice_range How far proximity voice reaches, 50-1000 m.
|
||||
distances When far-away players update less often (metres).
|
||||
object_placement everyone, admins (only admins can build), or nobody.
|
||||
object_limit How many objects each player may have placed, 1-1024
|
||||
(default 100), or 0 for no limit. Admins are not limited.
|
||||
A player at the limit deletes one to place another.
|
||||
noclip, no_bail, Let players use noclip (and tp) / No Bail / the forward and up
|
||||
boosts boosts (default true; admins always can).
|
||||
enforce_tuning Players skate with the game's own Gameplay/SkatePhysicsTuning,
|
||||
@@ -154,6 +166,7 @@ and change voice, distances, placement and kicks from the Multiplayer menu.
|
||||
tps 20|30|60|120 voice on|off voice-range <m>
|
||||
distances <full> <half> <half-return> <low>
|
||||
placement everyone|admins|nobody clear-objects
|
||||
objects <number>|off How many objects each player may have placed.
|
||||
noclip on|off nobail on|off boosts on|off
|
||||
What players may use (admins always can).
|
||||
tuning on|off Everyone on the game's own physics tuning.
|
||||
|
||||
@@ -48,8 +48,32 @@ int run() {
|
||||
const auto reloaded = load_config(file);
|
||||
check(reloaded.name == "Old Server" && !reloaded.boosts && reloaded.votes.map.percent == 60, "Values lost on rewrite");
|
||||
|
||||
// Server names: letters, digits, spaces and - _ [ ] ( ) only.
|
||||
check(valid_server_name("Old Server") && valid_server_name("[EU] Skate_Park-2 (24x7)") && valid_server_name("a"),
|
||||
// steam_token: empty (anonymous) unless set, kept on a rewrite, and only letters and digits.
|
||||
// (The maps are not loaded yet, so config_error has another complaint: look for this one.)
|
||||
const auto token_refused = [](const ServerConfig &c) { return config_error(c).find("steam_token") != std::string::npos; };
|
||||
check(has("steam_token") && config.steam_token.empty() && !token_refused(config), "steam_token is not a new, empty setting");
|
||||
auto tokened = reloaded;
|
||||
tokened.steam_token = "0123456789ABCDEF0123456789ABCDEF";
|
||||
save_config(tokened);
|
||||
check(load_config(file).steam_token == tokened.steam_token && !token_refused(tokened), "steam_token was not kept");
|
||||
tokened.steam_token = "not a token";
|
||||
check(token_refused(tokened), "A steam_token with other characters was accepted");
|
||||
tokened.steam_token = std::string(65, 'A');
|
||||
check(token_refused(tokened), "An overlong steam_token was accepted");
|
||||
// object_limit: 100 unless set (0 is no limit), and kept on a rewrite.
|
||||
check(has("object_limit") && config.object_limit == 100, "object_limit is not a new setting of 100");
|
||||
auto limited = reloaded;
|
||||
limited.object_limit = 50;
|
||||
save_config(limited);
|
||||
check(load_config(file).object_limit == 50, "object_limit was not kept");
|
||||
limited.object_limit = 0;
|
||||
save_config(limited);
|
||||
check(load_config(file).object_limit == 0, "No object limit became the default again");
|
||||
save_config(reloaded);
|
||||
|
||||
// Server names: letters, digits, spaces and - _ / [ ] ( ) only.
|
||||
check(valid_server_name("Old Server") && valid_server_name("[EU] Skate_Park-2 (24x7)") && valid_server_name("a") &&
|
||||
valid_server_name("EU/West 24/7") && !valid_server_name("///") && !valid_server_name("a\\b"),
|
||||
"A plain server name was refused");
|
||||
check(!valid_server_name("") && !valid_server_name(std::string(65, 'a')) && !valid_server_name("Best! Server") &&
|
||||
!valid_server_name("caf\xC3\xA9") && !valid_server_name("a.b") && !valid_server_name("<b>x</b>") &&
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// What the Linux server's self-update accepts from a release: launcher.json's "server_linux"
|
||||
// entry, and the list of files in the tarball.
|
||||
#include "Server/server_release.h"
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace {
|
||||
int failures{};
|
||||
void check(bool ok, const char *what) {
|
||||
if (!ok) {
|
||||
std::cerr << "FAIL: " << what << '\n';
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
template <class Call> bool throws(Call &&call) {
|
||||
try {
|
||||
call();
|
||||
} catch (const std::exception &) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
using namespace dingosdk::server;
|
||||
const std::string sha(64, 'a'), exe(64, 'b');
|
||||
const auto assets = release_assets(
|
||||
R"({"tag_name":"v1.2.0","assets":[{"name":"launcher.json","browser_download_url":"https://github.com/o/r/releases/download/v1.2.0/launcher.json"},)"
|
||||
R"({"name":"ReSkateServer-Linux-1.2.0.tar.gz","browser_download_url":"https://github.com/o/r/releases/download/v1.2.0/ReSkateServer-Linux-1.2.0.tar.gz"}]})");
|
||||
check(assets.size() == 2 && assets.contains("launcher.json"), "A release's assets were not read");
|
||||
check(throws([] { release_assets(R"({"message":"API rate limit exceeded"})"); }), "An answer that is not a release was accepted");
|
||||
|
||||
const auto entry = [&](std::string url, std::string hash, std::string exe_hash, std::string size, std::string version = "1.2.0") {
|
||||
return R"({"schema":1,"server":{"url":"asset:ReSkateServer-1.2.0.zip"},"server_linux":{"version":")" + version + R"(","url":")" + url +
|
||||
R"(","sha256":")" + hash + R"(","size":)" + size + R"(,"exe_sha256":")" + exe_hash + R"("}})";
|
||||
};
|
||||
const auto good = parse_linux_release(entry("asset:ReSkateServer-Linux-1.2.0.tar.gz", sha, exe, "31000000"), assets);
|
||||
check(good && good->version == "1.2.0" && good->size == 31000000 && good->sha256 == sha && good->exe_sha256 == exe &&
|
||||
good->url == "https://github.com/o/r/releases/download/v1.2.0/ReSkateServer-Linux-1.2.0.tar.gz",
|
||||
"A Linux release was not read, or its asset was not resolved");
|
||||
const auto direct = parse_linux_release(entry("https://example.com/server.tar.gz", sha, exe, "1"), {});
|
||||
check(direct && direct->url == "https://example.com/server.tar.gz", "A direct HTTPS link was not kept");
|
||||
// A release from before Linux servers updated themselves has no entry: nothing to do.
|
||||
check(!parse_linux_release(R"({"schema":1,"server":{"url":"asset:ReSkateServer-1.1.3.zip"}})", assets), "A release with no Linux server gave one");
|
||||
for (const auto &wrong : {entry("asset:missing.tar.gz", sha, exe, "1"), entry("http://example.com/s.tar.gz", sha, exe, "1"),
|
||||
entry("https://example.com/s.tar.gz --output /etc/passwd", sha, exe, "1"),
|
||||
entry("https://example.com/s.tar.gz", std::string(64, 'A'), exe, "1"),
|
||||
entry("https://example.com/s.tar.gz", sha, "abc", "1"), entry("https://example.com/s.tar.gz", sha, exe, "0"),
|
||||
entry("https://example.com/s.tar.gz", sha, exe, "999999999999"),
|
||||
entry("https://example.com/s.tar.gz", sha, exe, "-5"), entry("https://example.com/s.tar.gz", sha, exe, "1", "1.2; rm"),
|
||||
std::string(R"({"server_linux":"yes"})"), std::string("[]")})
|
||||
check(throws([&] { parse_linux_release(wrong, assets); }), ("A wrong Linux release was accepted: " + wrong).c_str());
|
||||
|
||||
check(https_url("https://github.com/o/r/releases/download/v1/a.tar.gz?x=1&y=%20") && !https_url("https://") &&
|
||||
!https_url("file:///etc/passwd") && !https_url("https://a b") && !https_url("https://a\nb") && !https_url("-o x"),
|
||||
"Only plain HTTPS links may be handed to curl");
|
||||
|
||||
check(checked_archive_listing("ReSkateServer-Linux-1.2.0/\nReSkateServer-Linux-1.2.0/ReSkateServer\n"
|
||||
"ReSkateServer-Linux-1.2.0/licenses/\nReSkateServer-Linux-1.2.0/licenses/zstd-LICENSE.txt\n") ==
|
||||
"ReSkateServer-Linux-1.2.0",
|
||||
"A release tarball's listing was refused");
|
||||
check(checked_archive_listing("./pkg/\r\n./pkg/ReSkateServer\r\n") == "pkg", "A listing written with ./ was refused");
|
||||
for (const char *wrong : {"", "pkg/\n", "pkg/ReSkateServer\nother/file\n", "pkg/a\n/etc/passwd\n", "pkg/a\npkg/../../etc/passwd\n",
|
||||
"pkg/a\npkg//b\n", "pkg/a\npkg\\b\n", "../a\n../b\n", "pkg/a\n./\n"})
|
||||
check(throws([&] { checked_archive_listing(wrong); }), "A tarball that is not one plain folder was accepted");
|
||||
|
||||
if (!failures) std::cout << "server release: ok\n";
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
@@ -50,6 +50,7 @@ BanListCheck use_ban_list(std::string_view answer) {
|
||||
BanListCheck check;
|
||||
try {
|
||||
auto lists = multiplayer::parse_identity_lists(answer);
|
||||
multiplayer::server_tokens_rule = multiplayer::parse_server_tokens_required(answer);
|
||||
const auto &bans = lists[static_cast<std::size_t>(multiplayer::IdentityList::banned)];
|
||||
check.banned = bans.size();
|
||||
check.changed = last != bans;
|
||||
|
||||
+23
-4
@@ -6,6 +6,7 @@
|
||||
#include "server_config.h"
|
||||
#include "server_host.h"
|
||||
#include "server_update.h"
|
||||
#include "Extension/Multiplayer/developer_identity.h"
|
||||
#include "steam_server.h"
|
||||
#include "Extension/Multiplayer/Session/monotonic_clock.h"
|
||||
#include "Engine/Core/Platform/path_text.h"
|
||||
@@ -364,16 +365,19 @@ int run(int argc, char **argv, bool skip_update) {
|
||||
|
||||
SteamServer steam;
|
||||
std::string error;
|
||||
if (!steam.start(here, config.port, config.query_port, error)) {
|
||||
if (!steam.start(here, config.port, config.query_port, config.steam_token, error)) {
|
||||
write_log(error);
|
||||
return 1;
|
||||
}
|
||||
write_log("Signing in to Steam...");
|
||||
write_log(config.steam_token.empty() ? "Signing in to Steam..." : "Signing in to Steam with steam_token...");
|
||||
const auto login_started = std::chrono::steady_clock::now();
|
||||
while (!steam.logged_on() && !stopping) {
|
||||
steam.run_callbacks();
|
||||
if (std::chrono::steady_clock::now() - login_started > std::chrono::seconds(60)) {
|
||||
write_log("Steam sign-in timed out after 60 s. Check the internet connection and try again.");
|
||||
write_log(config.steam_token.empty()
|
||||
? "Steam sign-in timed out after 60 s. Check the internet connection and try again."
|
||||
: "Steam sign-in timed out after 60 s. Steam may not have accepted steam_token: it must be a token for "
|
||||
"app 3354750 that no other running server is using. Or check the internet connection.");
|
||||
return 1;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
@@ -391,8 +395,14 @@ int run(int argc, char **argv, bool skip_update) {
|
||||
return 1;
|
||||
}
|
||||
write_log(config.name + " is up on " + host.map_name() + " for " + std::to_string(config.max_players) + " players.");
|
||||
write_log("Steam ID " + std::to_string(steam.steam_id()) + ", public IP " + steam.public_ip() + ".");
|
||||
write_log("Steam ID " + std::to_string(steam.steam_id()) +
|
||||
(config.steam_token.empty() ? " (anonymous: new every start; set steam_token to keep one)" : " (from steam_token: the same every start)") +
|
||||
", public IP " + steam.public_ip() + ".");
|
||||
write_log("Join code: " + host.invite() + (config.password.empty() ? "" : " (password required)"));
|
||||
if (config.steam_token.empty() && config.listed)
|
||||
write_log("No steam_token: the server browser can be set to show only servers that have one, and then "
|
||||
"this server is not in it (players can still join with the code). It takes a minute to make "
|
||||
"one: see steam_token in README.");
|
||||
write_log(config.admins.empty() ? "No admins yet: type \"admin add <SteamID64>\" to add one."
|
||||
: std::to_string(config.admins.size()) + " admin(s). Type help for commands.");
|
||||
|
||||
@@ -402,6 +412,7 @@ int run(int argc, char **argv, bool skip_update) {
|
||||
#endif
|
||||
auto next_advertise = std::chrono::steady_clock::now();
|
||||
std::optional<bool> name_allowed; // last seen: whether the name may be listed
|
||||
bool tokens_required{}; // last seen: whether the browser wants a steam_token
|
||||
auto next_update_check = next_advertise + update_interval;
|
||||
std::future<UpdateCheck> update_check;
|
||||
bool update_now{}, update_waiting{}, restart{};
|
||||
@@ -484,6 +495,14 @@ int run(int argc, char **argv, bool skip_update) {
|
||||
}
|
||||
if (const auto now = std::chrono::steady_clock::now(); now >= next_advertise) {
|
||||
next_advertise = now + std::chrono::seconds(2);
|
||||
// The ReSkate team's rule, read with its ban list: say when it starts or stops hiding this server.
|
||||
if (const bool required = multiplayer::server_tokens_required(); required != tokens_required) {
|
||||
tokens_required = required;
|
||||
if (config.steam_token.empty() && config.listed)
|
||||
write_log(required ? "The server browser now shows only servers with a steam_token, so this server is "
|
||||
"hidden from it. Add a steam_token (see README) to be listed again."
|
||||
: "The server browser shows servers without a steam_token again.");
|
||||
}
|
||||
// A name with a bad word in it is never listed (clients hide one too); the
|
||||
// server still runs and players can join with its code.
|
||||
const bool allowed = !text::contains_bad_words(config.name);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "Extension/Multiplayer/Net/protocol.h"
|
||||
#include "Engine/Game/World/world_names.h"
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
@@ -25,6 +26,7 @@ Json to_json(const ServerConfig &c) {
|
||||
root["password"] = c.password;
|
||||
root["welcome"] = c.welcome;
|
||||
root["listed"] = c.listed;
|
||||
root["steam_token"] = c.steam_token;
|
||||
root["auto_update"] = c.auto_update;
|
||||
root["global_bans"] = c.global_bans;
|
||||
root["activity_log"] = c.activity_log;
|
||||
@@ -48,6 +50,7 @@ Json to_json(const ServerConfig &c) {
|
||||
distances["low_rate_start"] = c.distances.low_rate_start;
|
||||
root["distances"] = std::move(distances);
|
||||
root["object_placement"] = placement_text(c.object_placement);
|
||||
root["object_limit"] = c.object_limit;
|
||||
root["noclip"] = c.noclip;
|
||||
root["no_bail"] = c.no_bail;
|
||||
root["boosts"] = c.boosts;
|
||||
@@ -124,6 +127,7 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector<std::str
|
||||
c.password = root.value("password", c.password);
|
||||
c.welcome = root.value("welcome", c.welcome);
|
||||
c.listed = root.value("listed", c.listed);
|
||||
c.steam_token = root.value("steam_token", c.steam_token);
|
||||
c.auto_update = root.value("auto_update", c.auto_update);
|
||||
c.global_bans = root.value("global_bans", c.global_bans);
|
||||
c.activity_log = root.value("activity_log", c.activity_log);
|
||||
@@ -176,6 +180,7 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector<std::str
|
||||
c.votes.seconds = std::clamp(votes.value("seconds", c.votes.seconds), 10U, 300U);
|
||||
c.votes.cooldown = std::clamp(votes.value("cooldown_seconds", c.votes.cooldown), 0U, 3600U);
|
||||
}
|
||||
c.object_limit = root.value("object_limit", c.object_limit);
|
||||
const auto placement = root.value("object_placement", placement_text(c.object_placement));
|
||||
// On a dedicated server the protocol's "host only" means its admins.
|
||||
c.object_placement = placement == "nobody" ? ObjectPlacement::nobody
|
||||
@@ -232,6 +237,8 @@ std::optional<std::uint64_t> parse_scoring(std::string_view text) {
|
||||
std::string config_error(const ServerConfig &c) {
|
||||
using namespace multiplayer;
|
||||
if (!valid_server_name(c.name)) return std::string("name must be ") + server_name_rule + ".";
|
||||
if (c.steam_token.size() > 64 || !std::all_of(c.steam_token.begin(), c.steam_token.end(), [](unsigned char ch) { return std::isalnum(ch); }))
|
||||
return "steam_token must be a game server login token (letters and digits), or empty to sign in anonymously.";
|
||||
if (c.map.empty() || !valid_map_destination(map_destination(c.map)))
|
||||
return "map \"" + c.map + "\" is not a known map. Use a name like \"San Vansterdam\", or put the map's mod "
|
||||
"folder in Mods next to the server.";
|
||||
@@ -245,6 +252,7 @@ std::string config_error(const ServerConfig &c) {
|
||||
if (!c.welcome.empty() && !valid_chat_text(c.welcome)) return "welcome must be one chat line (at most 200 bytes).";
|
||||
if (!valid_multiplayer_tps(c.tps)) return "tps must be 20, 30, 60 or 120.";
|
||||
if (!valid_voice_range(c.voice_range)) return "voice_range must be 50 to 1000.";
|
||||
if (!valid_object_limit(c.object_limit)) return "object_limit must be 0 (no limit) to " + std::to_string(max_object_limit) + ".";
|
||||
if (!c.distances.valid()) return "distances must be ordered: full_rate_return < half_rate_start <= half_rate_return < low_rate_start <= 10000.";
|
||||
for (unsigned lot = 0; lot < park_lots.size(); ++lot)
|
||||
if (c.parks[lot].empty() || !valid_park(lot, c.parks[lot]))
|
||||
|
||||
@@ -39,6 +39,10 @@ struct ServerConfig {
|
||||
std::string password; // empty: anyone may join
|
||||
std::string welcome; // sent to each player as they join
|
||||
bool listed = true; // shown in the in-game server browser
|
||||
// A Steam game server login token (steamcommunity.com/dev/managegameservers, app 3354750).
|
||||
// With one the server signs in to its own account and keeps the same Steam ID every start,
|
||||
// which is how the ReSkate team's list of official servers knows it. Empty: anonymous.
|
||||
std::string steam_token;
|
||||
bool auto_update = true; // install new releases when nobody is on
|
||||
bool global_bans = true; // turn away players the ReSkate team has banned (global_bans.h)
|
||||
bool activity_log = true; // console lines for throwdowns, objects and loading
|
||||
@@ -64,6 +68,8 @@ struct ServerConfig {
|
||||
MultiplayerDistances distances;
|
||||
// everyone, admins (only the admins below may build) or nobody.
|
||||
ObjectPlacement object_placement = ObjectPlacement::everyone;
|
||||
// Objects each player may have placed (object_placement.h); 0: no limit. Admins are not held to it.
|
||||
unsigned object_limit = default_object_limit;
|
||||
// Whether players may use noclip (and teleport) / No Bail / the boosts (admins always may).
|
||||
bool noclip = true, no_bail = true, boosts = true;
|
||||
// Players skate with the game's own physics tuning, not copies they edited.
|
||||
|
||||
+13
-1
@@ -45,7 +45,7 @@ constexpr std::string_view help_text =
|
||||
"status | players | say <text> | msg <player> <text> | msg-party <player> <text> | msg-admins <text> | kick <player> | ban <player or SteamID64> [name] | unban <SteamID64> | bans\n"
|
||||
"map <name, e.g. San Vansterdam> | maps | name <text> | password <text|off> | welcome <text|off> | listed on|off\n"
|
||||
"tps 20|30|60|120 | voice on|off | voice-range <50-1000> | distances <full> <half> <half-return> <low>\n"
|
||||
"placement everyone|admins|nobody | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n"
|
||||
"placement everyone|admins|nobody | objects <number>|off | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n"
|
||||
"tpall [player] | tphere <player> | votes [map|kick|tod on|off|<percent>] | vote-cancel\n"
|
||||
"map-pool [add|remove <map>|clear] | rotation [<minutes>|off]\n"
|
||||
"park <lot> <layout> | layer-sync on|off | layer <key> default|on|off | tod <time|default>\n"
|
||||
@@ -261,6 +261,7 @@ void Host::send_roster() {
|
||||
p.voice_range = config_.voice_range;
|
||||
p.distances = config_.distances;
|
||||
p.object_placement = config_.object_placement;
|
||||
p.object_limit = config_.object_limit;
|
||||
p.guest_noclip = config_.noclip;
|
||||
p.guest_no_bail = config_.no_bail;
|
||||
p.guest_boosts = config_.boosts;
|
||||
@@ -698,6 +699,8 @@ void Host::sync_objects() {
|
||||
(config_.object_placement == ObjectPlacement::host_only && is_admin(id)))) {
|
||||
auto layout = guest->objects.layout();
|
||||
std::erase_if(layout, [&](const auto &object) { return guest->cleared.contains(object.id); });
|
||||
// No more of a player's objects than the server allows each of them; admins are not limited.
|
||||
if (!is_admin(id)) layout = limited_layout(std::move(layout), guest->shared.objects(), config_.object_limit);
|
||||
if (config_.activity_log) activity_.objects(id, guest->shared.layout(), layout);
|
||||
guest->shared.replace(layout);
|
||||
guest->shared_from = guest->objects.revision();
|
||||
@@ -840,6 +843,7 @@ std::string Host::command(std::string_view line, std::uint64_t admin) {
|
||||
const auto verb = lower(action);
|
||||
// The in-game menu's names for the same settings.
|
||||
const std::string name = verb == "voice-allow" ? "voice" : verb == "object-placement" ? "placement"
|
||||
: verb == "object-limit" ? "objects"
|
||||
: verb == "world-layer-sync" ? "layer-sync" : verb == "noclip-allow" ? "noclip"
|
||||
: verb == "nobail-allow" ? "nobail" : verb == "boosts-allow" ? "boosts"
|
||||
: verb == "tuning-enforce" ? "tuning" : verb;
|
||||
@@ -1190,6 +1194,14 @@ std::string Host::command(std::string_view line, std::uint64_t admin) {
|
||||
: *policy == ObjectPlacement::host_only ? "Only admins can place objects. Everyone else's are frozen."
|
||||
: "Object placement is off. Existing objects stay.");
|
||||
}
|
||||
if (name == "objects") {
|
||||
const auto limit = parse_object_limit(argument);
|
||||
if (!limit) return "objects <1-" + std::to_string(max_object_limit) + ">|off";
|
||||
config_.object_limit = *limit;
|
||||
for (auto &[id, guest] : guests_) guest->shared_from = 0; // look at every layout again
|
||||
return changed(*limit ? "Each player can place up to " + std::to_string(*limit) + " objects. Admins are not limited."
|
||||
: std::string("Players can place as many objects as they like."));
|
||||
}
|
||||
if (name == "votes") {
|
||||
// votes | votes <map|kick|tod> on|off|<percent> | votes seconds|cooldown <n>
|
||||
const auto [what_text, value_text] = split(argument);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#include "server_release.h"
|
||||
#include "Engine/Core/Json/json.h"
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace dingosdk::server {
|
||||
namespace {
|
||||
bool sha256_text(std::string_view text) noexcept {
|
||||
return text.size() == 64 &&
|
||||
std::all_of(text.begin(), text.end(), [](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); });
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool https_url(std::string_view url) noexcept {
|
||||
constexpr std::string_view scheme = "https://";
|
||||
if (!url.starts_with(scheme) || url.size() == scheme.size() || url.size() > 2048) return false;
|
||||
return std::all_of(url.begin(), url.end(), [](unsigned char c) {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
|
||||
std::string_view("-._~:/?#[]@!$&'()*+,;=%").find(static_cast<char>(c)) != std::string_view::npos;
|
||||
});
|
||||
}
|
||||
|
||||
std::map<std::string, std::string, std::less<>> release_assets(std::string_view release_json) {
|
||||
const auto release = Json::parse(release_json);
|
||||
if (!release.is_object() || !release.contains("assets") || !release.at("assets").is_array())
|
||||
throw std::runtime_error("GitHub's answer is not a release");
|
||||
std::map<std::string, std::string, std::less<>> assets;
|
||||
for (const auto &asset : release.at("assets"))
|
||||
if (asset.is_object()) assets[asset.value("name", "")] = asset.value("browser_download_url", "");
|
||||
return assets;
|
||||
}
|
||||
|
||||
std::optional<LinuxRelease> parse_linux_release(std::string_view launcher_json,
|
||||
const std::map<std::string, std::string, std::less<>> &assets) {
|
||||
const auto root = Json::parse(launcher_json);
|
||||
if (!root.is_object()) throw std::runtime_error("launcher.json is not a JSON object");
|
||||
if (!root.contains("server_linux")) return std::nullopt;
|
||||
const auto &entry = root.at("server_linux");
|
||||
if (!entry.is_object()) throw std::runtime_error("launcher.json \"server_linux\" is not an object");
|
||||
LinuxRelease release;
|
||||
release.version = entry.value("version", "");
|
||||
release.url = entry.value("url", "");
|
||||
release.sha256 = entry.value("sha256", "");
|
||||
release.exe_sha256 = entry.value("exe_sha256", "");
|
||||
if (entry.contains("size") && entry.at("size").is_number_unsigned()) release.size = entry.at("size").get<std::uint64_t>();
|
||||
constexpr std::string_view asset = "asset:";
|
||||
if (release.url.starts_with(asset)) {
|
||||
const auto found = assets.find(std::string_view(release.url).substr(asset.size()));
|
||||
if (found == assets.end()) throw std::runtime_error("the release is missing asset " + release.url.substr(asset.size()));
|
||||
release.url = found->second;
|
||||
}
|
||||
if (!https_url(release.url)) throw std::runtime_error("launcher.json \"server_linux.url\" is not an HTTPS link");
|
||||
if (!sha256_text(release.sha256) || !sha256_text(release.exe_sha256))
|
||||
throw std::runtime_error("launcher.json \"server_linux\" hashes must be 64 lowercase hex digits");
|
||||
if (!release.size || release.size > max_linux_release_bytes)
|
||||
throw std::runtime_error("launcher.json \"server_linux.size\" is out of range");
|
||||
if (release.version.empty() || release.version.size() > 32 ||
|
||||
!std::all_of(release.version.begin(), release.version.end(),
|
||||
[](unsigned char c) { return std::isalnum(c) || c == '.' || c == '-' || c == '_'; }))
|
||||
throw std::runtime_error("launcher.json \"server_linux.version\" is not a version");
|
||||
return release;
|
||||
}
|
||||
|
||||
std::string checked_archive_listing(std::string_view listing) {
|
||||
std::string top;
|
||||
std::size_t entries{};
|
||||
for (std::size_t start = 0; start < listing.size();) {
|
||||
auto end = listing.find('\n', start);
|
||||
if (end == std::string_view::npos) end = listing.size();
|
||||
auto line = listing.substr(start, end - start);
|
||||
start = end + 1;
|
||||
if (!line.empty() && line.back() == '\r') line.remove_suffix(1);
|
||||
if (line.empty()) continue;
|
||||
if (line.front() == '/' || line.find('\\') != std::string_view::npos || line.size() > 512)
|
||||
throw std::runtime_error("the update has a file outside its folder");
|
||||
// "./name/..." is how some tars write it.
|
||||
if (line.starts_with("./")) line.remove_prefix(2);
|
||||
std::string_view first;
|
||||
for (std::size_t at = 0; at <= line.size();) {
|
||||
auto slash = line.find('/', at);
|
||||
if (slash == std::string_view::npos) slash = line.size();
|
||||
const auto part = line.substr(at, slash - at);
|
||||
if (part == ".." || part == "." || (part.empty() && slash != line.size()))
|
||||
throw std::runtime_error("the update has a file outside its folder");
|
||||
if (at == 0) first = part;
|
||||
at = slash + 1;
|
||||
}
|
||||
if (first.empty()) throw std::runtime_error("the update has a file outside its folder");
|
||||
if (top.empty()) top = first;
|
||||
else if (top != first) throw std::runtime_error("the update is not one folder");
|
||||
++entries;
|
||||
}
|
||||
if (top.empty() || entries < 2) throw std::runtime_error("the update is empty");
|
||||
return top;
|
||||
}
|
||||
} // namespace dingosdk::server
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
// What the Linux server's self-update reads from a release, apart from how it is fetched
|
||||
// (server_update.cpp): the release's launcher.json entry for the Linux server, and the checks
|
||||
// on the tarball before anything is unpacked. Portable, so the tests run on Windows too.
|
||||
namespace dingosdk::server {
|
||||
// launcher.json's "server_linux": the tarball (pinned by size and SHA-256) and the SHA-256 of
|
||||
// the ReSkateServer inside it, which a running server compares with its own.
|
||||
struct LinuxRelease {
|
||||
std::string version, url, sha256, exe_sha256;
|
||||
std::uint64_t size{};
|
||||
};
|
||||
// The largest tarball a server will download.
|
||||
inline constexpr std::uint64_t max_linux_release_bytes = 512ull * 1024 * 1024;
|
||||
// GitHub's answer for a release, as the names of its assets and where each downloads from.
|
||||
// Throws when the answer is not a release.
|
||||
std::map<std::string, std::string, std::less<>> release_assets(std::string_view release_json);
|
||||
// Nothing when the release was made before Linux servers updated themselves (no
|
||||
// "server_linux"). An "asset:<name>" URL becomes that asset's download link. Throws on an
|
||||
// entry that is there but wrong: no HTTPS link, a hash that is not one, a size out of range.
|
||||
std::optional<LinuxRelease> parse_linux_release(std::string_view launcher_json,
|
||||
const std::map<std::string, std::string, std::less<>> &assets);
|
||||
// `tar -t`'s list of a release tarball: every entry under the one top folder the package has,
|
||||
// none absolute or with a ".." in it. Returns that folder's name; throws otherwise.
|
||||
std::string checked_archive_listing(std::string_view listing);
|
||||
// A link curl may be handed: https, with nothing but URL characters in it.
|
||||
bool https_url(std::string_view url) noexcept;
|
||||
} // namespace dingosdk::server
|
||||
+213
-9
@@ -4,7 +4,17 @@
|
||||
#include "Launcher/updater.h"
|
||||
#include <Windows.h>
|
||||
#else
|
||||
#include "server_release.h"
|
||||
#include "launcher_update_config.h"
|
||||
#include <fcntl.h>
|
||||
#include <poll.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <vector>
|
||||
#endif
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
@@ -88,24 +98,218 @@ void remove_previous_update(const std::filesystem::path &folder) noexcept {
|
||||
}
|
||||
#else // _WIN32
|
||||
|
||||
// Linux V1: self-update is disabled. Operators update by replacing the binary
|
||||
// (package manager / re-download). This keeps the server free of WinHTTP and
|
||||
// Windows file-replacement semantics. A curl-based updater can be added later.
|
||||
bool updates_enabled() noexcept { return false; }
|
||||
// Linux: the same releases, read with the tools every server machine has. curl does the HTTPS
|
||||
// (as for the global bans) and tar unpacks; both are run directly, never through a shell, and
|
||||
// nothing is installed that launcher.json does not pin by SHA-256.
|
||||
namespace {
|
||||
std::mutex release_mutex;
|
||||
std::optional<LinuxRelease> release; // from the last check
|
||||
|
||||
// Read once, before an update can replace the file: /proc/self/exe then names a deleted one.
|
||||
const std::filesystem::path &self() {
|
||||
static const std::filesystem::path path = std::filesystem::read_symlink("/proc/self/exe");
|
||||
return path;
|
||||
}
|
||||
|
||||
// Runs a program found on PATH with these arguments and returns what it printed, at most
|
||||
// `limit` bytes. Throws with the program's own complaint when it fails or cannot be started.
|
||||
std::string run_tool(const std::vector<std::string> &arguments, std::size_t limit) {
|
||||
int out[2], err[2];
|
||||
if (pipe(out) != 0) throw std::runtime_error(arguments.front() + " could not be started");
|
||||
if (pipe(err) != 0) {
|
||||
close(out[0]), close(out[1]);
|
||||
throw std::runtime_error(arguments.front() + " could not be started");
|
||||
}
|
||||
const auto child = fork();
|
||||
if (child < 0) {
|
||||
for (const int fd : {out[0], out[1], err[0], err[1]}) close(fd);
|
||||
throw std::runtime_error(arguments.front() + " could not be started");
|
||||
}
|
||||
if (child == 0) {
|
||||
std::vector<char *> argv;
|
||||
for (const auto &argument : arguments) argv.push_back(const_cast<char *>(argument.c_str()));
|
||||
argv.push_back(nullptr);
|
||||
dup2(out[1], STDOUT_FILENO);
|
||||
dup2(err[1], STDERR_FILENO);
|
||||
if (const int null = open("/dev/null", O_RDONLY); null >= 0) dup2(null, STDIN_FILENO);
|
||||
for (int fd = 3; fd < 1024; ++fd) close(fd);
|
||||
execvp(argv.front(), argv.data());
|
||||
_exit(127);
|
||||
}
|
||||
close(out[1]), close(err[1]);
|
||||
std::string output, complaint;
|
||||
bool too_much{};
|
||||
pollfd fds[2]{{out[0], POLLIN, 0}, {err[0], POLLIN, 0}};
|
||||
for (int open_ends = 2; open_ends;) {
|
||||
if (poll(fds, 2, -1) < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
break;
|
||||
}
|
||||
for (auto &fd : fds) {
|
||||
if (fd.fd < 0 || !(fd.revents & (POLLIN | POLLHUP | POLLERR))) continue;
|
||||
char buffer[65536];
|
||||
const auto count = read(fd.fd, buffer, sizeof(buffer));
|
||||
if (count > 0) {
|
||||
auto &text = fd.fd == out[0] ? output : complaint;
|
||||
const auto room = (fd.fd == out[0] ? limit : std::size_t{4096}) - std::min(text.size(), fd.fd == out[0] ? limit : std::size_t{4096});
|
||||
text.append(buffer, std::min<std::size_t>(room, static_cast<std::size_t>(count)));
|
||||
if (fd.fd == out[0] && static_cast<std::size_t>(count) > room) too_much = true;
|
||||
} else if (count == 0 || errno != EINTR) {
|
||||
close(fd.fd);
|
||||
fd.fd = -1;
|
||||
--open_ends;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (auto &fd : fds)
|
||||
if (fd.fd >= 0) close(fd.fd);
|
||||
int status{};
|
||||
while (waitpid(child, &status, 0) < 0 && errno == EINTR) {}
|
||||
if (WIFEXITED(status) && WEXITSTATUS(status) == 127 && complaint.empty())
|
||||
throw std::runtime_error(arguments.front() + " is not installed");
|
||||
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
|
||||
while (!complaint.empty() && (complaint.back() == '\n' || complaint.back() == '\r')) complaint.pop_back();
|
||||
std::replace(complaint.begin(), complaint.end(), '\n', ' ');
|
||||
throw std::runtime_error(complaint.empty() ? arguments.front() + " failed" : complaint);
|
||||
}
|
||||
if (too_much) throw std::runtime_error(arguments.front() + " answered with more than expected");
|
||||
return output;
|
||||
}
|
||||
|
||||
std::vector<std::string> curl(const std::string &url, std::uint64_t limit, int seconds) {
|
||||
if (!https_url(url)) throw std::runtime_error("a download link is not HTTPS");
|
||||
return {"curl", "--silent", "--show-error", "--fail", "--location", "--proto", "=https", "--proto-redir", "=https",
|
||||
"--max-time", std::to_string(seconds), "--max-filesize", std::to_string(limit), "--user-agent", "ReSkateServer/1",
|
||||
"--url", url};
|
||||
}
|
||||
std::string https_text(const std::string &url, std::size_t limit) { return run_tool(curl(url, limit, 20), limit); }
|
||||
|
||||
struct RemovePath {
|
||||
std::filesystem::path path;
|
||||
~RemovePath() {
|
||||
std::error_code ignored;
|
||||
std::filesystem::remove_all(path, ignored);
|
||||
}
|
||||
};
|
||||
const char *const archive_name = "ReSkateServer-update.tar.gz";
|
||||
const char *const stage_name = ".reskate-update";
|
||||
} // namespace
|
||||
|
||||
bool updates_enabled() noexcept { return launcher_update::launcher_binary_updates; }
|
||||
|
||||
UpdateCheck check_for_update() {
|
||||
UpdateCheck check;
|
||||
check.problem = "self-update is not supported on Linux; update by replacing the binary";
|
||||
try {
|
||||
std::optional<LinuxRelease> found;
|
||||
if (const char *local = std::getenv("RESKATE_LAUNCHER_CONFIG_FILE"); local && *local) {
|
||||
// Test an unpublished config before attaching it to a release, as the launcher can.
|
||||
// Its links must be whole HTTPS ones: there is no release to find an "asset:" in.
|
||||
std::ifstream input(local, std::ios::binary);
|
||||
const std::string text{std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
|
||||
if (!input && !input.eof()) throw std::runtime_error(std::string("cannot read ") + local);
|
||||
found = parse_linux_release(text, {});
|
||||
} else {
|
||||
std::string repo;
|
||||
for (const wchar_t c : std::wstring_view(launcher_update::launcher_release_repo)) repo += static_cast<char>(c);
|
||||
// Only this lookup uses GitHub's rate-limited API; the files come from the release's download links.
|
||||
const auto assets = release_assets(https_text("https://api.github.com/repos/" + repo + "/releases/latest", 4 * 1024 * 1024));
|
||||
const auto manifest = assets.find("launcher.json");
|
||||
if (manifest == assets.end()) {
|
||||
check.problem = "the latest release has no launcher.json";
|
||||
return check;
|
||||
}
|
||||
found = parse_linux_release(https_text(manifest->second, 256 * 1024), assets);
|
||||
}
|
||||
if (!found) {
|
||||
check.problem = "the latest release has no Linux server";
|
||||
return check;
|
||||
}
|
||||
check.version = found->version;
|
||||
check.available = launcher::sha256_file(self()) != found->exe_sha256;
|
||||
std::lock_guard lock(release_mutex);
|
||||
release = std::move(found);
|
||||
} catch (const std::exception &e) {
|
||||
check.problem = e.what();
|
||||
}
|
||||
return check;
|
||||
}
|
||||
|
||||
void install_update(const std::filesystem::path &) {
|
||||
throw std::runtime_error("self-update is not supported on Linux");
|
||||
void install_archive(const std::filesystem::path &archive, const std::filesystem::path &folder, std::string_view exe_sha256) {
|
||||
namespace fs = std::filesystem;
|
||||
// Looked at before it is unpacked: one folder, nothing that reaches outside it.
|
||||
checked_archive_listing(run_tool({"tar", "-tzf", archive.string()}, 1024 * 1024));
|
||||
const auto stage = folder / stage_name;
|
||||
RemovePath remove_stage{stage};
|
||||
fs::remove_all(stage);
|
||||
fs::create_directory(stage);
|
||||
run_tool({"tar", "-xzf", archive.string(), "-C", stage.string(), "--strip-components=1", "--no-same-owner"}, 64 * 1024);
|
||||
// Plain files and folders only: a link could point anywhere.
|
||||
std::vector<fs::path> files, folders;
|
||||
for (auto entry = fs::recursive_directory_iterator(stage); entry != fs::recursive_directory_iterator(); ++entry) {
|
||||
const auto status = entry->symlink_status();
|
||||
if (fs::is_directory(status)) folders.push_back(fs::relative(entry->path(), stage));
|
||||
else if (fs::is_regular_file(status)) files.push_back(fs::relative(entry->path(), stage));
|
||||
else throw std::runtime_error("the update has something that is not a file or a folder");
|
||||
}
|
||||
const auto exe = self().filename();
|
||||
// Otherwise the new copy would find itself out of date and update forever.
|
||||
if (std::find(files.begin(), files.end(), exe) == files.end() || launcher::sha256_file(stage / exe) != exe_sha256)
|
||||
throw std::runtime_error("the release's ReSkateServer doesn't match launcher.json");
|
||||
// Nothing in the folder has changed until here. Each file then takes its place in one step
|
||||
// (a rename within the folder), which Linux allows for a program and libraries in use; the
|
||||
// server itself goes last, so a failure part-way leaves a server that updates again.
|
||||
for (const auto &relative : folders) fs::create_directories(folder / relative);
|
||||
std::stable_partition(files.begin(), files.end(), [&](const fs::path &file) { return file != exe; });
|
||||
for (const auto &relative : files) fs::rename(stage / relative, folder / relative);
|
||||
}
|
||||
|
||||
bool relaunch() { return false; }
|
||||
void install_update(const std::filesystem::path &folder) {
|
||||
LinuxRelease pinned;
|
||||
{
|
||||
std::lock_guard lock(release_mutex);
|
||||
if (!release) throw std::runtime_error("no update has been found");
|
||||
pinned = *release;
|
||||
}
|
||||
const auto archive = folder / archive_name;
|
||||
RemovePath remove_archive{archive};
|
||||
auto download = curl(pinned.url, pinned.size, 600);
|
||||
download.insert(download.end(), {"--output", archive.string()});
|
||||
run_tool(download, 64 * 1024);
|
||||
if (std::filesystem::file_size(archive) != pinned.size || launcher::sha256_file(archive) != pinned.sha256)
|
||||
throw std::runtime_error("the downloaded update doesn't match launcher.json");
|
||||
install_archive(archive, folder, pinned.exe_sha256);
|
||||
}
|
||||
|
||||
void remove_previous_update(const std::filesystem::path &) noexcept {}
|
||||
// The updated server takes this one's place: the same process, so whatever started it (a
|
||||
// terminal, systemd) keeps the server it started.
|
||||
bool relaunch() {
|
||||
std::vector<std::string> arguments;
|
||||
{
|
||||
std::ifstream in("/proc/self/cmdline", std::ios::binary);
|
||||
for (std::string argument; std::getline(in, argument, '\0');) arguments.push_back(argument);
|
||||
}
|
||||
if (arguments.empty()) arguments.push_back(self().string());
|
||||
std::vector<char *> argv;
|
||||
for (auto &argument : arguments) argv.push_back(argument.data());
|
||||
argv.push_back(nullptr);
|
||||
// Nothing this server had open (Steam's sockets, the log) carries over.
|
||||
std::error_code ignored;
|
||||
std::vector<int> open_files;
|
||||
for (const auto &entry : std::filesystem::directory_iterator("/proc/self/fd", ignored)) {
|
||||
const auto fd = std::atoi(entry.path().filename().c_str());
|
||||
if (fd > STDERR_FILENO) open_files.push_back(fd);
|
||||
}
|
||||
for (const int fd : open_files)
|
||||
if (const int flags = fcntl(fd, F_GETFD); flags >= 0) fcntl(fd, F_SETFD, flags | FD_CLOEXEC);
|
||||
execv(self().c_str(), argv.data());
|
||||
return false;
|
||||
}
|
||||
|
||||
void remove_previous_update(const std::filesystem::path &folder) noexcept {
|
||||
std::error_code ignored;
|
||||
std::filesystem::remove_all(folder / stage_name, ignored);
|
||||
std::filesystem::remove(folder / archive_name, ignored);
|
||||
}
|
||||
|
||||
#endif // _WIN32
|
||||
} // namespace dingosdk::server
|
||||
+10
-1
@@ -1,12 +1,15 @@
|
||||
#pragma once
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
// Self-update for release builds of the dedicated server. The latest GitHub
|
||||
// release's launcher.json pins the server zip (size and SHA-256) and the
|
||||
// SHA-256 of the ReSkateServer.exe inside it; a server whose own exe differs
|
||||
// installs that zip over its folder and starts again. Its own files
|
||||
// (ReSkateServer.json, Mods, logs) are not in the zip, so they are kept.
|
||||
// The Linux server does the same from the release's tarball ("server_linux" in
|
||||
// launcher.json, server_release.h), using the machine's curl and tar.
|
||||
namespace dingosdk::server {
|
||||
// Release builds only: a local build never replaces itself.
|
||||
bool updates_enabled() noexcept;
|
||||
@@ -21,8 +24,14 @@ UpdateCheck check_for_update();
|
||||
// Downloads the pinned zip into `folder` and unpacks it there. Throws on any
|
||||
// failure, before anything in the folder has changed.
|
||||
void install_update(const std::filesystem::path& folder);
|
||||
// Starts a new copy of this exe with the same command line.
|
||||
// Starts a new copy of this exe with the same command line. On Linux the new copy takes
|
||||
// this process's place, so this only returns (false) when that failed.
|
||||
bool relaunch();
|
||||
// Deletes the files a previous update renamed aside.
|
||||
void remove_previous_update(const std::filesystem::path& folder) noexcept;
|
||||
#ifndef _WIN32
|
||||
// The second half of install_update, for a tarball already downloaded and verified: checks
|
||||
// what is in it, that its ReSkateServer has this SHA-256, and puts its files in `folder`.
|
||||
void install_archive(const std::filesystem::path& archive, const std::filesystem::path& folder, std::string_view exe_sha256);
|
||||
#endif
|
||||
} // namespace dingosdk::server
|
||||
@@ -133,7 +133,7 @@ std::string server_tags(const Advertisement &a) {
|
||||
}
|
||||
SteamServer::~SteamServer() { stop(); }
|
||||
bool SteamServer::start(const std::filesystem::path &folder, std::uint16_t port, std::uint16_t query_port,
|
||||
std::string &error) {
|
||||
const std::string &token, std::string &error) {
|
||||
try {
|
||||
std::optional<QuietSteam> quiet{std::in_place};
|
||||
#ifdef _WIN32
|
||||
@@ -199,7 +199,8 @@ bool SteamServer::start(const std::filesystem::path &folder, std::uint16_t port,
|
||||
text("SteamAPI_ISteamGameServer_SetProduct", "reskate");
|
||||
text("SteamAPI_ISteamGameServer_SetGameDescription", "ReSkate dedicated server");
|
||||
symbol<void (*)(void *, bool)>(module_, "SteamAPI_ISteamGameServer_SetDedicatedServer")(server_, true);
|
||||
symbol<void (*)(void *)>(module_, "SteamAPI_ISteamGameServer_LogOnAnonymous")(server_);
|
||||
if (token.empty()) symbol<void (*)(void *)>(module_, "SteamAPI_ISteamGameServer_LogOnAnonymous")(server_);
|
||||
else text("SteamAPI_ISteamGameServer_LogOn", token.c_str());
|
||||
return true;
|
||||
} catch (const std::exception &e) {
|
||||
error = e.what();
|
||||
|
||||
@@ -15,13 +15,16 @@ struct Advertisement {
|
||||
};
|
||||
std::string server_tags(const Advertisement &);
|
||||
|
||||
// A Steam game server for Skate's app, signed in anonymously. Its SteamID is
|
||||
// new each start, so invite codes last only while the server runs.
|
||||
// A Steam game server for Skate's app. Signed in anonymously its SteamID is new each
|
||||
// start, so invite codes last only while the server runs; signed in with a game server
|
||||
// login token it is the token's account, with the same SteamID every start.
|
||||
class SteamServer {
|
||||
public:
|
||||
~SteamServer();
|
||||
// Loads steam_api64.dll from `folder`, next to the Steam client files it needs.
|
||||
bool start(const std::filesystem::path &folder, std::uint16_t port, std::uint16_t query_port, std::string &error);
|
||||
// `token`: a game server login token, or empty to sign in anonymously.
|
||||
bool start(const std::filesystem::path &folder, std::uint16_t port, std::uint16_t query_port, const std::string &token,
|
||||
std::string &error);
|
||||
void run_callbacks();
|
||||
bool logged_on();
|
||||
std::uint64_t steam_id();
|
||||
|
||||
+2
-2
@@ -160,10 +160,10 @@ endif()
|
||||
|
||||
# ReSkate dedicated server: a headless session host. It runs from its own folder
|
||||
# next to steam_api64.dll and the Steam client files; no game install needed.
|
||||
# On Linux next to libsteam_api.so; self-update is disabled there (see server_update.cpp).
|
||||
# On Linux next to libsteam_api.so; it updates itself there too, with the machine's curl and tar.
|
||||
add_executable(dingosdk_server Server/main.cpp Server/server_host.cpp Server/server_party.cpp
|
||||
Extension/Multiplayer/Session/party_book.cpp Server/server_config.cpp Server/steam_server.cpp
|
||||
Server/server_update.cpp $<$<BOOL:${WIN32}>:Launcher/updater.cpp>
|
||||
Server/server_update.cpp Server/server_release.cpp $<$<BOOL:${WIN32}>:Launcher/updater.cpp>
|
||||
Server/global_bans.cpp Extension/Multiplayer/developer_identity.cpp
|
||||
Extension/Multiplayer/Steam/steam_transport.cpp Extension/Multiplayer/Net/protocol.cpp
|
||||
Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Net/wire_codec.cpp
|
||||
|
||||
@@ -10,8 +10,8 @@ WorkingDirectory=/opt/reskate-server
|
||||
ExecStart=/opt/reskate-server/ReSkateServer
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
# Server uses restart_for_update internally; V1 Linux has self-update off,
|
||||
# so any non-zero exit still restarts here.
|
||||
# The server updates itself in place (the new version takes over this same process), so
|
||||
# its user must be able to write to WorkingDirectory. Any exit still restarts it here.
|
||||
# The server already appends every line to ReSkateServer.log next to itself;
|
||||
# appending stdout there too wrote each line twice. The journal keeps the console copy.
|
||||
StandardOutput=journal
|
||||
|
||||
Reference in new issue
Block a user