From d2b8e180973b1c997a4329afda3e0cef8300fdb2 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Sun, 11 Oct 2026 21:19:49 -0500 Subject: [PATCH] 2.0.7 hotfix: map updates, vote bar, ReSkate Admin tag, servers on the newest map version Map updates - A map the game already has, being replaced by the host's version, could load as soon as the new files were in Mods, while the mods were still being applied: it ended on "This map is not loading". The session now holds the load while a map is downloading, installing or applying, as it waits for one that is missing. - A player whose version of a map is later than the host's keeps it and loads. Only an older one is replaced by the host's. Versions are compared number by number (1.10.0 is later than 1.9.2). Dedicated servers - maps.latest_versions (off by default): the server asks Thunderstore for the newest version of each of its custom maps, at start and every maps.latest_check_minutes (5-1440, default 60), and writes it into the mod's manifest.json. Nothing is downloaded: the server reads only a map's level list and version, and players, who are held to the server's version, fetch it as they join. The map being played waits until nobody is on it. A folder that is not a Thunderstore package is not touched, and the level list is left as it is. - Console: maps update, maps latest on|off, maps latest every . Overlay - Vote card: the pass mark is always in the middle of the bar. Green grows over the left half up to the yes votes needed and over the right half past them, whatever share the vote needs. - The "Staff" tag is "ReSkate Admin", shimmering from #2500FF to #B2F9FF. No protocol change. Tests cover the version comparison and the manifest write; the Thunderstore lookup was checked against the site. The map update flow, the vote bar and the tag were not seen in game, and the server setting has not run on a live server. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 8 + Engine/Game/Multiplayer/session_model.h | 4 +- Extension/Assets/map_download.cpp | 3 + Extension/Multiplayer/Hud/custom_nametags.h | 4 +- Extension/Multiplayer/Net/protocol.h | 29 ++- Extension/Multiplayer/Session/session.cpp | 5 +- Extension/Multiplayer/Session/session.h | 5 + .../Multiplayer/Session/session_internal.h | 3 +- .../Multiplayer/Session/session_view.cpp | 2 +- Extension/Multiplayer/Test/protocol_tests.cpp | 7 + Extension/UI/Overlay/chat_overlay.cpp | 17 +- Extension/UI/Overlay/overlay.h | 2 +- Extension/UI/Overlay/role_badge.h | 2 +- Server/GUI/server_gui.cpp | 2 +- Server/README.txt | 21 ++ Server/Test/server_map_update_tests.cpp | 99 ++++++++++ Server/main.cpp | 8 + Server/server_commands.cpp | 38 ++-- Server/server_config.cpp | 4 + Server/server_config.h | 4 + Server/server_engine.cpp | 8 + Server/server_host.h | 7 + Server/server_map_update.cpp | 181 ++++++++++++++++++ Server/server_map_update.h | 51 +++++ Server/server_votes.cpp | 24 +++ cmake/Apps.cmake | 3 +- 26 files changed, 508 insertions(+), 33 deletions(-) create mode 100644 Server/Test/server_map_update_tests.cpp create mode 100644 Server/server_map_update.cpp create mode 100644 Server/server_map_update.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 4d820c35..457082ad 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -126,6 +126,14 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS) target_include_directories(dingosdk_server_words_tests PRIVATE "${PROJECT_SOURCE_DIR}") target_link_libraries(dingosdk_server_words_tests PRIVATE dingosdk_word_filter) add_test(NAME server_words COMMAND dingosdk_server_words_tests) + add_executable(dingosdk_server_map_update_tests Server/Test/server_map_update_tests.cpp Server/server_map_update.cpp + Engine/Vfs/thunderstore_package.cpp) + target_include_directories(dingosdk_server_map_update_tests PRIVATE "${PROJECT_SOURCE_DIR}") + target_link_libraries(dingosdk_server_map_update_tests PRIVATE dingosdk_json) + if(WIN32) + target_link_libraries(dingosdk_server_map_update_tests PRIVATE dingosdk_https) + endif() + add_test(NAME server_map_update COMMAND dingosdk_server_map_update_tests) add_executable(dingosdk_word_filter_tests Engine/Core/Text/Test/word_filter_tests.cpp) target_link_libraries(dingosdk_word_filter_tests PRIVATE dingosdk_word_filter) add_test(NAME word_filter COMMAND dingosdk_word_filter_tests) diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index 43d1c462..27e45be5 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -102,7 +102,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", "Staff", "Content Creator", "Centrix", "Homie", "Admin", "Host", "Friend", "Server" or empty). + // and a tag shown in a box before the name ("Dev", "ReSkate Admin", "Content Creator", "Centrix", "Homie", "Admin", "Host", "Friend", "Server" 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 @@ -206,7 +206,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", "Staff", "Content Creator", "Centrix" or "Homie"; empty + // Local: the tag the ReSkate backend gives this player ("Dev", "ReSkate Admin", "Content 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. // The local player is on the ReSkate team's developer, staff or homie list: the player list diff --git a/Extension/Assets/map_download.cpp b/Extension/Assets/map_download.cpp index 2e2636e6..ade729e5 100644 --- a/Extension/Assets/map_download.cpp +++ b/Extension/Assets/map_download.cpp @@ -346,6 +346,9 @@ View view() { void tick() { const auto now = stage.load(); + // The session leaves the map alone until it has been applied (an update's level is one the + // game already has, and would load from files that are being replaced). + multiplayer::hold_map_load(now == Stage::downloading || now == Stage::installing || now == Stage::applying); if (now == Stage::applying) { if (apply_wanted.load()) { // (A merge of the player's own may be running: wait for it and start this one.) diff --git a/Extension/Multiplayer/Hud/custom_nametags.h b/Extension/Multiplayer/Hud/custom_nametags.h index baa24da0..5f3710eb 100644 --- a/Extension/Multiplayer/Hud/custom_nametags.h +++ b/Extension/Multiplayer/Hud/custom_nametags.h @@ -36,7 +36,7 @@ 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_staff = 0xfffff9b2U, // pale cyan (#B2F9FF) nametag_admin = 0xffc674ffU, // pink nametag_host = 0xffffc85eU, // blue nametag_friend = 0xff8ae07bU, // green @@ -48,7 +48,7 @@ inline constexpr std::uint32_t nametag_developer_start = 0xffff206eU, // #6E20FF nametag_creator_start = 0xff2e10c8U, // #C8102E nametag_homie_start = 0xff008ae0U, // #E08A00 nametag_centrix_start = 0xffffffffU, // white - nametag_staff_start = 0xff3a6b0bU; // #0B6B3A + nametag_staff_start = 0xffff0025U; // #2500FF // 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.h b/Extension/Multiplayer/Net/protocol.h index ce71a337..c807c9de 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -220,12 +220,33 @@ inline std::string_view map_package_version(std::string_view package) noexcept { const auto second = first == std::string_view::npos ? first : package.find('-', first + 1); return second == std::string_view::npos ? std::string_view{} : package.substr(second + 1); } -// The version of a map installed here, when the host's package is the same map at another -// one: players skate the host's version, so that is fetched first. Nothing when the versions -// agree, either names none, or they are different packages. +// Whether version `a` is a later one than `b`: their numbers compared in turn ("1.10.0" is +// later than "1.9.2"), a missing one counting as 0. False when either is not numbers and dots. +inline bool map_version_newer(std::string_view a, std::string_view b) noexcept { + const auto next = [](std::string_view &text, std::uint64_t &value) { + value = 0; + std::size_t i = 0; + for (; i < text.size() && text[i] >= '0' && text[i] <= '9' && i < 9; ++i) value = value * 10 + static_cast(text[i] - '0'); + if (!i || (i < text.size() && text[i] != '.')) return false; + text.remove_prefix(i < text.size() ? i + 1 : i); + return true; + }; + if (a.empty() || b.empty()) return false; + bool newer{}, decided{}; + while (!a.empty() || !b.empty()) { + std::uint64_t left{}, right{}; + if ((!a.empty() && !next(a, left)) || (!b.empty() && !next(b, right))) return false; + if (!decided && left != right) newer = left > right, decided = true; + } + return newer; +} +// The version of a map installed here, when the host's package is the same map at a later +// one: that is fetched first, so players skate at least the host's version. Nothing when the +// versions agree, the one here is the later (a player ahead of the host is left as they are), +// either names none, or they are different packages. inline std::string_view other_map_version(std::string_view host, std::string_view mine) noexcept { const auto wanted = map_package_version(host), have = map_package_version(mine); - if (wanted.empty() || have.empty() || wanted == have) return {}; + if (wanted.empty() || have.empty() || wanted == have || map_version_newer(have, wanted)) return {}; const auto host_name = host.substr(0, host.size() - wanted.size()), my_name = mine.substr(0, mine.size() - have.size()); if (host_name.size() != my_name.size()) return {}; for (std::size_t i = 0; i < host_name.size(); ++i) diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 5575a058..14b4ab69 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -706,6 +706,7 @@ std::string take_leave_notice() { return std::exchange(session().leave_notice, { std::optional take_map_need() { return std::exchange(session().map_need, std::nullopt); } void set_map_package_lookup(MapPackageLookup lookup) noexcept { map_package_lookup.store(lookup, std::memory_order_relaxed); } bool fetching_map() noexcept { return fetching_map_flag.load(std::memory_order_relaxed); } +void hold_map_load(bool hold) noexcept { map_load_held.store(hold, std::memory_order_relaxed); } MultiplayerChat chat() { auto &s = session(); std::lock_guard lock(s.mutex); @@ -804,7 +805,9 @@ bool prepare_join_map(Session &s, bool ready, std::string_view current, MapLoade // fetched at the host's version as a missing one is (the downloader puts it in the // installed one's place), and loads when the two agree. const auto have = std::string(other_map_version(s.map_package, host_map_package(s.join_destination))); - const auto result = have.empty() ? loader(s.join_destination, s.map_load_submitted, detail) : MapLoadResult::missing; + // (Nor while its files are being put in place and applied: hold_map_load.) + const bool held = map_load_held.load(std::memory_order_relaxed); + const auto result = have.empty() && !held ? loader(s.join_destination, s.map_load_submitted, detail) : MapLoadResult::missing; if (result == MapLoadResult::missing) { const auto name = s.map_label.empty() ? world_level_name(world_destination_asset(s.join_destination)) : s.map_label; const char *who = dedicated_host(s) ? "server" : "host"; diff --git a/Extension/Multiplayer/Session/session.h b/Extension/Multiplayer/Session/session.h index 7baecc28..9d7c3145 100644 --- a/Extension/Multiplayer/Session/session.h +++ b/Extension/Multiplayer/Session/session.h @@ -43,6 +43,11 @@ struct MapNeed { std::optional take_map_need(); // Whether a session is waiting on a map being fetched right now. Thread-safe. bool fetching_map() noexcept; +// Held while a map is being downloaded, installed or applied: the session does not load the +// host's map meanwhile, and waits as it does for one that is missing. A map that is being +// replaced by another version is one the game already knows, so without this it would load as +// soon as the new files are in place, before the mods are applied again. Thread-safe. +void hold_map_load(bool hold) noexcept; // How a host learns the package of the map it is on, to tell its guests: the level's asset in, // the package (or nothing) out. Called on the client thread, often; it should be quick. using MapPackageLookup = std::string (*)(std::string_view asset); diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 6f588b66..18413c8e 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -446,7 +446,7 @@ bool identity_link(Session &s, std::uint64_t other); 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", "Staff", "Content Creator", "Centrix", "Homie", "Admin", "Host", "Friend" or none), shown in chat +// A player's role colour and badge ("Dev", "ReSkate Admin", "Content 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. @@ -462,6 +462,7 @@ std::string answer_server_poll(Session &s, std::size_t answer); inline std::atomic server_vote_open_flag{}; inline std::atomic map_package_lookup{}; inline std::atomic fetching_map_flag{}; +inline std::atomic map_load_held{}; // A guest without the map is downloading it: the clock they are loading against starts over // each time they say so, for as long as map_fetch_limit_us from the first time. inline void guest_fetching(Peer &peer, std::uint64_t now) { diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index 3ae3db95..1df386ba 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -666,7 +666,7 @@ std::pair mark_role(IdentityList list) { case IdentityList::developer: return {nametag_developer, "Dev"}; case IdentityList::content_creator: return {nametag_creator, "Content Creator"}; case IdentityList::centrix: return {nametag_centrix, "Centrix"}; - case IdentityList::staff: return {nametag_staff, "Staff"}; + case IdentityList::staff: return {nametag_staff, "ReSkate Admin"}; default: return {nametag_homie, "Homie"}; } } diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index c260ae5a..d070dd56 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -1172,6 +1172,13 @@ void dedicated_server_codec() { other_map_version("Sandos-Vancouver_Plaza-2.0.0", "Sandos-Vancouver_Plaza").empty() && other_map_version("Sandos-Vancouver_Plaza-2.0.0", "Other-Vancouver_Plaza-1.9.0").empty() && other_map_version("Sandos-Vancouver_Plaza-2.0.0", "").empty() && other_map_version("", "").empty() && + // (A player ahead of the host keeps their version.) + other_map_version("Sandos-Vancouver_Plaza-1.9.0", "Sandos-Vancouver_Plaza-2.0.0").empty() && + other_map_version("Sandos-Vancouver_Plaza-1.9.2", "Sandos-Vancouver_Plaza-1.10.0").empty() && + other_map_version("Sandos-Vancouver_Plaza-1.10.0", "Sandos-Vancouver_Plaza-1.9.2") == "1.9.2" && + map_version_newer("1.10.0", "1.9.2") && map_version_newer("2.0", "1.9.9") && map_version_newer("1.0.1", "1.0") && + !map_version_newer("1.0.0", "1.0.0") && !map_version_newer("1.0", "1.0.0") && !map_version_newer("1.9.2", "1.10.0") && + !map_version_newer("", "1.0.0") && !map_version_newer("1.0.0-beta", "0.9.0") && !map_version_newer("abc", "1.0.0") && map_package_version("Sandos-Vancouver_Plaza-2.0.0") == "2.0.0" && map_package_version("Sandos-Vancouver_Plaza").empty(), "A map's version is not compared with the host's as it should be"); check(map_package_name("Sandos-Vancouver_Plaza", "2.0.0") == "Sandos-Vancouver_Plaza-2.0.0" && diff --git a/Extension/UI/Overlay/chat_overlay.cpp b/Extension/UI/Overlay/chat_overlay.cpp index ba3fcc88..abf8b8d5 100644 --- a/Extension/UI/Overlay/chat_overlay.cpp +++ b/Extension/UI/Overlay/chat_overlay.cpp @@ -607,16 +607,23 @@ void draw_vote(const MultiplayerVote& vote, ImFont* heading, ImFont* body, ImVec draw->AddText(heading, size, ImVec2(left, y), theme::paper, question.c_str(), nullptr, inner); y += question_extent.y + gap; // The votes cast, yes in green from the left and no in red from the right: a vote is - // decided by those who vote. The mark is where yes has to reach to pass; nothing is cast - // yet, the bar is empty. + // decided by those who vote. The mark is in the middle whatever share the vote needs, and + // green reaches it when the vote would pass: up to the yes votes needed it grows over the + // left half, and past them over the right half. Nothing cast yet, the bar is empty. const unsigned cast = static_cast(vote.yes) + vote.no; draw->AddRectFilled(ImVec2(left, y), ImVec2(right, y + bar), skate_theme::tile_light); if (cast) { - const float split = std::floor(left + inner * static_cast(vote.yes) / static_cast(cast)); + float share = static_cast(vote.yes) / static_cast(cast); + if (const unsigned needed = vote.needed) { + const unsigned yes = vote.yes; + share = yes <= needed ? 0.5f * static_cast(yes) / static_cast(needed) + : 0.5f + 0.5f * static_cast(yes - needed) / static_cast(cast - needed); + } + const float split = std::floor(left + inner * std::clamp(share, 0.0f, 1.0f)); if (vote.yes) draw->AddRectFilled(ImVec2(left, y), ImVec2(split, y + bar), green); if (vote.no) draw->AddRectFilled(ImVec2(split, y), ImVec2(right, y + bar), red); - if (running && vote.needed && vote.needed <= cast) { - const float mark = std::floor(left + inner * static_cast(vote.needed) / static_cast(cast)); + if (running && vote.needed) { + const float mark = std::floor(left + inner * 0.5f); draw->AddRectFilled(ImVec2(mark - scale, y - 3.0f * scale), ImVec2(mark + scale, y + bar + 3.0f * scale), theme::paper); } } diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index 5e3e83c8..f113705b 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -391,7 +391,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", "Staff", "Content Creator", "Centrix", "Homie", "Admin", "Host", "Friend") + std::string tag; // role badge before the name ("Dev", "ReSkate Admin", "Content 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 1b9ce0ce..099e9cdd 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", "Staff", "Content Creator", "Centrix", "Homie", "Admin", "Host", "Friend") before a player's name, in chat +// The role badge ("Dev", "ReSkate Admin", "Content 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/Server/GUI/server_gui.cpp b/Server/GUI/server_gui.cpp index 2e708a4b..943b0539 100644 --- a/Server/GUI/server_gui.cpp +++ b/Server/GUI/server_gui.cpp @@ -70,7 +70,7 @@ inline std::pair player_role(std::uint64_t id, bool case multiplayer::IdentityList::developer: return {nametag_developer, "Dev"}; case multiplayer::IdentityList::content_creator: return {nametag_creator, "Content Creator"}; case multiplayer::IdentityList::centrix: return {nametag_centrix, "Centrix"}; - case multiplayer::IdentityList::staff: return {nametag_staff, "Staff"}; + case multiplayer::IdentityList::staff: return {nametag_staff, "ReSkate Admin"}; default: return {nametag_homie, "Homie"}; } } diff --git a/Server/README.txt b/Server/README.txt index 61e8810b..055fcb6e 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -158,6 +158,22 @@ rotation_extend_minutes and "rotation extend [minutes]" adds time at once. rotation_announce Tell players on a card when the map has 10, 5 and 1 minutes left (default true). +latest_versions Keep this server's custom maps at Thunderstore's newest + version (default false). A map mod in Mods whose folder is a + Thunderstore package (Owner-Name) is looked up when the + server starts and every latest_check_minutes; when a newer + version is out, that version is written into the folder's + manifest.json and the maps are read again. Nothing is + downloaded: the server only needs to know the version, and + players, who are sent to the server's version of a map, + fetch it as they join. A player who already has a later + version than the server keeps it. The map being played is + left until nobody is on it. A folder that is not a + Thunderstore package is never touched. + The level list is not changed: when a new version adds, + removes or renames levels, copy its reskate-levels.json in. +latest_check_minutes + How often Thunderstore is asked, 5-1440 (default 60). parks Layout for each park lot, e.g. "skatepark_01", or "empty". world_layer_sync Force the "layers" below on every player. layers World layer key -> "on" / "off". @@ -540,6 +556,11 @@ or Travel, and voice, distances, placement and kicks from the Multiplayer menu. maps The maps this server knows. maps reload Read the Mods folder again: a map mod copied in while the server runs can be switched to at once. + maps update Ask Thunderstore now for the newest version of + each of this server's maps and take it (see + maps.latest_versions); the log says what it did. + maps latest on|off Turn maps.latest_versions on or off. + maps latest every How often Thunderstore is asked. map-pool [add|remove |clear] The maps players vote between and the rotation uses (see maps.pool). rotation [|off] Change the map on a timer (see maps.rotation_minutes). diff --git a/Server/Test/server_map_update_tests.cpp b/Server/Test/server_map_update_tests.cpp new file mode 100644 index 00000000..846153ba --- /dev/null +++ b/Server/Test/server_map_update_tests.cpp @@ -0,0 +1,99 @@ +// Writing a map mod's newest version into its manifest.json (server_map_update.h), without the +// network. +#include "Server/server_map_update.h" +#include "Engine/Core/Json/json.h" +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using namespace dingosdk; +using namespace dingosdk::server; + +namespace { +int failures{}; +void check(bool ok, const std::string &what) { + if (ok) return; + ++failures; + std::cerr << "FAIL: " << what << '\n'; +} +std::string read(const fs::path &file) { + std::ifstream in(file, std::ios::binary); + std::stringstream text; + text << in.rdbuf(); + return text.str(); +} +void write(const fs::path &file, const std::string &text) { + fs::create_directories(file.parent_path()); + std::ofstream(file, std::ios::binary) << text; +} + +// With a Thunderstore package's folder name as its argument: asks Thunderstore about it and +// says what it would do, changing nothing (the map counts as being played). By hand only. +int look_up(const std::string &folder) { + const auto mods = fs::temp_directory_path() / "reskate-server-map-lookup" / "Mods"; + std::error_code ignored; + fs::remove_all(mods.parent_path(), ignored); + write(mods / folder / "manifest.json", "{\"version_number\": \"0.0.0\"}"); + write(mods / folder / "reskate-levels.json", "{\"levels\": []}"); + for (const auto &update : update_maps(mods, folder)) + std::cout << update.folder << ": installed " << update.from << ", newest " << (update.to.empty() ? "?" : update.to) + << (update.waiting ? " (would be written)" : "") << (update.problem.empty() ? "" : " - " + update.problem) << std::endl; + fs::remove_all(mods.parent_path(), ignored); + return 0; +} +} // namespace + +int main(int argc, char **argv) { + if (argc > 1) return look_up(argv[1]); + const auto mods = fs::temp_directory_path() / "reskate-server-map-update-tests" / "Mods"; + std::error_code ignored; + fs::remove_all(mods.parent_path(), ignored); + try { + // The version changes and the rest of the manifest is kept. + const auto map = mods / "Team-Map"; + write(map / "manifest.json", "{\"name\": \"Map\", \"version_number\": \"1.0.0\", \"dependencies\": [\"A-B-1.0.0\"], \"website_url\": \"\"}"); + write(map / "reskate-levels.json", "{\"levels\": [1]}"); + check(map_version(map) == "1.0.0", "A manifest's version was not read"); + set_map_version(map, "1.2.0"); + const auto after = Json::parse(read(map / "manifest.json")); + check(map_version(map) == "1.2.0", "The new version is not in the manifest"); + check(after.value("name", "") == "Map" && after.contains("dependencies") && after.at("dependencies").size() == 1 && after.contains("website_url"), + "Writing the version lost something else of the manifest"); + check(read(map / "reskate-levels.json") == "{\"levels\": [1]}", "Writing the version touched the level list"); + check(!fs::exists(map / "manifest.json.new"), "The manifest's working file was left behind"); + + // A folder with only a level list gets a manifest with the version alone. + const auto lite = mods / "Team-Lite"; + write(lite / "reskate-levels.json", "{\"levels\": []}"); + check(map_version(lite).empty(), "A folder without a manifest has a version"); + set_map_version(lite, "3.0.0"); + check(map_version(lite) == "3.0.0", "A folder without a manifest did not get one with its version"); + + // A manifest that is not what it should be is left as it is. + const auto odd = mods / "Team-Odd"; + write(odd / "manifest.json", "[1, 2, 3]"); + bool threw{}; + try { + set_map_version(odd, "9.9.9"); + } catch (const std::exception &) { + threw = true; + } + check(threw && read(odd / "manifest.json") == "[1, 2, 3]", "A manifest that is not an object was written over"); + write(odd / "manifest.json", "{ not json"); + threw = false; + try { + set_map_version(odd, "9.9.9"); + } catch (const std::exception &) { + threw = true; + } + check(threw && read(odd / "manifest.json") == "{ not json", "A manifest that cannot be read was written over"); + } catch (const std::exception &e) { + check(false, std::string("The test stopped: ") + e.what()); + } + fs::remove_all(mods.parent_path(), ignored); + if (!failures) std::cout << "Server map update tests passed.\n"; + return failures ? 1 : 0; +} diff --git a/Server/main.cpp b/Server/main.cpp index a30398ba..66375cdd 100644 --- a/Server/main.cpp +++ b/Server/main.cpp @@ -3,6 +3,7 @@ // the Steam client files (steamclient64.dll, tier0_s64.dll, vstdlib_s64.dll). // On Linux the Steam files are libsteam_api.so and steamclient.so. #include "global_bans.h" +#include "server_map_update.h" #include "server_config.h" #include "server_host.h" #include "server_update.h" @@ -512,6 +513,7 @@ int run(int argc, char **argv, bool skip_update) { std::future ban_check; auto next_ban_check = next_advertise; bool bans_unread{}; + MapUpdater map_updater; // "maps.latest_versions" and "maps update" (server_map_update.h) if (!config.global_bans) write_log("Global bans are off (\"use_global_bans\": false): only this server's own bans apply."); write_log(host.command("words")); // reads this server's own chat words // Signed in to Steam, as last logged, and when it is looked at again. Players already on stay @@ -583,6 +585,12 @@ int run(int argc, char **argv, bool skip_update) { } update_now = false; } + if (const auto maps = map_updater.tick(config.latest_versions, config.latest_check_minutes, config.mods, host.map_folder_in_play(), + host.take_map_update_request()); + !maps.lines.empty() || maps.changed) { + for (const auto &line : maps.lines) write_log(line); + if (maps.changed) write_log(host.reload_maps()); + } // (The chat word lists come in the same answer, and hold on every server: it is read // for them whether or not the bans are used.) if (!ban_check.valid() && now_time >= next_ban_check) diff --git a/Server/server_commands.cpp b/Server/server_commands.cpp index 971bc9b2..f671e73b 100644 --- a/Server/server_commands.cpp +++ b/Server/server_commands.cpp @@ -183,22 +183,34 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { if (name == "maps" && lower(argument) == "reload") { // A map mod copied into Mods while the server runs: its maps can be switched to at once. if (!console && !can(admin, "map")) return "Your role cannot reload the maps."; - std::vector before; - for (const auto &level : levels()) before.push_back(level.asset); - const auto problems = load_levels(config_.mods); - resend_maps(); // whoever picks maps is sent the list again - std::string added; - std::size_t gone{}; - for (const auto &level : levels()) - if (std::find(before.begin(), before.end(), level.asset) == before.end()) added += (added.empty() ? "" : ", ") + level.name; - for (const auto &asset : before) - if (!find_level(asset)) ++gone; - std::string text = "Maps read again from Mods: " + std::to_string(levels().size()) + " maps" + - (added.empty() ? ", none new" : ", new: " + added) + (gone ? ", " + std::to_string(gone) + " no longer there" : "") + "."; - for (const auto &problem : problems) text += "\nMods: skipped " + problem; + const auto text = reload_maps(); if (!console) log_(text); return text; } + if (name == "maps" && lower(split(argument).first) == "update") { + // Now, whatever "maps latest" is: Thunderstore is asked about each map mod (server_map_update.h). + if (!console && !can(admin, "map")) return "Your role cannot update the maps."; + map_update_requested_ = true; + return std::string("Asking Thunderstore for the newest version of each of this server's maps; what it finds is in the log."); + } + if (name == "maps" && lower(split(argument).first) == "latest") { // maps latest [on|off | every ] + if (!console && !can(admin, "map")) return "Your role cannot change how the maps are updated."; + const auto [word, wanted] = split(split(argument).second); + const auto now_text = std::string("maps latest on|off | maps latest every <5-1440 minutes> (now ") + (config_.latest_versions ? "on" : "off") + + ", every " + std::to_string(config_.latest_check_minutes) + " min)"; + if (lower(word) == "every") { + const auto minutes = number(wanted); + if (!minutes || *minutes < 5 || *minutes > 1440) return now_text; + config_.latest_check_minutes = static_cast(*minutes); + return changed("Thunderstore is asked about this server's maps every " + std::to_string(*minutes) + " minutes."); + } + const auto on = on_off(word); + if (!on) return now_text; + config_.latest_versions = *on; + if (*on) map_update_requested_ = true; + return changed(*on ? "This server keeps its maps at Thunderstore's newest version, and players are held to it." + : "This server's maps stay at the versions in its Mods folder."); + } if (name == "maps") { std::string text = std::to_string(levels().size()) + " maps (custom maps come from Mods next to the server)"; for (const auto &level : levels()) diff --git a/Server/server_config.cpp b/Server/server_config.cpp index da9b413b..1a40047f 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -90,6 +90,8 @@ Layout layout(const ServerConfig &c) { maps.set("rotation_vote_seconds", c.rotation_vote_seconds); maps.set("rotation_extend_minutes", c.rotation_extend); maps.set("rotation_announce", c.rotation_announce); + maps.set("latest_versions", c.latest_versions); + maps.set("latest_check_minutes", c.latest_check_minutes); auto parks = Json::object(); for (unsigned lot = 0; lot < park_lots.size(); ++lot) parks[park_lots[lot].key] = c.parks[lot]; maps.set("parks", std::move(parks)); @@ -336,6 +338,8 @@ ServerConfig load_config(const std::filesystem::path &file, std::vectoris_object()) for (unsigned lot = 0; lot < park_lots.size(); ++lot) c.parks[lot] = parks->value(park_lots[lot].key, c.parks[lot]); c.world_layer_sync = get("maps", "world_layer_sync", c.world_layer_sync); diff --git a/Server/server_config.h b/Server/server_config.h index 8dd45551..f5726021 100644 --- a/Server/server_config.h +++ b/Server/server_config.h @@ -100,6 +100,10 @@ struct ServerConfig { unsigned rotation_extend = 15; // minutes, 0 to 240 // Players are told on a card when the map has 10, 5 and 1 minutes left. bool rotation_announce = true; + // The custom maps in Mods that are Thunderstore packages are kept at Thunderstore's newest + // version (server_map_update.h), looked up at start and every so many minutes. + bool latest_versions = false; + unsigned latest_check_minutes = 60; unsigned max_players = 16; // players; the server itself is not one // The most poses a second one player is sent (crowd_limits); 0: no limit. unsigned crowd_budget = crowd_pose_budget; diff --git a/Server/server_engine.cpp b/Server/server_engine.cpp index f8c07c9c..fb64f0d7 100644 --- a/Server/server_engine.cpp +++ b/Server/server_engine.cpp @@ -1,5 +1,6 @@ #include "server_engine.h" #include "global_bans.h" +#include "server_map_update.h" #include "server_update.h" #include "Extension/Multiplayer/Session/monotonic_clock.h" #include "Engine/Core/Text/word_filter.h" @@ -395,6 +396,7 @@ void ServerEngine::worker_loop() { auto next_update_check = std::chrono::steady_clock::now() + std::chrono::minutes(15); auto next_ban_check = std::chrono::steady_clock::now(); std::future ban_check; + MapUpdater map_updater; // "maps.latest_versions" and "maps update" (server_map_update.h) bool bans_unread = true; std::optional name_allowed; const auto target_tick_interval = std::chrono::microseconds(1000000 / std::max(10U, config_.tps)); @@ -416,6 +418,12 @@ void ServerEngine::worker_loop() { // Live ReSkate Identity Sync (Centrix, Dev, Staff, Homie, Content Creator, Global Bans) const auto now_time = std::chrono::steady_clock::now(); + if (const auto maps = map_updater.tick(config_.latest_versions, config_.latest_check_minutes, config_.mods, host_->map_folder_in_play(), + host_->take_map_update_request()); + !maps.lines.empty() || maps.changed) { + for (const auto &line : maps.lines) log(line); + if (maps.changed) log(host_->reload_maps()); + } if (!ban_check.valid() && now_time >= next_ban_check) { ban_check = std::async(std::launch::async, read_global_bans); } diff --git a/Server/server_host.h b/Server/server_host.h index 2a8919c9..ac8182cc 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -46,6 +46,12 @@ class Host { std::string invite() const; std::string map_name() const; + // "maps reload": reads Mods again and says what changed. Also after a map was updated. + std::string reload_maps(); + // The mod folder of the map in play while anyone is on (an update leaves it alone), or + // nothing; and whether "maps update" was typed since this was last asked. + std::string map_folder_in_play() const; + bool take_map_update_request() { return std::exchange(map_update_requested_, false); } unsigned players() const; std::uint64_t secret() const { return secret_; } // The UDP port players may connect straight to, or 0 (config connection). @@ -224,6 +230,7 @@ class Host { ServerConfig &config_; SteamTransport &transport_; Log log_; + bool map_update_requested_{}; ActivityLog activity_; // what players do, for the console (config_.activity_log) // The running player vote (server_votes.cpp), and when each player may start another. // A poll is one too: it has answers instead of yes and no, and runs nothing (but the console's poll-run). diff --git a/Server/server_map_update.cpp b/Server/server_map_update.cpp new file mode 100644 index 00000000..fb146dd6 --- /dev/null +++ b/Server/server_map_update.cpp @@ -0,0 +1,181 @@ +#include "server_map_update.h" +#include "Engine/Core/Json/json.h" +#include "Engine/Vfs/thunderstore_package.h" +#ifdef _WIN32 +#include "Engine/Vfs/https_download.h" +#else +#include +#endif +#include +#include +#include +#include + +namespace dingosdk::server { +namespace { +namespace fs = std::filesystem; +namespace package = thunderstore_package; + +constexpr char community[] = "reskate"; +constexpr std::uint64_t largest_answer = 1024 * 1024; + +#ifdef _WIN32 +std::string get_text(const std::wstring &url) { + https::Download result; + auto answer = https::get_text(url, largest_answer, 20, L"ReSkateServer/1", &result); + if (!answer) throw std::runtime_error("HTTP " + std::to_string(result.http_status) + ", error " + std::to_string(result.error)); + return std::move(*answer); +} +#else +// curl does the TLS, as for the team's lists (global_bans.cpp). The address is made here from a +// package's name, which is letters, digits, "_", "-" and "." only; anything else is refused +// before it reaches a command line. +std::string get_text(const std::wstring &url) { + std::string address; + for (const auto wide : url) { + const char c = wide < 0x80 ? static_cast(wide) : '?'; + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.' || c == '/' || c == ':')) + throw std::runtime_error("the package's address is not one to ask for"); + address += c; + } + const auto command = "curl --silent --show-error --fail --location --max-time 20 --max-filesize " + std::to_string(largest_answer) + + " --proto =https --proto-redir =https --user-agent ReSkateServer/1 " + address + " 2>&1"; + auto *pipe = popen(command.c_str(), "r"); + if (!pipe) throw std::runtime_error("curl could not be started"); + std::string output; + char buffer[4096]; + for (std::size_t count; (count = fread(buffer, 1, sizeof(buffer), pipe)) > 0;) + if (output.size() < largest_answer) output.append(buffer, count); + if (pclose(pipe) != 0) { + while (!output.empty() && (output.back() == '\n' || output.back() == '\r')) output.pop_back(); + std::replace(output.begin(), output.end(), '\n', ' '); + throw std::runtime_error(output.empty() ? "curl failed" : output); + } + return output; +} +#endif + +std::string read(const fs::path &file) { + std::ifstream in(file, std::ios::binary); + std::stringstream text; + text << in.rdbuf(); + return in ? text.str() : std::string(); +} +bool hidden(const fs::path &folder) { + const auto name = folder.filename().string(); + return name.empty() || name.front() == '.'; +} +} // namespace + +std::string map_version(const fs::path &mod) { + try { + const auto text = read(mod / "manifest.json"); + return text.empty() ? std::string() : Json::parse(text).value("version_number", ""); + } catch (const std::exception &) { + return {}; + } +} + +void set_map_version(const fs::path &mod, const std::string &version) { + const auto file = mod / "manifest.json"; + auto root = Json::object(); + // (A manifest that cannot be read is not written over: its mod is left as it is.) + if (const auto text = read(file); !text.empty()) { + root = Json::parse(text); + if (!root.is_object()) throw std::runtime_error("its manifest.json is not an object"); + } + root["version_number"] = version; + // Written beside it and moved over it, so a manifest is never half written. + auto fresh = file; + fresh += ".new"; + { + std::ofstream out(fresh, std::ios::binary | std::ios::trunc); + out << root.dump(2) << '\n'; + if (!out.flush()) throw std::runtime_error("its manifest.json could not be written"); + } + std::error_code moved; + fs::rename(fresh, file, moved); + if (moved) { + std::error_code ignored; + fs::remove(fresh, ignored); + throw std::runtime_error("its manifest.json could not be replaced (" + moved.message() + ")"); + } +} + +std::vector update_maps(const fs::path &mods, const std::string &in_play) { + std::vector result; + std::error_code error; + std::vector folders; + for (const auto &entry : fs::directory_iterator(mods, error)) + if (entry.is_directory(error) && !hidden(entry.path()) && fs::exists(entry.path() / "reskate-levels.json", error)) + folders.push_back(entry.path()); + for (const auto &path : folders) { + const auto folder = path.filename().string(); + const auto name = package::parse_name(folder); + if (!name || !name->version.empty()) continue; // not a Thunderstore package's folder + MapUpdate update; + update.folder = folder; + update.from = map_version(path); + try { + const auto choice = package::choose(get_text(package::details_url(*name)), *name, community); + if (!choice.ok) { + update.problem = choice.reason.empty() ? "Thunderstore does not list it as a map" : choice.reason; + result.push_back(std::move(update)); + continue; + } + update.to = choice.version; + if (update.to == update.from) continue; // already the newest: nothing to say + if (folder == in_play) { + update.waiting = true; + result.push_back(std::move(update)); + continue; + } + set_map_version(path, update.to); + update.updated = true; + } catch (const std::exception &e) { + update.problem = e.what(); + // (A map copied in by hand, or one its author took down.) + if (update.problem.find("404") != std::string::npos) update.problem = "it is not on Thunderstore under that name"; + } + result.push_back(std::move(update)); + } + return result; +} + +MapUpdater::Done MapUpdater::tick(bool enabled, unsigned minutes, const fs::path &mods, const std::string &in_play, bool asked) { + Done done; + const auto now = std::chrono::steady_clock::now(); + asked_ = asked_ || asked; + if (work_.valid() && work_.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { + bool again_soon{}; + std::vector found; + try { + found = work_.get(); + } catch (const std::exception &e) { + done.lines.push_back(std::string("Map versions: the check failed (") + e.what() + ")."); + again_soon = true; + } + for (const auto &update : found) { + if (update.updated) { + done.changed = true; + done.lines.push_back("Map versions: " + update.folder + " is now at " + update.to + (update.from.empty() ? "" : " (was " + update.from + ")") + + ", Thunderstore's newest; players are sent there."); + said_.erase(update.folder); + } else if (update.waiting) { + again_soon = true; + if (said_.insert(update.folder + " waits for " + update.to).second) + done.lines.push_back("Map versions: " + update.folder + " " + update.to + " is out, and waits until its map is not being played."); + } else if (!update.problem.empty() && said_.insert(update.folder + ": " + update.problem).second) { + done.lines.push_back("Map versions: " + update.folder + " was left as it is: " + update.problem); + } + } + next_ = now + (again_soon ? std::chrono::minutes(5) : std::chrono::minutes(std::max(minutes, 5U))); + } + if (!work_.valid() && ((enabled && now >= next_) || asked_)) { + asked_ = false; + next_ = now + std::chrono::minutes(std::max(minutes, 5U)); + work_ = std::async(std::launch::async, update_maps, mods, in_play); + } + return done; +} +} // namespace dingosdk::server diff --git a/Server/server_map_update.h b/Server/server_map_update.h new file mode 100644 index 00000000..e991bd67 --- /dev/null +++ b/Server/server_map_update.h @@ -0,0 +1,51 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +// Keeping the server's custom maps at their newest Thunderstore version ("maps.latest_versions"). +// The server reads two files of a map mod: reskate-levels.json (its levels) and manifest.json +// (its version, which players are held to: protocol.h: map_package_name). So nothing is +// downloaded: a map mod in Mods whose folder is a Thunderstore package (Owner-Name) is asked +// about, and when Thunderstore's newest version is not the one in its manifest.json, that +// version is written there. Players then fetch the newest as they join. +// +// The level list is left as it is. A new version that adds, removes or renames levels needs its +// reskate-levels.json copied in by the server's owner, as when the map was first set up. +namespace dingosdk::server { +struct MapUpdate { + std::string folder, from, to; // the mod folder, its version before and Thunderstore's newest + bool updated{}; // the newest version is in its manifest.json + bool waiting{}; // left for later: its map is being played + std::string problem; // why it could not be looked up or written, or empty +}; +// Looks every map mod in `mods` up and writes the newest version where it differs. `in_play`: a +// mod folder to leave as it is this time. Asks Thunderstore, so it can take a few seconds a +// map; made for a worker thread. +std::vector update_maps(const std::filesystem::path &mods, const std::string &in_play); +// The version a mod folder's manifest.json gives (nothing when it has none), and writing one +// there: everything else in the file is kept, and a folder without one gets a file with the +// version alone. Throws when the file cannot be written. +std::string map_version(const std::filesystem::path &mod); +void set_map_version(const std::filesystem::path &mod, const std::string &version); + +// The run loop's side: a check when one is due or asked for, on a worker, and what it found. +class MapUpdater { + public: + struct Done { + std::vector lines; // for the log + bool changed{}; // a map's version changed: read Mods again + }; + // Every turn of the loop. `enabled` and `minutes`: the config's; `asked`: "maps update". + Done tick(bool enabled, unsigned minutes, const std::filesystem::path &mods, const std::string &in_play, bool asked); + + private: + std::future> work_; + std::chrono::steady_clock::time_point next_{}; + bool asked_{}; + std::set said_; // problems said once, not at every check +}; +} // namespace dingosdk::server diff --git a/Server/server_votes.cpp b/Server/server_votes.cpp index 67886f8a..f60ef932 100644 --- a/Server/server_votes.cpp +++ b/Server/server_votes.cpp @@ -741,6 +741,30 @@ std::string Host::rotation_text() const { void Host::resend_maps() { for (auto &[id, guest] : guests_) guest->maps_sent = false; } +std::string Host::reload_maps() { + std::vector before; + for (const auto &level : levels()) before.push_back(level.asset); + const auto problems = load_levels(config_.mods); + resend_maps(); // whoever picks maps is sent the list again + std::string added; + std::size_t gone{}; + for (const auto &level : levels()) + if (std::find(before.begin(), before.end(), level.asset) == before.end()) added += (added.empty() ? "" : ", ") + level.name; + for (const auto &asset : before) + if (!find_level(asset)) ++gone; + std::string text = "Maps read again from Mods: " + std::to_string(levels().size()) + " maps" + + (added.empty() ? ", none new" : ", new: " + added) + (gone ? ", " + std::to_string(gone) + " no longer there" : "") + "."; + for (const auto &problem : problems) text += "\nMods: skipped " + problem; + return text; +} +std::string Host::map_folder_in_play() const { + if (!players()) return {}; + // The package is the folder and the version (protocol.h: map_package_name). + const auto package = map_package(config_.map); + const auto version = multiplayer::map_package_version(package); + if (package.empty()) return {}; + return version.empty() ? package : package.substr(0, package.size() - version.size() - 1); +} // The rotation's vote: three maps of the pool other than this one, picked at random, then any // map of the pool, then staying. Asked as a poll, so every game can answer it (/1, /2...); games // that know next_map_vote show the maps to pick from. diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 073d75cb..85c0675d 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -180,6 +180,7 @@ if(WIN32) Server/server_engine.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 Server/server_release.cpp Launcher/updater.cpp + Server/server_map_update.cpp Engine/Vfs/thunderstore_package.cpp Server/global_bans.cpp Server/server_words.cpp Extension/Multiplayer/developer_identity.cpp Extension/Multiplayer/word_lists.cpp Extension/Multiplayer/Steam/steam_transport.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Net/wire_codec.cpp @@ -196,7 +197,7 @@ else() find_package(OpenSSL REQUIRED) add_executable(dingosdk_server Server/main.cpp Server/server_engine.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 Server/server_release.cpp + Server/server_update.cpp Server/server_release.cpp Server/server_map_update.cpp Engine/Vfs/thunderstore_package.cpp Server/global_bans.cpp Server/server_words.cpp Extension/Multiplayer/developer_identity.cpp Extension/Multiplayer/word_lists.cpp Extension/Multiplayer/Steam/steam_transport.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Net/wire_codec.cpp