diff --git a/CMakeLists.txt b/CMakeLists.txt index 0cb82be..0870970 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -89,6 +89,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}") @@ -143,8 +147,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) diff --git a/Engine/Game/Multiplayer/object_placement.h b/Engine/Game/Multiplayer/object_placement.h index e53010a..443b9b1 100644 --- a/Engine/Game/Multiplayer/object_placement.h +++ b/Engine/Game/Multiplayer/object_placement.h @@ -1,4 +1,5 @@ #pragma once +#include #include #include #include @@ -27,4 +28,21 @@ inline std::optional 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 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 diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index 4b3a838..9ef549f 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -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 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 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 from{}, to{}; }; std::vector 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. diff --git a/Extension/Customization/developer_hoodie_material.h b/Extension/Customization/developer_hoodie_material.h index 6281af3..d4a2bbf 100644 --- a/Extension/Customization/developer_hoodie_material.h +++ b/Extension/Customization/developer_hoodie_material.h @@ -122,24 +122,28 @@ inline std::pair, std::array> 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 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 mar constexpr std::array red{{{.2f, .004f, .01f}, {.8f, .03f, .03f}, {.8f, .05f, .13f}}}; constexpr std::array gold{{{.34f, .12f, .006f}, {.8f, .5f, .06f}, {.8f, .64f, .11f}}}; constexpr std::array 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 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; } diff --git a/Extension/Multiplayer/Hud/custom_nametags.h b/Extension/Multiplayer/Hud/custom_nametags.h index 9850af3..face9f8 100644 --- a/Extension/Multiplayer/Hud/custom_nametags.h +++ b/Extension/Multiplayer/Hud/custom_nametags.h @@ -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. diff --git a/Extension/Multiplayer/Net/protocol.cpp b/Extension/Multiplayer/Net/protocol.cpp index 4a55395..26c7b57 100644 --- a/Extension/Multiplayer/Net/protocol.cpp +++ b/Extension/Multiplayer/Net/protocol.cpp @@ -558,6 +558,8 @@ std::vector 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(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 decode(std::span bytes) noexcept { const auto placement = r.integer(1); if (!valid_object_placement(placement)) return {}; p.object_placement = static_cast(placement); + const auto object_limit = r.integer(2); + if (!valid_object_limit(object_limit)) return {}; + p.object_limit = static_cast(object_limit); const auto forced = r.integer(1); if (forced > 1) return {}; p.force_world_layers = forced != 0; diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index 521f348..25c3540 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -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{}; diff --git a/Extension/Multiplayer/Remote/cosmetics.h b/Extension/Multiplayer/Remote/cosmetics.h index 7b15291..c64a632 100644 --- a/Extension/Multiplayer/Remote/cosmetics.h +++ b/Extension/Multiplayer/Remote/cosmetics.h @@ -43,6 +43,12 @@ inline constexpr std::array 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; +// 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 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; } diff --git a/Extension/Multiplayer/Session/object_state.h b/Extension/Multiplayer/Session/object_state.h index 20554da..822197b 100644 --- a/Extension/Multiplayer/Session/object_state.h +++ b/Extension/Multiplayer/Session/object_state.h @@ -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 limited_layout(std::vector layout, const std::map &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. diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 606d065..697b210 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -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; diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index ba69160..5177954 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -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{static_cast(value >> 16), static_cast(value >> 8), static_cast(value)}; }; auto styles = developer_hoodie_detail::own_styles.load(); - styles[item] = {static_cast(mode), colour(from), colour(to), static_cast(speed)}; + styles[item] = {static_cast(mode == 4 ? 3 : mode), colour(from), colour(to), static_cast(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 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; diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 00ea1b7..3229f37 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -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 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 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); diff --git a/Extension/Multiplayer/Session/session_receive.cpp b/Extension/Multiplayer/Session/session_receive.cpp index b2b28d5..b1cda91 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -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; diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index 5f8be1d..4e88dc4 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -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; diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index f6579ab..7644e76 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -50,6 +50,8 @@ void load_host_preferences(Session &s) { p.voice_range = value->get(); if (const auto value = number("Host.ObjectPlacement"); value && *value >= 0 && valid_object_placement(static_cast(*value))) p.placement = static_cast(*value); + if (const auto value = number("Host.ObjectLimit"); value && *value >= 0 && valid_object_limit(static_cast(*value))) + p.object_limit = static_cast(*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(p.distances.half_rate_return)}, {"Host.Distance.LowStart", static_cast(p.distances.low_rate_start)}, {"Host.ObjectPlacement", static_cast(p.placement)}, + {"Host.ObjectLimit", static_cast(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(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{}; + // 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{}); 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(styles[i].mode); + shown.mode = rainbow ? 4 : static_cast(styles[i].mode); shown.speed = styles[i].speed; for (std::size_t part = 0; part < 3; ++part) shown.from[part] = static_cast(from[part]) / 255.f, shown.to[part] = static_cast(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 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"}; } } diff --git a/Extension/Multiplayer/Steam/steam_friend_join.cpp b/Extension/Multiplayer/Steam/steam_friend_join.cpp index f133aae..5cafe7a 100644 --- a/Extension/Multiplayer/Steam/steam_friend_join.cpp +++ b/Extension/Multiplayer/Steam/steam_friend_join.cpp @@ -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) { diff --git a/Extension/Multiplayer/Steam/steam_friend_join.h b/Extension/Multiplayer/Steam/steam_friend_join.h index 71367b9..02a112f 100644 --- a/Extension/Multiplayer/Steam/steam_friend_join.h +++ b/Extension/Multiplayer/Steam/steam_friend_join.h @@ -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 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; diff --git a/Extension/Multiplayer/Steam/steam_server_browser.cpp b/Extension/Multiplayer/Steam/steam_server_browser.cpp index 03017f0..4b0f07a 100644 --- a/Extension/Multiplayer/Steam/steam_server_browser.cpp +++ b/Extension/Multiplayer/Steam/steam_server_browser.cpp @@ -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 @@ -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 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(search.response)->done || - (now - started_ > 1000000 && !api().refreshing(servers_, search.request)); + if (static_cast(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); diff --git a/Extension/Multiplayer/Steam/steam_server_browser.h b/Extension/Multiplayer/Steam/steam_server_browser.h index 25a3773..ab5c7b4 100644 --- a/Extension/Multiplayer/Steam/steam_server_browser.h +++ b/Extension/Multiplayer/Steam/steam_server_browser.h @@ -15,6 +15,12 @@ std::optional 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> 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 searches_; // the internet and LAN lists std::map 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 rows_; void release(); - void read(); + void read(std::uint64_t now); }; } // namespace dingosdk::multiplayer diff --git a/Extension/Multiplayer/Steam/steam_social.cpp b/Extension/Multiplayer/Steam/steam_social.cpp index 87c1d4f..42a6e54 100644 --- a/Extension/Multiplayer/Steam/steam_social.cpp +++ b/Extension/Multiplayer/Steam/steam_social.cpp @@ -1,4 +1,5 @@ #include "steam_social.h" +#include "steam_friend_join.h" #include "Engine/Core/Platform/launcher_support.h" #include #include @@ -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(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 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: diff --git a/Extension/Multiplayer/Steam/steam_social.h b/Extension/Multiplayer/Steam/steam_social.h index c2156ee..3e670b1 100644 --- a/Extension/Multiplayer/Steam/steam_social.h +++ b/Extension/Multiplayer/Steam/steam_social.h @@ -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 steam_social_snapshot(); } diff --git a/Extension/Multiplayer/Test/developer_identity_tests.cpp b/Extension/Multiplayer/Test/developer_identity_tests.cpp index 6ef4fd3..f8f2b27 100644 --- a/Extension/Multiplayer/Test/developer_identity_tests.cpp +++ b/Extension/Multiplayer/Test/developer_identity_tests.cpp @@ -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(L::official_server)] == std::vector{85568392924040001ULL, 85568392924040002ULL} && + bans[static_cast(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(L::blocked_server)] == std::vector{85568392924040001ULL, 90071992551410001ULL} && + blocks[static_cast(L::official_server)].empty() && servers[static_cast(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(L::staff)] == std::vector{stranger} && staff[static_cast(L::centrix)] == std::vector{stranger} && + staff[static_cast(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"); diff --git a/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index 1f6062e..b43a564 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -35,6 +35,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; } } @@ -417,6 +420,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"); diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index 10fd6c0..480c3b8 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -739,6 +739,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}; @@ -762,6 +763,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 &layout) { + std::vector out; + for (const auto &entry : layout) out.push_back(entry.id); + return out; + }; + const std::vector uploaded{object(1), object(2), object(3), object(4), object(5)}; + std::map shown{{4, object(4)}, {5, object(5)}}; + check(ids(limited_layout(uploaded, {}, 0)) == std::vector{1, 2, 3, 4, 5} && + ids(limited_layout(uploaded, {}, 9)) == std::vector{1, 2, 3, 4, 5}, + "A layout within the limit was cut"); + check(ids(limited_layout(uploaded, {}, 3)) == std::vector{1, 2, 3}, "A layout over the limit was not cut to it"); + check(ids(limited_layout(uploaded, shown, 3)) == std::vector{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; diff --git a/Extension/Multiplayer/Test/steam_friend_join_tests.cpp b/Extension/Multiplayer/Test/steam_friend_join_tests.cpp index e0ad29a..33c3aa4 100644 --- a/Extension/Multiplayer/Test/steam_friend_join_tests.cpp +++ b/Extension/Multiplayer/Test/steam_friend_join_tests.cpp @@ -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; } } diff --git a/Extension/Multiplayer/developer_identity.cpp b/Extension/Multiplayer/developer_identity.cpp index 8b2d9fb..d4373cb 100644 --- a/Extension/Multiplayer/developer_identity.cpp +++ b/Extension/Multiplayer/developer_identity.cpp @@ -1,5 +1,6 @@ #include "developer_identity.h" #include "Engine/Core/Json/json.h" +#include "Extension/Multiplayer/Net/protocol.h" #include #include #include @@ -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 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, 5> categories{{ + {"dev", IdentityList::developer}, {"staff", IdentityList::staff}, {"homie", IdentityList::homie}, + {"content_creator", IdentityList::content_creator}, {"centrix", IdentityList::centrix}}}; constexpr auto banned = static_cast(IdentityList::banned); +constexpr auto servers = static_cast(IdentityList::official_server); +constexpr auto blocked = static_cast(IdentityList::blocked_server); std::atomic> 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 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 &ids) { + const auto read = [](const Json &entries, std::vector &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(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 publish_identity_lists(IdentityLists lists) { const auto previous = current.load(); if (previous && *previous == lists) return false; diff --git a/Extension/Multiplayer/developer_identity.h b/Extension/Multiplayer/developer_identity.h index 03290ba..851f530 100644 --- a/Extension/Multiplayer/developer_identity.h +++ b/Extension/Multiplayer/developer_identity.h @@ -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, static_cast(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 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 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); diff --git a/Extension/Multiplayer/developer_identity_fetch.cpp b/Extension/Multiplayer/developer_identity_fetch.cpp index 0976564..848476b 100644 --- a/Extension/Multiplayer/developer_identity_fetch.cpp +++ b/Extension/Multiplayer/developer_identity_fetch.cpp @@ -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(IdentityList::banned)].size(); if (publish_identity_lists(std::move(lists)) || failing) diff --git a/Extension/Objects/ParkEditor/park_editor_lobby.cpp b/Extension/Objects/ParkEditor/park_editor_lobby.cpp index f008733..13d6e1d 100644 --- a/Extension/Objects/ParkEditor/park_editor_lobby.cpp +++ b/Extension/Objects/ParkEditor/park_editor_lobby.cpp @@ -5,10 +5,26 @@ #include namespace dingosdk { -namespace { std::atomic placement_allowed{true}; } +namespace { +std::atomic placement_allowed{true}; +std::atomic 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(); diff --git a/Extension/Objects/ParkEditor/park_editor_operations.cpp b/Extension/Objects/ParkEditor/park_editor_operations.cpp index 6169147..78fec0b 100644 --- a/Extension/Objects/ParkEditor/park_editor_operations.cpp +++ b/Extension/Objects/ParkEditor/park_editor_operations.cpp @@ -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; })) diff --git a/Extension/Objects/ParkEditor/park_editor_preview.cpp b/Extension/Objects/ParkEditor/park_editor_preview.cpp index e1bdfbc..706c7a9 100644 --- a/Extension/Objects/ParkEditor/park_editor_preview.cpp +++ b/Extension/Objects/ParkEditor/park_editor_preview.cpp @@ -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; })) diff --git a/Extension/Objects/ParkEditor/park_editor_runtime.h b/Extension/Objects/ParkEditor/park_editor_runtime.h index a10faea..4872918 100644 --- a/Extension/Objects/ParkEditor/park_editor_runtime.h +++ b/Extension/Objects/ParkEditor/park_editor_runtime.h @@ -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 &); diff --git a/Extension/Objects/local_placements_runtime.cpp b/Extension/Objects/local_placements_runtime.cpp index 2f8f14a..9d727bc 100644 --- a/Extension/Objects/local_placements_runtime.cpp +++ b/Extension/Objects/local_placements_runtime.cpp @@ -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(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; diff --git a/Extension/UI/NativeMenu/Test/native_menu_view_tests.cpp b/Extension/UI/NativeMenu/Test/native_menu_view_tests.cpp index b1b2870..9eb463b 100644 --- a/Extension/UI/NativeMenu/Test/native_menu_view_tests.cpp +++ b/Extension/UI/NativeMenu/Test/native_menu_view_tests.cpp @@ -57,6 +57,7 @@ int main() { check(missing_tabs({1, 2, 3, 4}, 8, false) == std::vector{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(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"; diff --git a/Extension/UI/NativeMenu/native_menu_multiplayer.cpp b/Extension/UI/NativeMenu/native_menu_multiplayer.cpp index 09addd7..25db9a2 100644 --- a/Extension/UI/NativeMenu/native_menu_multiplayer.cpp +++ b/Extension/UI/NativeMenu/native_menu_multiplayer.cpp @@ -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 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(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"), diff --git a/Extension/UI/NativeMenu/native_menu_view.h b/Extension/UI/NativeMenu/native_menu_view.h index 78e2043..0707663 100644 --- a/Extension/UI/NativeMenu/native_menu_view.h +++ b/Extension/UI/NativeMenu/native_menu_view.h @@ -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)); diff --git a/Extension/UI/Overlay/multiplayer_lobbies.cpp b/Extension/UI/Overlay/multiplayer_lobbies.cpp index b0794de..ea5a4f1 100644 --- a/Extension/UI/Overlay/multiplayer_lobbies.cpp +++ b/Extension/UI/Overlay/multiplayer_lobbies.cpp @@ -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 &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(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(); diff --git a/Extension/UI/Overlay/multiplayer_menu.cpp b/Extension/UI/Overlay/multiplayer_menu.cpp index e98a8e2..a6c19c4 100644 --- a/Extension/UI/Overlay/multiplayer_menu.cpp +++ b/Extension/UI/Overlay/multiplayer_menu.cpp @@ -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 modes{0, 2, 3, 1}; - int option = static_cast(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(option)], style.speed); + if (mp.identity_rainbow) { + static constexpr std::array modes{0, 4, 2, 3, 1}; + int option = static_cast(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(option)], style.speed); + } else { + static constexpr std::array modes{0, 2, 3, 1}; + int option = static_cast(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(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); diff --git a/Extension/UI/Overlay/multiplayer_session.cpp b/Extension/UI/Overlay/multiplayer_session.cpp index 14e06b6..62c18bc 100644 --- a/Extension/UI/Overlay/multiplayer_session.cpp +++ b/Extension/UI/Overlay/multiplayer_session.cpp @@ -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 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 labels{"No limit", "10", "25", "50", "100", "250", "500", custom.c_str()}; + int picked = static_cast(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(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."); diff --git a/Extension/UI/Overlay/nametag_gradient.h b/Extension/UI/Overlay/nametag_gradient.h index 386f124..d3864d9 100644 --- a/Extension/UI/Overlay/nametag_gradient.h +++ b/Extension/UI/Overlay/nametag_gradient.h @@ -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) diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index f648452..db36708 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -312,7 +312,7 @@ struct Nametag { std::array 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). diff --git a/Extension/UI/Overlay/role_badge.h b/Extension/UI/Overlay/role_badge.h index f92c34b..cabc785 100644 --- a/Extension/UI/Overlay/role_badge.h +++ b/Extension/UI/Overlay/role_badge.h @@ -4,7 +4,7 @@ #include #include -// 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) { diff --git a/Extension/UI/Overlay/skate_menu.h b/Extension/UI/Overlay/skate_menu.h index 111c639..9fd1223 100644 --- a/Extension/UI/Overlay/skate_menu.h +++ b/Extension/UI/Overlay/skate_menu.h @@ -31,7 +31,7 @@ struct SkateMenu { std::optional 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 ban_id{}; diff --git a/Extension/UI/skate_theme.h b/Extension/UI/skate_theme.h index 0cc7921..94b2761 100644 --- a/Extension/UI/skate_theme.h +++ b/Extension/UI/skate_theme.h @@ -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); diff --git a/Launcher/launch.cpp b/Launcher/launch.cpp index 9fdffbc..ba33ec8 100644 --- a/Launcher/launch.cpp +++ b/Launcher/launch.cpp @@ -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"); diff --git a/Server/README-linux.md b/Server/README-linux.md index 6bebb79..7664595 100644 --- a/Server/README-linux.md +++ b/Server/README-linux.md @@ -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, diff --git a/Server/README.txt b/Server/README.txt index 8b26a7a..636c5e9 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -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 distances placement everyone|admins|nobody clear-objects + objects |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. diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index 24633db..96072be 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -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("x") && diff --git a/Server/Test/server_release_tests.cpp b/Server/Test/server_release_tests.cpp new file mode 100644 index 0000000..1a07c40 --- /dev/null +++ b/Server/Test/server_release_tests.cpp @@ -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 +#include + +namespace { +int failures{}; +void check(bool ok, const char *what) { + if (!ok) { + std::cerr << "FAIL: " << what << '\n'; + ++failures; + } +} +template 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; +} diff --git a/Server/global_bans.cpp b/Server/global_bans.cpp index 15cd491..5ab5a09 100644 --- a/Server/global_bans.cpp +++ b/Server/global_bans.cpp @@ -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(multiplayer::IdentityList::banned)]; check.banned = bans.size(); check.changed = last != bans; diff --git a/Server/main.cpp b/Server/main.cpp index e412514..c076836 100644 --- a/Server/main.cpp +++ b/Server/main.cpp @@ -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 \" 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 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 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); diff --git a/Server/server_config.cpp b/Server/server_config.cpp index 574bbd7..518f3cb 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -4,6 +4,7 @@ #include "Extension/Multiplayer/Net/protocol.h" #include "Engine/Game/World/world_names.h" #include +#include #include #include #include @@ -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 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])) diff --git a/Server/server_config.h b/Server/server_config.h index bfcb0e2..61d5420 100644 --- a/Server/server_config.h +++ b/Server/server_config.h @@ -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. diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 99a0ee4..fdb246d 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -46,7 +46,7 @@ constexpr std::string_view help_text = "status | players | say | msg | msg-party | msg-admins | kick | ban [name] | unban | bans\n" "map | maps | name | password | welcome | listed on|off\n" "tps 20|30|60|120 | voice on|off | voice-range <50-1000> | distances \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 |off | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n" "tpall [player] | tphere | votes [map|kick|tod on|off|] | vote-cancel\n" "map-pool [add|remove |clear] | rotation [|off]\n" "park | park random | layer-sync on|off | layer default|on|off | tod \n" @@ -262,6 +262,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; @@ -699,6 +700,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(); @@ -841,6 +844,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; @@ -1191,6 +1195,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 on|off| | votes seconds|cooldown const auto [what_text, value_text] = split(argument); diff --git a/Server/server_release.cpp b/Server/server_release.cpp new file mode 100644 index 0000000..38650dd --- /dev/null +++ b/Server/server_release.cpp @@ -0,0 +1,97 @@ +#include "server_release.h" +#include "Engine/Core/Json/json.h" +#include +#include +#include + +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(c)) != std::string_view::npos; + }); +} + +std::map> 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> 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 parse_linux_release(std::string_view launcher_json, + const std::map> &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(); + 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 diff --git a/Server/server_release.h b/Server/server_release.h new file mode 100644 index 0000000..0295e8d --- /dev/null +++ b/Server/server_release.h @@ -0,0 +1,33 @@ +#pragma once +#include +#include +#include +#include +#include + +// 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> release_assets(std::string_view release_json); +// Nothing when the release was made before Linux servers updated themselves (no +// "server_linux"). An "asset:" 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 parse_linux_release(std::string_view launcher_json, + const std::map> &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 diff --git a/Server/server_update.cpp b/Server/server_update.cpp index 0f4e7a9..f8262c9 100644 --- a/Server/server_update.cpp +++ b/Server/server_update.cpp @@ -4,7 +4,17 @@ #include "Launcher/updater.h" #include #else +#include "server_release.h" +#include "launcher_update_config.h" +#include +#include +#include #include +#include +#include +#include +#include +#include #endif #include #include @@ -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 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 &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 argv; + for (const auto &argument : arguments) argv.push_back(const_cast(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(room, static_cast(count))); + if (fd.fd == out[0] && static_cast(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 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 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(input), std::istreambuf_iterator()}; + 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(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 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 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 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 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 diff --git a/Server/server_update.h b/Server/server_update.h index 1c9048b..0009eba 100644 --- a/Server/server_update.h +++ b/Server/server_update.h @@ -1,12 +1,15 @@ #pragma once #include #include +#include // 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 diff --git a/Server/steam_server.cpp b/Server/steam_server.cpp index 6692169..532b231 100644 --- a/Server/steam_server.cpp +++ b/Server/steam_server.cpp @@ -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 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(module_, "SteamAPI_ISteamGameServer_SetDedicatedServer")(server_, true); - symbol(module_, "SteamAPI_ISteamGameServer_LogOnAnonymous")(server_); + if (token.empty()) symbol(module_, "SteamAPI_ISteamGameServer_LogOnAnonymous")(server_); + else text("SteamAPI_ISteamGameServer_LogOn", token.c_str()); return true; } catch (const std::exception &e) { error = e.what(); diff --git a/Server/steam_server.h b/Server/steam_server.h index d586414..db20889 100644 --- a/Server/steam_server.h +++ b/Server/steam_server.h @@ -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(); diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 29c8d39..f630f2c 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -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 $<$:Launcher/updater.cpp> + Server/server_update.cpp Server/server_release.cpp $<$: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 diff --git a/contrib/reskate-server.service b/contrib/reskate-server.service index 795dc52..ed92727 100644 --- a/contrib/reskate-server.service +++ b/contrib/reskate-server.service @@ -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