diff --git a/CMakeLists.txt b/CMakeLists.txt index 63b78dbb..50c2ff11 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -81,6 +81,11 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS) dingosdk_lz4 dingosdk_zstd) endif() add_test(NAME server_config COMMAND dingosdk_server_config_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}") + target_link_libraries(dingosdk_global_bans_tests PRIVATE dingosdk_json $<$:dingosdk_https>) + add_test(NAME server_global_bans COMMAND dingosdk_global_bans_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) @@ -154,6 +159,11 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32) target_link_libraries(dingosdk_multiplayer_mesh_tests PRIVATE dingosdk_logging dingosdk_launcher_support dingosdk_lz4 dingosdk_zstd bcrypt dingosdk_word_filter) add_test(NAME multiplayer_mesh COMMAND dingosdk_multiplayer_mesh_tests) + add_executable(dingosdk_developer_identity_tests + Extension/Multiplayer/Test/developer_identity_tests.cpp Extension/Multiplayer/developer_identity.cpp + Extension/Multiplayer/developer_identity_fetch.cpp) + target_link_libraries(dingosdk_developer_identity_tests PRIVATE dingosdk_json dingosdk_https dingosdk_logging winhttp) + add_test(NAME developer_identity COMMAND dingosdk_developer_identity_tests) add_executable(dingosdk_settings_lock_tests Engine/Game/Settings/Test/multiplayer_settings_lock_tests.cpp) target_include_directories(dingosdk_settings_lock_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME multiplayer_settings_lock COMMAND dingosdk_settings_lock_tests) diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index 486272ae..15db4660 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -69,7 +69,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", "Admin", "Host", "Friend" or empty). + // and a tag shown in a box before the name ("Dev", "Creator", "Homie", "Admin", "Host", "Friend" or empty). std::uint32_t color{}; std::string tag; }; @@ -113,6 +113,10 @@ struct MultiplayerModel { bool chat_visible{true}; // Local: bad words in chat names and messages show as **** (on by default). bool chat_filter{true}; + // Local: the tag the ReSkate backend gives this player ("Dev", "Creator" or "Homie"; empty + // for most players), and whether they show it and its animated items, to everyone. + std::string identity_tag; + bool identity_marks{true}; 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. // Admins it lists may change its settings, and its map through Levels. diff --git a/Engine/Resource/ebx_document.cpp b/Engine/Resource/ebx_document.cpp index a7eee7cf..3f439739 100644 --- a/Engine/Resource/ebx_document.cpp +++ b/Engine/Resource/ebx_document.cpp @@ -631,4 +631,11 @@ Document read_document(const std::span bytes) { return result; } +Guid read_file_guid(const std::span bytes) { + BinaryReader reader(bytes); + const auto chunks = read_chunks(reader); + reader.seek(unique_chunk(chunks, efix, "EFIX").begin); + return reader.guid(); +} + } diff --git a/Engine/Resource/ebx_document.h b/Engine/Resource/ebx_document.h index 8d50e399..4ae98098 100644 --- a/Engine/Resource/ebx_document.h +++ b/Engine/Resource/ebx_document.h @@ -194,5 +194,8 @@ struct Document { }; [[nodiscard]] Document read_document(std::span bytes); +// The document's own guid, which is what another document's import names it by, +// without reading the rest. +[[nodiscard]] Guid read_file_guid(std::span bytes); } diff --git a/Engine/Resource/ebx_merge.cpp b/Engine/Resource/ebx_merge.cpp index b4d3d858..da307d46 100644 --- a/Engine/Resource/ebx_merge.cpp +++ b/Engine/Resource/ebx_merge.cpp @@ -289,7 +289,7 @@ void sort_instances(Document& document) { } // namespace detail Document merge_documents(const Document& base, const std::span edits, - MergeSummary* summary) { + MergeSummary* summary, const CarryImport& carry) { auto result = detail::clone_document(base); const auto* baseRoot = base.root(); if (!baseRoot || !baseRoot->object) throw std::runtime_error("EBX base has no root object"); @@ -302,7 +302,8 @@ Document merge_documents(const Document& base, const std::spansize()); MergeSummary totals; - for (const auto* edit : edits) { + for (std::size_t index = 0; index < edits.size(); ++index) { + const auto* edit = edits[index]; if (!edit) continue; const auto* editRoot = edit->root(); if (!editRoot || !editRoot->object) throw std::runtime_error("EBX edit has no root object"); @@ -320,7 +321,17 @@ Document merge_documents(const Document& base, const std::spansize(); ++at) { - target->push_back(mapper.value((*editArray)[at])); + const auto& entry = (*editArray)[at]; + const auto* pointer = carry ? std::get_if(&entry.data) : nullptr; + if (pointer && pointer->kind == PointerKind::external && pointer->index >= 0 && + static_cast(pointer->index) < edit->imports.size()) { + const auto& reference = edit->imports[static_cast(pointer->index)]; + if (!carry(reference)) { + totals.dropped.push_back({index, reference}); + continue; + } + } + target->push_back(mapper.value(entry)); ++totals.arrayEntries; } } diff --git a/Engine/Resource/ebx_merge.h b/Engine/Resource/ebx_merge.h index fe3d0559..5440e769 100644 --- a/Engine/Resource/ebx_merge.h +++ b/Engine/Resource/ebx_merge.h @@ -3,6 +3,7 @@ #include "ebx_document.h" #include +#include #include #include @@ -12,8 +13,19 @@ struct MergeSummary { std::size_t instances{}; // instances taken from the edits std::size_t arrayEntries{};// root array entries taken from the edits std::size_t renumbered{}; // carried instances whose object id was re-derived + // Root array entries left behind because `carry` turned their target down. + struct Dropped { + std::size_t edit{}; // index into `edits` + ImportReference target; + }; + std::vector dropped; }; +// Whether a root array entry an edit appended comes across, asked for an entry +// that is a reference into another document. Such a reference only resolves +// while the document it names is loaded; one that is not loads as a null object. +using CarryImport = std::function; + // Combines several independently edited copies of one EBX asset with the base // they were all derived from. Each edit contributes the instances it added and // the entries it appended to the root's arrays; anything an edit merely changed @@ -21,6 +33,7 @@ struct MergeSummary { // be honoured. Edits are applied in the order given, so the last one wins a tie. [[nodiscard]] Document merge_documents(const Document& base, std::span edits, - MergeSummary* summary = nullptr); + MergeSummary* summary = nullptr, + const CarryImport& carry = {}); } // namespace dingosdk::frostbite::ebx diff --git a/Engine/Vfs/Test/item_list_merge_tests.cpp b/Engine/Vfs/Test/item_list_merge_tests.cpp new file mode 100644 index 00000000..4d5317bc --- /dev/null +++ b/Engine/Vfs/Test/item_list_merge_tests.cpp @@ -0,0 +1,393 @@ +// Cosmetic mods whose item lists name an item the merged bundle does not hold. +// +// Every cosmetic mod ships its own copy of the game's shared customization bundle: the game's +// assets, one of the game's item lists with the mod's item appended, and the item. The merge +// combines the lists, and a bundle keeps one asset per name. The game reads each entry of an +// item list at startup without checking it, so an entry whose item is not in the bundle is a +// crash on every launch (Skate.exe+0x891e07, 2026-10-04). Two ways to get one: +// * two mods each add a different item under the same asset name, and only one can stay; +// * a mod's list names an item no installed mod ships (it was built on top of another mod). +// Either way the entry has to go, and everything else of every mod has to stay. (A bundle only +// one mod ships is not merged at all, it passes through as that mod built it, so every case +// here has two mods shipping the shared bundle.) +// +// The mods are made here from the installed game's own list and one of its items. +// Arguments: []; skipped when the game is not there. With a second +// argument the two mods of the first case are also left in that folder, stamped for this game, +// to try a build against in the game itself. +#include "Engine/Game/Build/supported_build.h" +#include "Engine/Resource/binary_bundle.h" +#include "Engine/Resource/cas_codec.h" +#include "Engine/Resource/ebx_carry.h" +#include "Engine/Resource/ebx_document.h" +#include "Engine/Resource/ebx_writer.h" +#include "Engine/Resource/toc.h" +#include "Engine/Vfs/mod_catalog.h" +#include "Engine/Vfs/mod_merge_internal.h" + +#include + +#include +#include +#include + +namespace fs = std::filesystem; +namespace fb = dingosdk::frostbite; +using namespace dingosdk; +using namespace dingosdk::mods::detail; + +namespace { +using Bytes = std::vector; +constexpr char shared_bundle[] = "win32/characters/customization/configs/cas_main_sharedbundle"; +constexpr char list_type[] = "delmaritemcollectionasset"; + +int failures = 0; +void expect(bool ok, const std::string& what) { + if (ok) return; + std::cerr << "FAIL: " << what << '\n'; + ++failures; +} + +fb::Guid guid(std::uint8_t seed) { + fb::Guid value; + for (std::size_t index = 0; index < value.bytes.size(); ++index) + value.bytes[index] = static_cast(static_cast(seed + index)); + return value; +} + +// The shared bundle as one data layer has it: the game's, a mod's or the merged patch's. +struct Shared { + fb::TocBundle bundle; + Listing listing; +}; +Shared shared_in(const CasStore& store, const fs::path& root, const fs::path& baseRoot, const fs::path& gameRoot) { + for (const auto& bundle : fb::read_toc(read_file(root / L"Win32" / L"items.toc")).bundles) { + if (lower(bundle.name) != shared_bundle) continue; + if (auto listing = list_bundle(store, root, baseRoot, bundle, gameRoot)) return {bundle, std::move(*listing)}; + break; + } + throw std::runtime_error(std::string("no readable ") + shared_bundle + " in " + root.string()); +} + +bool list_name(std::string_view name) { + const auto text = lower(name); + return text.starts_with("items/") && text.find("collection") != std::string::npos; +} + +// The items a list names, by the guid of each one's own document. +std::vector listed(const fb::ebx::Document& list) { + const auto* root = list.root(); + const auto* items = root && root->object ? root->object->find("Items") : nullptr; + const auto* entries = items ? std::get_if(&items->value.data) : nullptr; + if (!entries) throw std::runtime_error("an item list has no Items"); + std::vector result; + for (const auto& entry : *entries) { + const auto* pointer = std::get_if(&entry.data); + if (!pointer || pointer->kind != fb::ebx::PointerKind::external) + throw std::runtime_error("an item list entry is not a reference to another document"); + result.push_back(list.imports.at(static_cast(pointer->index)).fileGuid); + } + return result; +} + +// The game's list with more items appended, the way a cosmetic mod ships it. +Bytes list_with(const fb::ebx::Document& game, std::initializer_list items) { + auto list = fb::ebx::detail::clone_document(game); + for (auto& field : list.instances.front().object->fields) { + if (field.name != "Items") continue; + auto& entries = std::get(field.value.data); + for (const auto& item : items) { + fb::ebx::Value entry; + entry.data = fb::ebx::PointerReference{fb::ebx::PointerKind::external, + static_cast(list.imports.size())}; + list.imports.push_back(item); + entries.push_back(std::move(entry)); + } + } + return fb::ebx::write_document(list); +} + +// One of the game's items under a name and identity of its own: what a cosmetic mod adds. +struct Item { + std::string name; + fb::ebx::ImportReference identity; + Bytes payload; +}; +Item item_from(const fb::ebx::Document& donor, const std::string& name, std::uint8_t seed) { + auto item = fb::ebx::detail::clone_document(donor); + auto& root = item.instances.front(); + item.fileGuid = guid(seed); + root.instanceGuid = guid(static_cast(seed + 0x40)); + const auto key = name.substr(name.rfind('/') + 1); + std::uint32_t hash = 5381; // the game's own hash of an item's key + for (const unsigned char character : key) hash = hash * 33U ^ character; + for (auto& field : root.object->fields) { + if (field.name == "Name") field.value.data = name; + else if (field.name == "Key") field.value.data = key; + else if (field.name == "HashedAssetKey") { + if (std::holds_alternative(field.value.data)) field.value.data = static_cast(hash); + else field.value.data = static_cast(hash); + } + } + return {name, {item.fileGuid, root.instanceGuid}, fb::ebx::write_document(item)}; +} + +// What the mods are made from: the game's shared bundle, its shortest item list and an item. +struct Game { + fs::path root, base; + Shared shared; + std::size_t listIndex{}; + std::string listName; + fb::ebx::Document list; + std::size_t listed{}; + fb::ebx::Document item; +}; +Game read_game(const fs::path& root, const CasStore& store) { + Game game{root, root / L"Data"}; + game.shared = shared_in(store, game.base, game.base, root); + const auto& assets = game.shared.listing.manifest.ebx; + bool list{}, item{}; + for (std::size_t index = 0; index < assets.size(); ++index) { + const auto name = lower(assets[index].name); + if (!name.starts_with("items/") || (item && !list_name(name))) continue; + auto document = fb::ebx::read_document(read_asset(store, game.base, game.base, game.shared.listing, index, root)); + const auto type = lower(document.rootType); + if (type == list_type) { + const auto count = listed(document).size(); + if (list && count >= game.listed) continue; + list = true; + game.listIndex = index; + game.listName = assets[index].name; + game.listed = count; + game.list = std::move(document); + } else if (!item && type.find("itemasset") != std::string::npos) { + item = true; + game.item = std::move(document); + } + } + if (!list || !item) throw std::runtime_error("the game's shared bundle has no item list or no item"); + return game; +} + +// A cosmetic mod's folder: its copy of the shared bundle in items.toc (the game's assets, the +// item list replaced and its items added) and one archive holding the manifest and those payloads. +// With no list the mod leaves the game's as it is, as a mod does whose items go in another one. +void write_mod(const fs::path& directory, const CasStore& store, const Game& game, const Bytes* list, + std::initializer_list items) { + auto manifest = game.shared.listing.manifest; + const auto& shipped = game.shared.listing.files; + std::vector files(shipped.begin() + static_cast(game.shared.listing.first), shipped.end()); + const auto installChunk = shipped.front().location.installChunk; + Bytes archive; + const auto stored = [&](std::span bytes) { + const fb::BundleFileInfo file{{true, installChunk, 1}, static_cast(archive.size()), + static_cast(bytes.size())}; + archive.insert(archive.end(), bytes.begin(), bytes.end()); + return file; + }; + const auto encoded = [](const Bytes& payload) { return fb::encode_cas(payload, {.compression = fb::CasCompression::raw}); }; + if (list) { + manifest.ebx[game.listIndex].sha1 = sha1_of(*list); + manifest.ebx[game.listIndex].originalSize = list->size(); + files[game.listIndex] = stored(encoded(*list)); + } + for (const auto& item : items) { + fb::BundleAsset asset; + asset.kind = fb::AssetKind::ebx; + asset.name = item.name; + asset.sha1 = sha1_of(item.payload); + asset.originalSize = item.payload.size(); + files.insert(files.begin() + static_cast(manifest.ebx.size()), stored(encoded(item.payload))); + manifest.ebx.push_back(std::move(asset)); + } + files.insert(files.begin(), stored(fb::write_binary_bundle(manifest))); + const std::vector bundles{{game.shared.bundle.name, fb::write_bundle_region(files), 1}}; + fs::create_directories(directory / L"Win32" / fs::path(store.directory(installChunk))); + write_file(directory / L"Win32" / L"items.toc", fb::write_patch_toc(bundles)); + write_file(directory / L"Win32" / fs::path(store.directory(installChunk)) / L"cas_01.cas", archive); + fs::copy_file(game.base / L"layout.toc", directory / L"layout.toc", fs::copy_options::overwrite_existing); +} + +// A Mods folder of its own beside nothing, merged against the installed game. +struct Merge { + fs::path root; + mods::Catalog catalog; + + Merge(const Game& game, const std::string& name) { + wchar_t temp[MAX_PATH]{}; + GetTempPathW(MAX_PATH, temp); + root = fs::path(temp) / ("reskate-item-lists-" + std::to_string(GetCurrentProcessId()) + "-" + name); + fs::remove_all(root); + catalog.data_root = game.root; + catalog.root = root / "Mods"; + catalog.present = true; + } + ~Merge() { + std::error_code ignored; + fs::remove_all(root, ignored); + } + // Mods are added highest priority first. + fs::path add(const std::string& name) { + mods::Mod mod; + mod.name = name; + mod.directory = catalog.root / name; + mod.provides_layout = true; + catalog.mods.push_back(mod); + return mod.directory; + } +}; + +// The merged shared bundle: the guid of every document in it, and what each item list names. +struct Merged { + std::set held; + std::map> lists; +}; +Merged read_merged(const Merge& merge, const Game& game) { + const auto output = merge.catalog.root / mods::generated_folder; + const auto layout = vfs::read_layout(output / L"layout.toc"); + const CasStore store(game.base, merge.root / L"unused", layout.root); + const auto shared = shared_in(store, output, game.base, game.root); + Merged merged; + for (std::size_t index = 0; index < shared.listing.manifest.ebx.size(); ++index) { + const auto bytes = read_asset(store, output, game.base, shared.listing, index, game.root); + merged.held.insert(fb::ebx::read_file_guid(bytes)); + const auto& name = shared.listing.manifest.ebx[index].name; + if (!list_name(name)) continue; + const auto document = fb::ebx::read_document(bytes); + if (lower(document.rootType) == list_type) merged.lists.emplace(name, listed(document)); + } + return merged; +} + +bool noted(const mods::MergeReport& report, const std::string& text) { + return std::ranges::find(report.notes, text) != report.notes.end(); +} +bool noted_part(const mods::MergeReport& report, std::string_view part) { + return std::ranges::any_of(report.notes, [&](const std::string& note) { return note.find(part) != std::string::npos; }); +} +std::string describe(const mods::MergeReport& report) { + std::string text = "issue: '" + report.issue + "'"; + for (const auto& note : report.notes) text += "\n note: " + note; + for (const auto& [mod, problems] : report.problems) + for (const auto& problem : problems) text += "\n " + mod + ": " + problem; + return text; +} + +// What the game needs of any merge: every item every list names is in the bundle. Then how many +// entries the mods' list ended with, and that no mod was turned away over it. +std::vector merged_list(const Merge& merge, const Game& game, const mods::MergeReport& report, + const std::string& label, std::size_t added) { + expect(report.issue.empty() && report.built && report.problems.empty(), + label + ": the merge builds the patch and keeps every mod\n" + describe(report)); + if (!report.built) return {}; + const auto merged = read_merged(merge, game); + for (const auto& [name, items] : merged.lists) + for (const auto& item : items) + expect(merged.held.contains(item), label + ": " + name + " names " + item.string() + + ", which the merged bundle does not hold"); + const auto found = merged.lists.find(game.listName); + expect(found != merged.lists.end(), label + ": the merged bundle still has " + game.listName); + if (found == merged.lists.end()) return {}; + expect(found->second.size() == game.listed + added, label + ": the list has the game's " + std::to_string(game.listed) + + " item(s) and " + std::to_string(added) + " more (" + std::to_string(found->second.size()) + ")\n" + describe(report)); + return found->second; +} +bool names(const std::vector& list, const Item& item) { + return std::ranges::find(list, item.identity.fileGuid) != list.end(); +} + +constexpr char same_name[] = "items/reskate_test/own_same_name"; + +// Two mods add different items under one name. The higher-priority mod's is the one the bundle +// keeps, so the other's entry goes, and the merge says which mod lost an item to which. +void same_name_from_two_mods(const Game& game, const CasStore& store, const fs::path* keep) { + Merge merge(game, "same-name"); + const auto upper = item_from(game.item, same_name, 0x10); + const auto under = item_from(game.item, same_name, 0x20); + const auto upperList = list_with(game.list, {upper.identity}), underList = list_with(game.list, {under.identity}); + const auto build = [&](const fs::path& top, const fs::path& bottom) { + write_mod(top, store, game, &upperList, {upper}); + write_mod(bottom, store, game, &underList, {under}); + }; + const auto top = merge.add("upper"), bottom = merge.add("under"); + build(top, bottom); + const auto report = mods::merge_mods(merge.catalog); + const auto list = merged_list(merge, game, report, "same name", 1); + expect(names(list, upper) && !names(list, under), "same name: the list keeps the higher-priority mod's item only"); + expect(noted(report, "under: " + game.shared.bundle.name + ": 1 added asset(s) share a name with ones upper adds, e.g. " + + same_name + "; the merged bundle keeps upper's"), + "same name: the merge says whose asset the name went to\n" + describe(report)); + expect(noted(report, "under: " + game.listName + ": 1 item(s) left out of the list because the merged bundle does not " + "hold them, e.g. " + same_name + ", replaced by upper's asset of the same name"), + "same name: the merge says which item was left out and why\n" + describe(report)); + if (!keep) return; + // The same two for the game's own Mods folder, where a mod has to say what it was built for. + const std::string stamp = "ReSkate Studio native Patch v1\nskate_sha256=" + std::string(supported_build::game_sha256) + "\n"; + for (const auto* name : {L"ReSkateTest-SameNameUpper", L"ReSkateTest-SameNameUnder"}) fs::create_directories(*keep / name); + build(*keep / L"ReSkateTest-SameNameUpper", *keep / L"ReSkateTest-SameNameUnder"); + for (const auto* name : {L"ReSkateTest-SameNameUpper", L"ReSkateTest-SameNameUnder"}) + write_file(*keep / name / mods::studio_marker_file, std::as_bytes(std::span(stamp))); + std::cout << "The two same-name mods are in " << keep->string() << ".\n"; +} + +// Two mods add items under names of their own: both stay, and nothing is said about leaving any out. +void different_names_from_two_mods(const Game& game, const CasStore& store) { + Merge merge(game, "different-names"); + const auto first = item_from(game.item, "items/reskate_test/own_first", 0x10); + const auto second = item_from(game.item, "items/reskate_test/own_second", 0x20); + const auto firstList = list_with(game.list, {first.identity}), secondList = list_with(game.list, {second.identity}); + write_mod(merge.add("first"), store, game, &firstList, {first}); + write_mod(merge.add("second"), store, game, &secondList, {second}); + const auto report = mods::merge_mods(merge.catalog); + const auto list = merged_list(merge, game, report, "different names", 2); + expect(names(list, first) && names(list, second), "different names: the list has both mods' items"); + expect(!noted_part(report, "left out of the list") && !noted_part(report, "share a name"), + "different names: nothing is reported\n" + describe(report)); +} + +// One mod alone changes the list, beside a mod that ships the bundle with the game's. Its list +// as it wrote it is what the bundle gets, unless that list names an item nothing ships: then +// that entry goes and its own item stays. +void one_mod_changes_the_list(const Game& game, const CasStore& store, bool missing) { + const std::string label = missing ? "an item nothing ships" : "one mod's list"; + Merge merge(game, missing ? "missing-item" : "one-list"); + const auto own = item_from(game.item, "items/reskate_test/own_alone", 0x10); + const fb::ebx::ImportReference absent{guid(0x70), guid(0x90)}; + const auto shipped = missing ? list_with(game.list, {own.identity, absent}) : list_with(game.list, {own.identity}); + write_mod(merge.add("alone"), store, game, &shipped, {own}); + write_mod(merge.add("bystander"), store, game, nullptr, {}); + const auto report = mods::merge_mods(merge.catalog); + const auto list = merged_list(merge, game, report, label, 1); + expect(names(list, own), label + ": the list has the mod's own item"); + const auto said = noted(report, "alone: " + game.listName + ": 1 item(s) left out of the list because the merged bundle " + "does not hold them, e.g. the asset with guid " + absent.fileGuid.string() + + ", which no enabled mod adds to the bundle"); + expect(said == missing, label + (missing ? ": the merge says which item was left out\n" : ": nothing is reported\n") + + describe(report)); +} +} // namespace + +int main(int argc, char** argv) try { + if (argc < 2 || !fs::exists(fs::path(argv[1]) / L"Data" / L"Win32" / L"items.toc")) { + std::cout << "No game given; skipped.\n"; + return 0; + } + const fs::path root = argv[1]; + const auto layout = vfs::read_layout(root / L"Data" / L"layout.toc"); + const CasStore store(root / L"Data", fs::temp_directory_path() / L"reskate-item-lists-unused", layout.root); + const auto game = read_game(root, store); + const fs::path keep = argc > 2 ? fs::path(argv[2]) : fs::path{}; + same_name_from_two_mods(game, store, argc > 2 ? &keep : nullptr); + different_names_from_two_mods(game, store); + one_mod_changes_the_list(game, store, false); + one_mod_changes_the_list(game, store, true); + if (failures) { + std::cerr << failures << " failure(s)\n"; + return 1; + } + std::cout << "item list merge: ok (" << game.listName << ", " << game.listed << " item(s) in the game)\n"; + return 0; +} catch (const std::exception& error) { + std::cerr << "FAIL: " << error.what() << '\n'; + return 1; +} diff --git a/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp b/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp index 337e86b3..34571741 100644 --- a/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp +++ b/Engine/Vfs/Test/mod_merge_added_assets_tests.cpp @@ -328,7 +328,8 @@ void copy_in_the_adders_toc_is_left_alone() { // Two asset mods each add test/song, each named by a change of its own (the rival's is to the // other asset). Only one can be carried into the map's copy: the higher-priority mod's. When the // two are different assets the merge says whose the name went to; the same asset added by both -// is no clash and is not reported. +// is no clash and is not reported. The bundle the two mods share keeps one asset per name as +// well, and says so the same way. void same_name_from_two_mods(bool different) { const std::string label = different ? "name clash" : "same asset twice"; Fixture fixture(different ? "name-clash" : "same-twice"); @@ -345,6 +346,10 @@ void same_name_from_two_mods(bool different) { "other mods' copies of the bundle get music's"); expect(said == different, label + (different ? ": the merge says whose asset the name went to\n" : ": nothing is reported\n") + describe(report)); + const bool kept = noted(report, std::string("rival: ") + bundle_name + ": 1 added asset(s) share a name with ones " + "music adds, e.g. test/song; the merged bundle keeps music's"); + expect(kept == different, label + (different ? ": the merge says whose asset the shared bundle keeps\n" + : ": nothing is reported about the shared bundle\n") + describe(report)); expect(noted(report, std::string("map: ") + bundle_name + ": 3 asset(s) added by other mods, e.g. test/song"), label + ": the map's copy still receives the first mod's three\n" + describe(report)); } diff --git a/Engine/Vfs/https_download.cpp b/Engine/Vfs/https_download.cpp index 9d654b54..d655c721 100644 --- a/Engine/Vfs/https_download.cpp +++ b/Engine/Vfs/https_download.cpp @@ -2,6 +2,9 @@ #include #include #include +#include +#include +#include #include namespace dingosdk::https { @@ -72,4 +75,24 @@ Download get(std::wstring_view url, const std::filesystem::path& destination, result.ok = true; return result; } + +std::optional get_text(std::wstring_view url, std::uint64_t max_bytes, + std::uint32_t timeout_seconds, const wchar_t* agent, Download* result) { + static std::atomic serial{}; + wchar_t folder[MAX_PATH + 1]{}; + const auto length = GetTempPathW(MAX_PATH, folder); + const auto path = std::filesystem::path(length ? std::wstring(folder, length) : L".") / + (L"reskate-get-" + std::to_wstring(GetCurrentProcessId()) + L"-" + std::to_wstring(++serial) + L".tmp"); + std::error_code error; + std::filesystem::remove(path, error); // get needs a fresh destination + const auto download = get(url, path, max_bytes, timeout_seconds, agent); + if (result) *result = download; + std::optional text; + if (download.ok) { + std::ifstream in(path, std::ios::binary); + text.emplace(std::istreambuf_iterator(in), std::istreambuf_iterator()); + } + std::filesystem::remove(path, error); + return text; +} } diff --git a/Engine/Vfs/https_download.h b/Engine/Vfs/https_download.h index e0cfe491..91b777ae 100644 --- a/Engine/Vfs/https_download.h +++ b/Engine/Vfs/https_download.h @@ -1,6 +1,8 @@ #pragma once #include #include +#include +#include #include namespace dingosdk::https { @@ -13,4 +15,8 @@ struct Download { // that take longer than `timeout_seconds` in total. Download get(std::wstring_view url, const std::filesystem::path& destination, std::uint64_t max_bytes, std::uint32_t timeout_seconds, const wchar_t* agent); +// The body of the same GET, for an answer small enough to hold: nothing when it +// fails, and `result`, when given, says how. +std::optional get_text(std::wstring_view url, std::uint64_t max_bytes, + std::uint32_t timeout_seconds, const wchar_t* agent, Download* result = nullptr); } diff --git a/Engine/Vfs/mod_merge_combine.cpp b/Engine/Vfs/mod_merge_combine.cpp index 2c7160e3..03f29a1a 100644 --- a/Engine/Vfs/mod_merge_combine.cpp +++ b/Engine/Vfs/mod_merge_combine.cpp @@ -8,7 +8,9 @@ #include "Engine/Core/Platform/path_text.h" #include +#include #include +#include #include namespace dingosdk::mods::detail { @@ -32,6 +34,7 @@ struct Asset { fb::BundleAsset asset; fb::BundleFileInfo file; bool base{}; + const std::string* mod{}; // whose copy of the bundle it came in; null for the game's }; // Where one contributor's copy of an asset lives, so conflicting copies can be @@ -68,6 +71,16 @@ std::string asset_key(const fb::BundleAsset& asset) { (asset.kind == fb::AssetKind::chunk ? asset.guid.string() : lower(asset.name)); } +// The game's item lists (items/itemmastercollection, a season's +// 0.30.0_itemcollection), which every cosmetic mod adds its items to. The game +// reads each entry of one at startup without checking it, so an entry naming an +// item that is not loaded is a crash on every launch, not a missing item. +bool item_list_name(std::string_view name) { + const auto text = lower(name); + return text.starts_with("items/") && text.find("collection") != std::string::npos; +} +constexpr std::string_view item_list_type = "delmaritemcollectionasset"; + // Some bundles carry no inline manifest: their region only lists placements, // so their assets cannot be named and the bundle can only be passed through // whole, with the highest-priority copy winning. @@ -90,6 +103,78 @@ struct BundleState { // The base's own sha1 for each asset, so a mod that merely carries an // unchanged copy can be told apart from one that changed it. std::map> baseSha; + // Assets a mod added that a higher-priority mod's different asset of the + // same name then replaced: the key, and the sha1 of the copy that went. + std::vector> displaced; +}; + +// The guids of the EBX a merged bundle ends up holding, to tell whether what a +// list entry names is in it. Read on first use: the assets mods added, which is +// where a mod's entry nearly always points, and the game's own thousands only +// when one of those is not the answer. +class HeldAssets final { +public: + using Decode = std::function(const Contribution&)>; + HeldAssets(const BundleState& state, Decode decode) : state_(state), decode_(std::move(decode)) {} + + [[nodiscard]] bool holds(const fb::Guid& guid) { + if (!added_) { read(false); added_ = true; } + if (guids_.contains(guid)) return true; + if (!shipped_ && !unsure_) { read(true); shipped_ = true; } + // A copy that could not be read may be the very one asked about. + return unsure_ || guids_.contains(guid); + } + + // The asset that took the place of the one with this guid, when it was a + // mod's addition another mod's of the same name replaced; null otherwise. + [[nodiscard]] const Asset* replacement(const fb::Guid& guid) { + if (!displacedRead_) { + displacedRead_ = true; + for (const auto& [key, sha1] : state_.displaced) { + const auto copies = state_.history.find(key); + const auto at = state_.seen.find(key); + if (copies == state_.history.end() || at == state_.seen.end()) continue; + for (const auto& copy : copies->second) { + if (copy.sha1 != sha1) continue; + try { + displaced_.emplace(fb::ebx::read_file_guid(decode_(copy)), &state_.assets[at->second]); + } catch (const std::exception&) {} + break; + } + } + } + const auto found = displaced_.find(guid); + return found == displaced_.end() ? nullptr : found->second; + } + +private: + void read(bool shipped) { + for (const auto& asset : state_.assets) { + if (asset.asset.kind != fb::AssetKind::ebx) continue; + const auto key = asset_key(asset.asset); + if (state_.baseSha.contains(key) != shipped) continue; + // The copy the bundle keeps. One combined from several mods' is none + // of theirs, and has the guid of the game's it was combined onto. + const Contribution* kept{}; + if (const auto copies = state_.history.find(key); copies != state_.history.end()) + for (const auto& copy : copies->second) { + if (copy.sha1 == asset.asset.sha1) { kept = © break; } + if (copy.base) kept = © + } + try { + if (!kept) throw std::runtime_error("no copy to read"); + guids_.insert(fb::ebx::read_file_guid(decode_(*kept))); + } catch (const std::exception&) { + unsure_ = true; + } + } + } + + const BundleState& state_; + Decode decode_; + std::set guids_; + std::map displaced_; + bool added_{}, shipped_{}, unsure_{}, displacedRead_{}; }; // Rebuilds the manifest and the matching placement list in the order the @@ -149,6 +234,8 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, std::map archiveFaults; // Mods whose added assets went into a copy here: their added chunks come too. std::set addedFrom; + // The mods absorbed so far, so each asset can say whose copy it is. + std::set owners; const auto absorb = [&](const fb::TocBundle& bundle, const ArchivePlacement* placement, bool isBase, const fs::path& root) { @@ -431,9 +518,13 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, if (!region.files.empty()) state.manifestChunk = region.files.front().location.installChunk; if (fresh || state.metadata.empty()) state.metadata = manifest.chunkMetadata; const std::size_t firstFile = casBacked ? 1 : 0; + const auto* owner = isBase ? nullptr : &*owners.insert(modName).first; + // By the mod whose added assets this copy's replace: how many, and one of them. + std::map> replaced; for (std::size_t position = 0; position < flat.size(); ++position) { auto& entry = flat[position]; entry.base = isBase; + entry.mod = owner; auto id = asset_key(entry.asset); // A copy rewritten above (another mod's change, renumbered collision) // or added from another mod is not this mod's edit and does not lie in @@ -479,9 +570,24 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, previous.asset.sha1 != entry.asset.sha1) report.notes.push_back("contested chunk " + entry.asset.name + ": kept the highest-priority copy"); + // Two mods each adding a different asset under one name: a bundle + // holds one asset per name, so the lower-priority mod's is gone, + // and whatever of that mod's still names it by guid finds nothing + // (an item list entry is dropped for that further down). + if (entry.asset.kind != fb::AssetKind::chunk && !rewritten && previous.mod && + previous.mod != owner && previous.asset.sha1 != entry.asset.sha1 && + !state.baseSha.contains(at->first)) { + auto& [count, example] = replaced[*previous.mod]; + if (!count++) example = entry.asset.name; + state.displaced.emplace_back(at->first, previous.asset.sha1); + } state.assets[at->second] = std::move(entry); } } + for (const auto& [other, clash] : replaced) + report.notes.push_back(other + ": " + bundle.name + ": " + std::to_string(clash.first) + + " added asset(s) share a name with ones " + modName + " adds, e.g. " + clash.second + + "; the merged bundle keeps " + modName + "'s"); }; if (base) for (const auto& bundle : base->bundles) absorb(bundle, nullptr, true, baseRoot); @@ -497,6 +603,15 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, if (state.opaque) { region = fb::write_bundle_region(state.files, {}); } else { + // A placement that is not a patch one still lives in the + // base install, whichever mod's copy referenced it. + const auto decode = [&](const Contribution& from) { + return fb::decode_cas( + store.read(from.file.location.patch ? from.root : baseRoot, + from.file.location, from.file.offset, from.file.size), + {gameRoot}); + }; + HeldAssets held(state, decode); // Two mods rewriting one EBX asset is the case layering can never // answer: whichever copy wins, the other mod's additions are gone. // Combining the documents is the only shape that keeps both. @@ -522,16 +637,11 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, for (const auto& contribution : found->second) if (!contribution.base && contribution.sha1 != baseCopy->sha1) edits.push_back(&contribution); - if (edits.size() < 2) continue; + // An item list is looked at even when one mod alone changed it: + // what its entries name has to be in the bundle either way. + const auto named = entry.asset.kind == fb::AssetKind::ebx && item_list_name(entry.asset.name); + if (edits.size() < 2 && !(named && edits.size() == 1)) continue; try { - // A placement that is not a patch one still lives in the - // base install, whichever mod's copy referenced it. - const auto decode = [&](const Contribution& from) { - return fb::decode_cas( - store.read(from.file.location.patch ? from.root : baseRoot, - from.file.location, from.file.offset, from.file.size), - {gameRoot}); - }; const auto baseBytes = decode(*baseCopy); std::vector> editBytes; for (const auto* edit : edits) editBytes.push_back(decode(*edit)); @@ -581,6 +691,8 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, what += ", " + std::to_string(table.conflicts) + " disagreed"; } else { const auto baseDocument = fb::ebx::read_document(baseBytes); + const auto itemList = lower(baseDocument.rootType) == item_list_type; + if (edits.size() < 2 && !itemList) continue; // A material grid is a square interaction matrix whose rows are // addressed by slot numbers baked into each map's collision. // Two mods that each appended surfaces both claim the same @@ -598,12 +710,41 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, editDocuments.push_back(fb::ebx::read_document(bytes)); std::vector pointers; for (const auto& document : editDocuments) pointers.push_back(&document); + // An item a mod lists is an asset that mod adds to this bundle. + // When it is not here (another mod added a different asset + // under its name, or the mod was built on top of a mod that + // is not installed) the entry goes: the mod loses that item, + // which it had already lost, and the game still starts. + const fb::ebx::CarryImport inBundle = [&](const fb::ebx::ImportReference& item) { + // What the game's own list names is the game's to answer for. + return std::ranges::any_of(baseDocument.imports, [&](const fb::ebx::ImportReference& known) { + return known.fileGuid == item.fileGuid; }) || + held.holds(item.fileGuid); + }; fb::ebx::MergeSummary summary; - auto combined = fb::ebx::merge_documents(baseDocument, pointers, &summary); - if (!summary.instances && !summary.arrayEntries) continue; + auto combined = fb::ebx::merge_documents(baseDocument, pointers, &summary, + itemList ? inBundle : fb::ebx::CarryImport{}); + // One mod's copy with nothing to leave out stays as that mod wrote it. + if (summary.dropped.empty() && + (edits.size() < 2 || (!summary.instances && !summary.arrayEntries))) continue; rebuilt = fb::ebx::write_document(combined); what = std::to_string(summary.instances) + " instance(s), " + std::to_string(summary.arrayEntries) + " list entries"; + std::map> left; // by edit + for (const auto& gone : summary.dropped) left[gone.edit].push_back(gone.target.fileGuid); + for (const auto& [index, guids] : left) { + // Name the mod whose asset took the item's name, when that is why. + const Asset* taken{}; + for (const auto& guid : guids) + if ((taken = held.replacement(guid))) break; + report.notes.push_back(path_utf8(edits[index]->root.filename()) + ": " + entry.asset.name + + ": " + std::to_string(guids.size()) + " item(s) left out of the list because the merged " + "bundle does not hold them, e.g. " + + (taken ? taken->asset.name + ", replaced by " + (taken->mod ? *taken->mod : "another mod") + + "'s asset of the same name" + : "the asset with guid " + guids.front().string() + + ", which no enabled mod adds to the bundle")); + } } const auto placement = store.write(state.manifestChunk, manifestArchive, fb::encode_cas(rebuilt, {gameRoot})); @@ -611,6 +752,7 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, entry.asset.sha1 = sha1_of(rebuilt); entry.asset.originalSize = rebuilt.size(); used.emplace(placement.location.installChunk, placement.location.archive); + if (edits.size() < 2) continue; report.notes.push_back(entry.asset.name + ": combined " + std::to_string(edits.size()) + " edits (" + what + ")"); ++report.mergedAssets; diff --git a/Extension/Customization/developer_board.cpp b/Extension/Customization/developer_board.cpp index 0df1cbf1..6e6d6c62 100644 --- a/Extension/Customization/developer_board.cpp +++ b/Extension/Customization/developer_board.cpp @@ -46,9 +46,10 @@ bool publish_materials(std::uintptr_t base, std::uintptr_t item) noexcept { } // namespace void update_developer_board(std::uintptr_t base, std::uintptr_t board, std::uint64_t steam_id, std::uint64_t generation, DeveloperBoardState &state) noexcept { - const bool developer = multiplayer::reskate_developer(steam_id); + const auto animation = developer_board_detail::animation_for(steam_id); + const bool listed = animation != developer_board_detail::Animation::none; if (!board) { state = {}; return; } - if (!developer && !state.count) return; + if (!listed && !state.count) return; try { auto read = [](std::uintptr_t address, void *out, std::size_t size) { return memory::peek_bytes(address, out, size); }; std::array publisher{}; @@ -64,14 +65,14 @@ void update_developer_board(std::uintptr_t base, std::uintptr_t board, std::uint bool published = true; auto write = [base](const auto &binding, const auto &color) { return write_color(base, binding, color); }; auto publish = [base, &published](std::uintptr_t item) { if (!publish_materials(base, item)) published = false; }; - developer_board_detail::animate(state, live, board, generation, developer, GetTickCount64(), write, publish); + developer_board_detail::animate(state, live, board, generation, animation, GetTickCount64(), write, publish); const auto wanted = static_cast(std::count_if(live.bindings.begin(), live.bindings.begin() + live.count, [](const auto &binding) { return binding.eligible; })); if (!published) report_board(board, steam_id, "native material publication failed", logging::Level::warning); - else if (developer && !live.pending && state.count < wanted) + else if (listed && !live.pending && state.count < wanted) report_board(board, steam_id, "waiting for the remaining board color overrides", logging::Level::warning); - else if (developer && state.count) - report_board(board, steam_id, "RGB active on the owned pink board cosmetics", logging::Level::info); + else if (listed && state.count) + report_board(board, steam_id, "animation active on the owned pink board cosmetics", logging::Level::info); } catch (const std::exception &error) { report_board(board, steam_id, error.what(), logging::Level::warning); } catch (...) {} @@ -81,8 +82,8 @@ void tick_local_developer_board(std::uintptr_t base, std::uintptr_t client, bool if (!ready) return; try { const auto social = multiplayer::steam_social_snapshot(); - const auto id = social ? social->local.id : 0; - if (!multiplayer::reskate_developer(id) && !state.count) return; + const auto id = social && multiplayer::own_marks_shown() ? social->local.id : 0; + if (developer_board_detail::animation_for(id) == developer_board_detail::Animation::none && !state.count) return; const auto local = multiplayer::capture_local(base, client, false); if (!local.ready) return; auto read = [](std::uintptr_t address, void *out, std::size_t size) { return memory::peek_bytes(address, out, size); }; diff --git a/Extension/Customization/developer_board_material.h b/Extension/Customization/developer_board_material.h index 1b432b9c..bda9d0ae 100644 --- a/Extension/Customization/developer_board_material.h +++ b/Extension/Customization/developer_board_material.h @@ -6,7 +6,10 @@ namespace dingosdk { namespace developer_board_detail { using developer_hoodie_detail::Color; using developer_hoodie_detail::material_hash; -using developer_hoodie_detail::rainbow; +using developer_hoodie_detail::Animation; +using developer_hoodie_detail::animation_for; +using developer_hoodie_detail::item_color; +using developer_hoodie_detail::settle_publishes; inline constexpr std::uint64_t graphic_color = 0x873f48af36e50d59ULL; inline constexpr std::uint64_t base_color = 0x8c46cd354b18a9e2ULL; inline constexpr Color shader_default{.5f, .5f, .5f}; @@ -58,6 +61,7 @@ struct Binding { struct SavedColor { Binding binding; Color original{}, last{}; + std::uint8_t settling{}; // publishes still owed after going back to `original` }; inline constexpr std::size_t max_bindings = targets.size() * 4; struct Materials { @@ -179,13 +183,13 @@ namespace developer_board_detail { // Never clear a NativeValue: the renderer still copies the key's full payload. template void animate(DeveloperBoardState &state, const Materials &live, std::uintptr_t entity, std::uint64_t generation, - bool developer, std::uint64_t milliseconds, Write &write, Publish &publish) { + Animation animation, std::uint64_t milliseconds, Write &write, Publish &publish) { if (state.entity != entity || state.generation != generation || state.component != live.component || state.appearance != live.appearance) state = {}; if (live.pending) return; DeveloperBoardState next; next.entity = entity; next.generation = generation; next.component = live.component; next.appearance = live.appearance; - const auto color = rainbow(milliseconds); + const auto color = item_color(animation, milliseconds); std::array changed_items{}; std::size_t changed{}; for (std::size_t i = 0; i < live.count; ++i) { @@ -194,14 +198,18 @@ void animate(DeveloperBoardState &state, const Materials &live, std::uintptr_t e for (std::size_t j = 0; j < state.count; ++j) if (same_binding(binding, state.colors[j].binding)) { saved = &state.colors[j]; break; } bool wrote{}; - if (developer && binding.eligible) { + if (animation != Animation::none && binding.eligible) { const auto original = saved && binding.color == saved->last ? saved->original : binding.color; if ((binding.node && binding.color == color) || (wrote = write(binding, color))) next.colors[next.count++] = {binding, original, color}; else if (saved) next.colors[next.count++] = *saved; - } else if (saved && binding.color == saved->last && binding.color != saved->original) { - wrote = write(binding, saved->original); + } else if (saved && (binding.color == saved->last || binding.color == saved->original)) { + // Back to the part's own color, which takes a few publishes to show (settle_publishes): + // `wrote` has the part published for each of them. + wrote = binding.color == saved->original || write(binding, saved->original); + const std::uint8_t left = saved->settling ? saved->settling - 1 : settle_publishes; if (!wrote) next.colors[next.count++] = *saved; + else if (left) next.colors[next.count++] = {binding, saved->original, saved->original, left}; } if (wrote && std::find(changed_items.begin(), changed_items.begin() + changed, binding.item) == changed_items.begin() + changed) changed_items[changed++] = binding.item; diff --git a/Extension/Customization/developer_hoodie.cpp b/Extension/Customization/developer_hoodie.cpp index 0cb4dc84..e07f6b78 100644 --- a/Extension/Customization/developer_hoodie.cpp +++ b/Extension/Customization/developer_hoodie.cpp @@ -44,9 +44,10 @@ bool publish_materials(std::uintptr_t base, std::uintptr_t item) noexcept { } // namespace void update_developer_hoodie(std::uintptr_t base, std::uintptr_t entity, std::uint64_t steam_id, std::uint64_t generation, DeveloperHoodieState &state) noexcept { - const bool developer = multiplayer::reskate_developer(steam_id); + const auto animation = developer_hoodie_detail::animation_for(steam_id); + const bool listed = animation != developer_hoodie_detail::Animation::none; if (!entity) { state = {}; return; } - if (!developer && !state.count) return; + if (!listed && !state.count) return; try { auto read = [](std::uintptr_t address, void *out, std::size_t size) { return memory::peek_bytes(address, out, size); }; std::array prefix{}; @@ -58,10 +59,11 @@ void update_developer_hoodie(std::uintptr_t base, std::uintptr_t entity, std::ui const auto live = developer_hoodie_detail::materials(read, base, entity); bool published = true; auto publish = [base, &published](std::uintptr_t item) { published = publish_materials(base, item); }; - developer_hoodie_detail::animate(state, live, entity, generation, developer, GetTickCount64(), write_color, publish); + developer_hoodie_detail::animate(state, live, entity, generation, animation, GetTickCount64(), write_color, publish); if (!published) report_hoodie(entity, steam_id, "native material publication failed", logging::Level::warning); - else if (state.count) report_hoodie(entity, steam_id, "RGB active on the owned hoodie material", logging::Level::info); - else if (developer && live.eligible) + else if (listed && state.count) + report_hoodie(entity, steam_id, "animation active on the owned hoodie material", logging::Level::info); + else if (listed && live.eligible) report_hoodie(entity, steam_id, "waiting for the assigned top slot's color parameters", logging::Level::warning); } catch (const std::exception &error) { report_hoodie(entity, steam_id, error.what(), logging::Level::warning); @@ -77,8 +79,8 @@ void tick_local_developer_hoodie(std::uintptr_t base, std::uintptr_t client, boo if (!ready) return; try { const auto social = multiplayer::steam_social_snapshot(); - const auto id = social ? social->local.id : 0; - if (!multiplayer::reskate_developer(id) && !state.count) return; + const auto id = social && multiplayer::own_marks_shown() ? social->local.id : 0; + if (developer_hoodie_detail::animation_for(id) == developer_hoodie_detail::Animation::none && !state.count) return; const auto local = multiplayer::capture_local(base, client, false); if (local.ready) update_developer_hoodie(base, local.entity, id, local.context, state); else report_hoodie(client, id, local.detail, logging::Level::warning); diff --git a/Extension/Customization/developer_hoodie_material.h b/Extension/Customization/developer_hoodie_material.h index 1c3d2b24..a649cd8b 100644 --- a/Extension/Customization/developer_hoodie_material.h +++ b/Extension/Customization/developer_hoodie_material.h @@ -14,6 +14,8 @@ inline constexpr std::string_view asset = "Own_TopShirt_Gen_HoodieRelaxed_00001" inline constexpr std::string_view top_slot = "Characters/MainCharacters/Generic/CAS/Common/Slots/CAS_Top_Slot"; // Serialized ParamDbKey identities from this hoodie's AppearanceShaderExpressionPreset. // Only the six fabric color regions: textures, stitches and other clothing stay intact. +// As seen in game: the first is the whole body, sleeves and hood; the fourth the cuffs and +// hem; the fifth the drawstrings and hood lining. The other three do not show. inline constexpr std::array color_keys{ 0x8c46cd354b18a9e2ULL, 0x1567b5742f4ec0b2ULL, 0x1564a942cb33c53bULL, 0xf526305270d94cb3ULL, 0x99879bf202d1960aULL, 0x998450425dbd9044ULL}; @@ -56,6 +58,33 @@ inline Color rainbow(std::uint64_t milliseconds) noexcept { default: return {high, low, falling}; } } +// What a listed player's hoodie and board do: a developer's cycle the rainbow, a content +// creator's go 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. +enum class Animation : std::uint8_t { none, rainbow, red, gold }; +inline Animation animation_for(std::uint64_t steam_id) noexcept { + using multiplayer::IdentityList; + switch (multiplayer::identity_mark(steam_id).value_or(IdentityList::count)) { + case IdentityList::developer: return Animation::rainbow; + case IdentityList::content_creator: return Animation::red; + case IdentityList::homie: return Animation::gold; + default: return Animation::none; + } +} +inline Color item_color(Animation animation, std::uint64_t milliseconds) noexcept { + if (animation == Animation::rainbow) return rainbow(milliseconds); + // One colour getting lighter and darker is hard to see, so each goes on into the colour + // next to it: a deep shade, a bright one, then a reddish pink or a yellow, and back. + 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}}}; + const auto &stops = animation == Animation::red ? red : gold; + // 0 to 2 and back, every three seconds. + const auto along = 1.f - std::cos(static_cast(milliseconds % 3000) * (6.2831853f / 3000.f)); + const auto &from = stops[along < 1.f ? 0 : 1], &to = stops[along < 1.f ? 1 : 2]; + const auto amount = along < 1.f ? along : along - 1.f; + return {from[0] + (to[0] - from[0]) * amount, from[1] + (to[1] - from[1]) * amount, + from[2] + (to[2] - from[2]) * amount}; +} // All discovery is bounded and follows the actor's own component/controller/material // backlinks. Never edit the shared AppearanceShaderExpressionPreset or recipe. template Materials materials(Read &read, std::uintptr_t base, std::uintptr_t entity) { @@ -142,19 +171,27 @@ struct DeveloperHoodieState { std::uint64_t generation{}; std::array colors{}; std::size_t count{}; + std::uint8_t settling{}; // publishes still owed after the animation went off (settle_publishes) }; namespace developer_hoodie_detail { +// The renderer shows a color one publish late. While the animation runs the next tick's +// publish brings it; once it is off, the item's own colors need publishing again after the +// write that brought them back, or the item keeps the last animated color on screen (seen +// in game, 2026-10-04). The saved colors are kept for these extra publishes. +inline constexpr std::uint8_t settle_publishes = 3; // Writes are only to freshly rediscovered bindings. Restoration additionally // requires the current color to still be ours; a new native preset takes precedence. template void animate(DeveloperHoodieState &state, const Materials &live, std::uintptr_t entity, std::uint64_t generation, - bool developer, std::uint64_t milliseconds, Write &write, Publish &publish) { + Animation animation, std::uint64_t milliseconds, Write &write, Publish &publish) { if (state.entity != entity || state.generation != generation || state.component != live.component || state.appearance != live.appearance || state.item != live.item) state = {}; - const bool enabled = developer && live.eligible; - const auto color = rainbow(milliseconds); + const bool enabled = animation != Animation::none && live.eligible; + const auto color = item_color(animation, milliseconds); + const std::uint8_t settling = enabled ? 0 : state.settling ? state.settling - 1 : settle_publishes; DeveloperHoodieState next; - if (enabled) { + next.settling = settling; + if (enabled || settling) { next.entity = entity; next.generation = generation; next.component = live.component; next.appearance = live.appearance; next.item = live.item; } @@ -170,10 +207,13 @@ void animate(DeveloperHoodieState &state, const Materials &live, std::uintptr_t next.colors[next.count++] = {binding, original, color}; changed = changed || binding.color != color; } - } else if (saved && binding.color == saved->last && binding.color != saved->original) { - changed = write(binding, saved->original) || changed; + } else if (saved && (binding.color == saved->last || binding.color == saved->original)) { + if (binding.color != saved->original) write(binding, saved->original); + changed = true; + if (settling) next.colors[next.count++] = {binding, saved->original, saved->original}; } } + if (!next.count) next = {}; state = next; if (changed) publish(live.item); } diff --git a/Extension/Multiplayer/Hud/custom_nametags.h b/Extension/Multiplayer/Hud/custom_nametags.h index 16bc9b8e..cfc5920b 100644 --- a/Extension/Multiplayer/Hud/custom_nametags.h +++ b/Extension/Multiplayer/Hud/custom_nametags.h @@ -19,10 +19,15 @@ struct NametagPlayer { }; // Role colours; friends are the viewer's own Steam friends. inline constexpr std::uint32_t nametag_white = 0xffffffffU, nametag_developer = 0xffff78b4U, // purple - nametag_admin = 0xff3dc8ffU, // gold + nametag_creator = 0xff6464ffU, // red + nametag_homie = 0xff7ae2ffU, // gold + nametag_admin = 0xffc674ffU, // pink nametag_host = 0xffffc85eU, // blue nametag_friend = 0xff8ae07bU; // green -inline constexpr std::uint32_t nametag_developer_start = 0xffff206eU; // #6E20FF, IM_COL32 layout +// Developers, content creators and homies shimmer from these to their role colour. +inline constexpr std::uint32_t nametag_developer_start = 0xffff206eU, // #6E20FF, IM_COL32 layout + nametag_creator_start = 0xff2e10c8U, // #C8102E + nametag_homie_start = 0xff008ae0U; // #E08A00 // 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 d4c2a038..5fa607f8 100644 --- a/Extension/Multiplayer/Net/protocol.cpp +++ b/Extension/Multiplayer/Net/protocol.cpp @@ -237,7 +237,7 @@ bool valid_appearance(const Appearance &a) noexcept { if (a.skater.key != skater_recipe_key || a.skater.version != 2 || a.board.key != board_recipe_key || a.board.version != 1) return false; - std::size_t size = header_size + 12; + std::size_t size = header_size + 13; for (const auto *r : {&a.skater, &a.board}) { if (r->scalars.size() > max_cosmetic_scalars || r->items.empty() || r->items.size() > max_cosmetic_slots) @@ -445,6 +445,7 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 w.integer(p.appearance.card.background, 4); w.integer(p.appearance.card.emblem, 4); w.integer(p.appearance.card.title, 4); + w.integer(p.appearance.unmarked ? 1 : 0, 1); } else if (p.kind == PacketKind::audio) { w.integer(p.audio.size(), 2); AudioState previous; @@ -674,7 +675,9 @@ std::optional decode(std::span bytes) noexcept { p.appearance.card.background = static_cast(r.integer(4)); p.appearance.card.emblem = static_cast(r.integer(4)); p.appearance.card.title = static_cast(r.integer(4)); - if (!p.map || !valid_appearance(p.appearance)) + const auto flags = r.integer(1); + p.appearance.unmarked = flags == 1; + if (flags > 1 || !p.map || !valid_appearance(p.appearance)) return {}; } else if (p.kind == PacketKind::audio) { const auto count = r.integer(2); diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index 441cce08..a2fdb89f 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 = 39; +constexpr std::uint16_t protocol_version = 40; 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; diff --git a/Extension/Multiplayer/Remote/cosmetics.h b/Extension/Multiplayer/Remote/cosmetics.h index 3d5fff17..d49c3e44 100644 --- a/Extension/Multiplayer/Remote/cosmetics.h +++ b/Extension/Multiplayer/Remote/cosmetics.h @@ -31,6 +31,9 @@ struct PlayerCard { struct Appearance { CosmeticRecipe skater, board; PlayerCard card; + // The player has turned off the marks the ReSkate backend gives them (developer_identity.h): + // nobody shows their tag or animates their hoodie and board. + bool unmarked{}; bool operator==(const Appearance &) const = default; }; // Stand-ins for a peer's cosmetic that this PC does not have (a player's diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 71e5b68d..0ba82454 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -257,6 +257,7 @@ void publish_party(Session &s) { s.custom_nametags = profile_runtime::local_preference("CustomNametags").value_or(true); s.chat_visible = profile_runtime::local_preference("ChatVisible").value_or(true); s.chat_filter = profile_runtime::local_preference("ChatFilter").value_or(true); + show_own_marks(profile_runtime::local_preference("IdentityMarks").value_or(true)); apply_nametags(s); } // The party's limit: parties players form hold up to eight (the game's Party panel rows), @@ -425,7 +426,9 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui } }; // The developer hoodie and board mark an identity: only one Steam vouches for (steam_vouched). - const auto developer_id = [&](const Peer &p) { return steam_vouched(s, p) ? p.member.id : std::uint64_t{}; }; + const auto developer_id = [&](const Peer &p) { + return steam_vouched(s, p) && shows_marks(p) ? p.member.id : std::uint64_t{}; + }; each_active_peer(s, [&](Peer &p) { // A dedicated server has no skater to show. if (!p.member.id || (dedicated_host(s) && p.member.id == s.host_id)) diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index ff18d237..bf0c1f3b 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -324,6 +324,15 @@ std::string edit_nametag_style(Session &s, std::string_view argument) { profile_runtime::set_local_preference("CustomNametags", s.custom_nametags); return s.custom_nametags ? "ReSkate nametags: names, distances and dots." : "The game's own nametags."; } +// The Special page: a player on one of the backend's lists going without their tag and items. +// Their next appearance packet tells everyone (session_send.cpp). +std::string edit_marks(Session &, std::string_view argument) { + const auto shown = parse_switch(argument, own_marks_shown()); + if (!shown) return "Use on, off, or toggle for your tag and items."; + show_own_marks(*shown); + profile_runtime::set_local_preference("IdentityMarks", *shown); + return *shown ? "Your tag and items show." : "Your tag and items are hidden, for you and everyone you skate with."; +} // Hidden chat still receives lines, so showing it again brings back the conversation. std::string edit_chat_visible(Session &s, std::string_view argument) { const auto visible = parse_switch(argument, s.chat_visible); @@ -438,10 +447,12 @@ std::string send_admin(Session &s, std::string text) { } } // namespace bool queue_command(std::string_view action, std::string_view argument, std::string_view password) { - if (launcher::offline_mode()) return false; + // A player's own marks are theirs offline as well, where their hoodie and board still animate. + if (launcher::offline_mode() && action != "marks") 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 != "nametags" && action != "nametag-style" && action != "chat-visible" && action != "chat-filter" && + action != "marks" && action != "voice" && action != "voice-mute" && action != "voice-volume" && action != "voice-allow" && action != "voice-range" && action != "chat" && action != "ban" && action != "unban" && action != "world-layer-sync" && action != "noclip-allow" && action != "nobail-allow" && action != "boosts-allow" && @@ -465,7 +476,8 @@ bool queue_command(std::string_view action, std::string_view argument, std::stri return true; } std::string command(std::string_view action, std::string_view argument, std::string_view password) { - if (launcher::offline_mode()) return "Multiplayer is unavailable in offline mode. Start Steam and relaunch ReSkate."; + if (launcher::offline_mode() && action != "marks") + return "Multiplayer is unavailable in offline mode. Start Steam and relaunch ReSkate."; const bool configured_host = action == "host-config"; if (configured_host) action = "host"; PrivateRequest input; @@ -620,6 +632,7 @@ std::string command(std::string_view action, std::string_view argument, std::str {"world-layer-sync", edit_world_layer_sync}, {"distances", edit_distances}, {"nametags", edit_nametags}, {"nametag-style", edit_nametag_style}, + {"marks", edit_marks}, {"chat-visible", edit_chat_visible}, {"chat-filter", edit_chat_filter}}; for (const auto &[name, edit] : settings) if (action == name) { @@ -705,6 +718,8 @@ std::string command(std::string_view action, std::string_view argument, std::str publish(s); return s.status; }; + // Joining is for the host or the server to refuse (a server may opt out of the bans). + if (action == "host" && reskate_banned(s.transport.status().local_id)) return refuse(std::string(banned_notice)); if (action == "host") { const auto space = argument.find(' '); if (space != std::string_view::npos) { diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 4b8d0acd..db57058c 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -310,6 +310,12 @@ inline bool dedicated_host(const Session &s) { return s.mode == Mode::join && ga inline bool steam_vouched(const Session &s, const Peer &peer) { return s.mode != Mode::join || peer.member.id == s.host_id || peer.direct_ready; } +// Whether a player shows the marks the backend gives them: not until their appearance has +// arrived, and not when it says they turned them off (Appearance::unmarked). +inline bool shows_marks(const Peer &peer) { + const auto &look = peer.appearance.value(); + return look && !look->unmarked; +} Peer *find_peer(Session &s, std::uint64_t id); unsigned player_count(const Session &s); void reset_peer(Session &s, std::size_t slot); @@ -327,7 +333,9 @@ bool is_banned(Session &s, std::uint64_t id); // Re-reads friend_ids when the Steam social snapshot has changed. void refresh_friends(Session &s); void add_chat(Session &s, std::uint64_t sender, std::string name, std::string text, bool local = false); -// A player's role colour and badge ("Dev", "Admin", "Host", "Friend" or none), shown in chat +// 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", "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); // Sends one line from this player; returns why not when it cannot. diff --git a/Extension/Multiplayer/Session/session_receive.cpp b/Extension/Multiplayer/Session/session_receive.cpp index 379363eb..a9d69f3b 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -265,6 +265,17 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { stop(s, s.transport.status().detail); return; } + // The backend's bans (reskate_banned) arrive while a session runs: a banned player stops + // hosting theirs, and nobody stays with a banned host. A banned guest is for the host or + // the server to turn away (below, and Host::tick), which a server may choose not to. + if (s.mode == Mode::host && reskate_banned(s.transport.status().local_id)) { + stop(s, std::string(banned_notice)); + return; + } + if (s.mode == Mode::join && reskate_banned(s.host_id)) { + stop(s, "This host is banned from ReSkate multiplayer."); + return; + } for (const auto &link : links) { if (s.mode == Mode::host && s.banned.contains(link.id)) { s.transport.disconnect(link.id, "You were kicked from this session."); @@ -274,6 +285,10 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { s.transport.disconnect(link.id, "You are banned from this host's lobbies."); continue; } + if (s.mode == Mode::host && reskate_banned(link.id)) { + s.transport.disconnect(link.id, banned_notice.data()); + continue; + } auto *p = find_peer(s, link.id); if (!p && s.mode == Mode::host && s.join_backoff.waiting(link.id, now)) { s.transport.disconnect(link.id, "Too many failed attempts to join. Wait a little and try again."); diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index 9249f441..c37e961d 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -595,7 +595,10 @@ void send_local(Session &s, const NativeFrame &local, std::uint64_t now, std::ui }; if (now - s.last_cosmetic_capture >= 500000) { s.last_cosmetic_capture = now; - const auto appearance = capture_cosmetics(s.base, local, s.cosmetic_capture_status); + auto appearance = capture_cosmetics(s.base, local, s.cosmetic_capture_status); + // The player's choice to go without their marks travels with their outfit, so a change + // is sent like one and reaches players who join later. + if (appearance) appearance->unmarked = !own_marks_shown(); if (appearance && (!s.sent_appearance || *appearance != *s.sent_appearance)) { auto p = packet(s, PacketKind::cosmetics, now); p.appearance = *appearance; diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index 8ac07ad1..11e5538e 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -172,6 +172,9 @@ void publish(Session &s, const NativeFrame *local) { static_cast(s.host_preferences.capacity), s.host_preferences.tps, s.host_preferences.lobby_name}; view.nametags = s.nametags; view.custom_nametags = s.custom_nametags; + if (const auto social = steam_social_snapshot()) + if (const auto mark = identity_mark(social->local.id)) view.identity_tag = mark_role(*mark).second; + view.identity_marks = own_marks_shown(); load_host_preferences(s); view.voice_range = s.mode == Mode::host ? s.voice_range : dedicated_host(s) ? s.roster_voice_range : s.host_preferences.voice_range; @@ -501,6 +504,13 @@ void publish_chat(Session &s) { std::lock_guard lock(s.mutex); s.chat_view = std::move(view); } +std::pair mark_role(IdentityList list) { + switch (list) { + case IdentityList::developer: return {nametag_developer, "Dev"}; + case IdentityList::content_creator: return {nametag_creator, "Creator"}; + default: return {nametag_homie, "Homie"}; + } +} // The colour and tag a player gets, in chat and on their nametag. std::pair player_role(Session &s, std::uint64_t sender, bool local) { if (!sender) return {}; @@ -508,7 +518,10 @@ std::pair player_role(Session &s, std::uint64_t send if (dedicated && sender == s.host_id) return {nametag_admin, {}}; // the server itself const auto *peer = local ? nullptr : find_peer(s, sender); const bool vouched = local || (peer && steam_vouched(s, *peer)); - if (vouched && reskate_developer(sender)) return {nametag_developer, "Dev"}; + // Who the backend says a player is comes before what they are in this lobby, unless they + // have turned their marks off (the Special page), which their appearance tells everyone. + if (vouched && (local ? own_marks_shown() : shows_marks(*peer))) + if (const auto mark = identity_mark(sender)) return mark_role(*mark); if (dedicated && (local ? s.server_admin : peer && peer->member.admin)) return {nametag_admin, "Admin"}; if (!dedicated && (local ? s.mode == Mode::host : sender == s.host_id)) return {nametag_host, "Host"}; if (!local && vouched) { diff --git a/Extension/Multiplayer/Test/developer_identity_tests.cpp b/Extension/Multiplayer/Test/developer_identity_tests.cpp new file mode 100644 index 00000000..c6df8cdf --- /dev/null +++ b/Extension/Multiplayer/Test/developer_identity_tests.cpp @@ -0,0 +1,219 @@ +#include "Extension/Customization/developer_board_material.h" +#include "Extension/Customization/developer_hoodie_material.h" +#include "Extension/Multiplayer/developer_identity.h" +#include +#include +#include +#include +#include +#include + +using namespace dingosdk::multiplayer; +namespace { +void check(bool ok, const std::string &message) { + if (ok) return; + std::cerr << message << '\n'; + std::exit(1); +} +bool rejected(std::string_view json) { + try { + parse_identity_lists(json); + } catch (const std::exception &) { + return true; + } + return false; +} +constexpr std::uint64_t dev = 76561198000000001ULL, other_dev = 76561198000000002ULL, homie = 76561198000000003ULL, + creator = 76561198000000004ULL, stranger = 76561198000000005ULL; +using L = IdentityList; + +void parsing() { + // The answer as the backend sends it; a list comes back sorted, each player once. + const auto lists = parse_identity_lists( + R"({"categories":{"dev":["76561198000000002","76561198000000001","76561198000000002"],)" + R"("homie":["76561198000000003"],"content_creator":["76561198000000004"]}})"); + check(lists[0] == std::vector{dev, other_dev} && lists[1] == std::vector{homie} && lists[2] == std::vector{creator} && + lists[3].empty(), + "The backend's answer was not read as its three lists, with nobody banned"); + // The ban list comes beside the categories; a player can be in both. + const auto bans = parse_identity_lists( + R"({"categories":{"dev":["76561198000000001"]},"banned":["76561198000000005","76561198000000001","76561198000000005"]})"); + check(bans[0] == std::vector{dev} && bans[3] == std::vector{dev, stranger}, "The ban list was not read"); + // A newer backend may list more categories and fields, and an older one fewer. + const auto partial = parse_identity_lists( + 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 ends of the range players' SteamID64s come from. + check(!rejected(R"({"categories":{"dev":["76561197960265729","76561202255233023"]}})"), "A valid SteamID64 was refused"); + + for (const std::string_view wrong : { + "", "go away\n", "Just a moment...", "[]", "{}", R"({"categories":[]})", + R"({"categories":{"dev":"76561198000000001"}})", // not a list + R"({"categories":{"dev":[76561198000000001]}})", // a number, which JSON readers round + R"({"categories":{"dev":["76561198000000001 "]}})", R"({"categories":{"dev":["+76561198000000001"]}})", + R"({"categories":{"dev":["zee_x64"]}})", R"({"categories":{"dev":[""]}})", + R"({"categories":{"dev":["76561197960265728"]}})", // account 0 + R"({"categories":{"dev":["76561202255233024"]}})", // past the last account + R"({"categories":{"dev":["103582791429521408"]}})", // a Steam group + R"({"categories":{"homie":["1"]}})", R"({"categories":{},"banned":"76561198000000005"})", + R"({"categories":{},"banned":[76561198000000005]})", R"({"categories":{},"banned":["everyone"]})"}) + check(rejected(wrong), "An answer that is not the lists was accepted: " + std::string(wrong)); +} + +void lookup() { + check(!reskate_developer(dev) && !identity_listed(homie, L::homie), "Someone was listed before any lists arrived"); + check(publish_identity_lists({{{dev, other_dev}, {homie}, {creator}}}), "The first lists did not count as a change"); + check(reskate_developer(dev) && reskate_developer(other_dev) && identity_listed(homie, L::homie) && + identity_listed(creator, L::content_creator), + "A listed player was not found"); + check(!reskate_developer(homie) && !reskate_developer(creator) && !reskate_developer(stranger) && + !identity_listed(dev, L::homie) && !identity_listed(homie, L::content_creator), + "A player was found in a list they are not in"); + check(!reskate_developer(0) && !identity_listed(dev, L::count), "No player, or no list, matched"); + check(!reskate_banned(dev) && !reskate_banned(stranger), "Someone is banned though the lists ban nobody"); + check(publish_identity_lists({{{dev, other_dev}, {homie}, {creator}, {dev, stranger}}}) && reskate_banned(stranger) && + reskate_banned(dev) && reskate_developer(dev) && !reskate_banned(other_dev) && !reskate_banned(0), + "The ban list did not ban exactly the players on it"); + check(publish_identity_lists({{{dev, other_dev}, {homie}, {creator}}}) && !reskate_banned(stranger), "A lifted ban stayed"); + check(!publish_identity_lists({{{dev, other_dev}, {homie}, {creator}}}), "The same lists counted as a change"); + // The one mark a player carries: a developer's before a content creator's before a homie's. + check(publish_identity_lists({{{dev}, {dev, homie, creator}, {dev, creator}}}) && identity_mark(dev) == L::developer && + identity_mark(creator) == L::content_creator && identity_mark(homie) == L::homie && !identity_mark(stranger) && + !identity_mark(0), + "A player's mark is not the first list they are on"); + check(own_marks_shown(), "A player's marks start hidden"); + show_own_marks(false); + check(!own_marks_shown() && identity_mark(dev) == L::developer, "Hiding their own marks did not take, or changed the lists"); + show_own_marks(true); + check(publish_identity_lists({{{dev, other_dev}, {homie}, {creator}}}), "The lists did not go back"); + // Removing someone in the panel takes their mark away at the next refresh. + check(publish_identity_lists({{{dev}, {}, {}}}) && reskate_developer(dev) && !reskate_developer(other_dev) && + !identity_listed(homie, L::homie), + "Replaced lists kept a player who was removed"); +} +// The hoodie and board: a developer's rainbow, a content creator's red, a homie's gold. +void items() { + using namespace dingosdk::developer_hoodie_detail; + publish_identity_lists({{{dev}, {dev, homie, creator}, {dev, creator}}}); + check(animation_for(dev) == Animation::rainbow && animation_for(creator) == Animation::red && + animation_for(homie) == Animation::gold, + "A list did not give its animation, a developer's first and a content creator's before a homie's"); + check(animation_for(stranger) == Animation::none && animation_for(0) == Animation::none, "An unlisted player's items animate"); + check(item_color(Animation::rainbow, 0) == rainbow(0) && item_color(Animation::rainbow, 3000) == rainbow(3000), + "The developer's rainbow changed"); + // Each goes from a deep shade to a bright one, on into the colour next to it, and back + // every three seconds. + for (const auto animation : {Animation::red, Animation::gold}) { + const auto deep = item_color(animation, 0), bright = item_color(animation, 750), far = item_color(animation, 1500); + check(item_color(animation, 3000) == deep && item_color(animation, 4500) == far, "An animation does not repeat every three seconds"); + check(bright[0] > deep[0] * 2 && far[0] >= bright[0], "An animation does not go from deep to bright"); + if (animation == Animation::red) { + // A content creator's: red, then a reddish pink. + check(bright[1] < bright[0] * .1f && bright[2] < bright[0] * .1f, "Red is not red"); + check(far[2] > bright[2] * 3 && far[2] < far[0] * .4f && far[1] < far[0] * .15f, "Red does not go on into a reddish pink"); + } else { + // A homie's: gold, then yellow. + check(bright[1] > bright[0] * .5f && bright[1] < bright[0] * .75f && bright[2] < bright[0] * .15f, "Gold is not gold"); + check(far[1] > bright[1] * 1.2f && far[1] < far[0] && far[2] < far[1] * .4f, "Gold does not go on into yellow"); + } + for (std::uint64_t at = 0; at < 6000; at += 125) { + // The material's gamut, and red always the strongest part of the colour. + const auto color = item_color(animation, at); + check(color[0] <= .801f && color[1] > 0 && color[2] > 0 && color[0] > color[1] && color[0] > color[2], + "An animation leaves its colours"); + } + } +} + +// Turning the animation off (the Special page, or a list that no longer has the player) puts an +// item's own colours back, and publishes them the extra times the renderer needs to show them. +void settling() { + using namespace dingosdk::developer_hoodie_detail; + const Color black{.02f, .02f, .02f}, navy{.02f, .02f, .2f}, teal{.02f, .3f, .3f}; + // What the game holds for each colour region; the materials are read afresh each tick, as in game. + std::array held{black, navy}; + dingosdk::DeveloperHoodieState state; + Materials live; + live.component = 1, live.appearance = 2, live.item = 3, live.eligible = true, live.count = 2; + live.bindings[0] = {10, 100, 7, color_keys[0], black}; + live.bindings[1] = {10, 101, 7, color_keys[3], navy}; + int publishes{}; + auto write = [&](const Binding &binding, const Color &color) { + held[binding.node - 100] = color; + return true; + }; + auto publish = [&](std::uintptr_t) { ++publishes; }; + const auto tick = [&](Animation animation, std::uint64_t at) { + for (std::size_t i = 0; i < held.size(); ++i) live.bindings[i].color = held[i]; + animate(state, live, 5, 6, animation, at, write, publish); + }; + tick(Animation::red, 0); + tick(Animation::red, 700); + check(state.count == 2 && held[0] == item_color(Animation::red, 700) && held[1] == held[0] && publishes == 2, + "The animation did not colour the hoodie"); + publishes = 0; + tick(Animation::none, 800); + check(held[0] == black && held[1] == navy && publishes == 1, "Turning it off did not bring the hoodie's own colours back"); + for (std::uint64_t at = 900; at < 910; ++at) tick(Animation::none, at); + check(publishes == 1 + settle_publishes && !state.count && held[0] == black && held[1] == navy, + "The own colours were not published the extra times, or the hoodie was not left alone after"); + // A colour the game itself sets meanwhile is the game's to keep. + tick(Animation::gold, 1000); + held[0] = teal; + tick(Animation::none, 1100); + check(held[0] == teal && held[1] == navy, "Turning it off overwrote a colour the game had set"); + // And it comes back on with the own colours still known. + for (std::uint64_t at = 1200; at < 1210; ++at) tick(Animation::none, at); + tick(Animation::red, 2000); + tick(Animation::none, 2100); + check(held[0] == teal && held[1] == navy, "A second round lost the hoodie's own colours"); + + // The board does the same, part by part. + namespace board = dingosdk::developer_board_detail; + std::array painted{black, navy}; + dingosdk::DeveloperBoardState deck; + board::Materials parts; + parts.component = 1, parts.appearance = 2, parts.count = 2; + parts.bindings[0] = {30, 10, 100, 7, board::parameter_key(board::base_color, 1), black, true}; + parts.bindings[1] = {31, 11, 101, 7, board::parameter_key(board::base_color, 1), navy, true}; + int shown{}; + auto paint = [&](const board::Binding &binding, const Color &color) { + painted[binding.node - 100] = color; + return true; + }; + auto show = [&](std::uintptr_t) { ++shown; }; + const auto roll = [&](Animation animation, std::uint64_t at) { + for (std::size_t i = 0; i < painted.size(); ++i) parts.bindings[i].color = painted[i]; + board::animate(deck, parts, 5, 6, animation, at, paint, show); + }; + roll(Animation::gold, 0); + roll(Animation::gold, 700); + check(deck.count == 2 && painted[0] == item_color(Animation::gold, 700) && painted[1] == painted[0], "The animation did not colour the board"); + shown = 0; + for (std::uint64_t at = 800; at < 810; ++at) roll(Animation::none, at); + check(painted[0] == black && painted[1] == navy && shown == 2 * (1 + settle_publishes) && !deck.count, + "Turning it off did not bring the board's own colours back and publish each part the extra times"); +} + +// --live : the deployed backend, read the way the game reads it. Not +// part of the test run, which stays off the network. +int live(const char *player) { + const auto id = std::strtoull(player, nullptr, 10); + for (int waited = 0; waited < 150 && !reskate_developer(id); ++waited) { + refresh_identity_lists(); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + std::cout << player << (reskate_developer(id) ? " is" : " is not") << " a developer in the lists the backend sent\n"; + return reskate_developer(id) ? 0 : 1; +} +} // namespace + +int main(int count, char **arguments) { + if (count == 3 && std::string_view(arguments[1]) == "--live") return live(arguments[2]); + parsing(); + lookup(); + items(); + settling(); + std::cout << "developer identity tests passed\n"; +} diff --git a/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index 77d05126..42953737 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -39,6 +39,9 @@ void update_developer_hoodie(std::uintptr_t, std::uintptr_t, std::uint64_t, std: void update_developer_board(std::uintptr_t, std::uintptr_t, std::uint64_t, std::uint64_t, DeveloperBoardState &) noexcept {} } namespace dingosdk::multiplayer { +// The backend's lists are not read here: a check lists the players it means. +std::set> simulated_identities; +bool identity_listed(std::uint64_t id, IdentityList list) noexcept { return simulated_identities.contains({id, list}); } bool local_allows_player_collision(std::uintptr_t, std::uintptr_t) noexcept { return false; } void update_remote_collision(std::uintptr_t, std::uintptr_t, const Pose &, bool, std::uint64_t) noexcept {} void expire_remote_collision(std::uintptr_t, std::uint64_t) noexcept {} @@ -644,6 +647,94 @@ void party_checks() { check(!host.local_party && host.parties.parties().empty(), "A guest who left the lobby stayed in the host's party"); std::cout << "Lobby parties: nobody by default, invites, two parties, party chat, leaving and departures passed.\n"; } +// A player's badge and colour, in chat and on their nametag: who the backend lists them as +// comes before what they are in the lobby, and a developer before the other lists. +void role_checks() { + Simulation sim; + for (unsigned i = 0; i < 4; ++i) sim.add(); + sim.run(60); sim.fresh(4); + auto &host = *sim.nodes[0], &first = *sim.nodes[1], &second = *sim.nodes[2], &third = *sim.nodes[3]; + const auto id = [](const Session &s) { return s.transport.status().local_id; }; + using Role = std::pair; + using L = IdentityList; + check(player_role(first, id(host), false) == Role{nametag_host, "Host"} && + player_role(host, id(host), true) == Role{nametag_host, "Host"} && + player_role(host, id(first), false) == Role{nametag_white, {}}, + "Players the backend does not list did not get their lobby roles"); + + simulated_identities = {{id(host), L::homie}, {id(first), L::content_creator}, + {id(second), L::developer}, {id(second), L::content_creator}, {id(second), L::homie}, + {id(third), L::content_creator}, {id(third), L::homie}}; + check(player_role(first, id(host), false) == Role{nametag_homie, "Homie"} && + player_role(host, id(host), true) == Role{nametag_homie, "Homie"}, + "A homie who hosts is not shown as a homie"); + check(player_role(host, id(first), false) == Role{nametag_creator, "Creator"} && + player_role(first, id(first), true) == Role{nametag_creator, "Creator"}, + "A content creator is not shown as one"); + check(player_role(host, id(second), false) == Role{nametag_developer, "Dev"}, + "A developer on every list is not shown as a developer"); + check(player_role(host, id(third), false) == Role{nametag_creator, "Creator"}, + "A content creator who is also a homie is not shown as a creator"); + // A player who has turned their marks off (the Special page) is whatever they are in the lobby, + // to everyone: their appearance carries the choice through the host. + auto plain = packet(host, PacketKind::cosmetics, sim.now); + plain.appearance = {{skater_recipe_key, 2, {}, {{1, "Outfit0", {}}}}, {board_recipe_key, 1, {}, {{2, "Deck", {}}}}}; + plain.appearance.unmarked = true; + host.cosmetic_packet = encode_wire(plain); + broadcast(host, plain, true, false, sim.now); + sim.run(20); + check(player_role(first, id(host), false) == Role{nametag_host, "Host"} && + player_role(second, id(host), false) == Role{nametag_host, "Host"}, + "A homie who turned their marks off still shows as a homie"); + check(player_role(host, id(first), false) == Role{nametag_creator, "Creator"}, "One player's choice hid another's marks"); + show_own_marks(false); + check(player_role(first, id(first), true) == Role{nametag_white, {}} && + player_role(host, id(first), false) == Role{nametag_creator, "Creator"}, + "A player's own choice did not hide their marks from themselves, or hid them from others before they were told"); + show_own_marks(true); + // A chat line carries its sender's role. + check(send_chat(first, "new video is up").empty(), "A guest's chat was refused"); + sim.run(20); + const auto said = std::find_if(host.chat.begin(), host.chat.end(), [](const auto &line) { return line.text == "new video is up"; }); + check(said != host.chat.end() && said->color == nametag_creator && said->tag == "Creator", + "A content creator's chat line does not carry their role"); + simulated_identities.clear(); + std::cout << "Roles: lobby roles, homie, content creator, developer first and chat lines passed.\n"; +} +// The backend's bans hold in every session, and reach one that is running: a banned guest is +// out and cannot come back, the others stay, and nobody stays with a banned host. +void global_ban_checks() { + Simulation sim; + for (unsigned i = 0; i < 3; ++i) sim.add(); + sim.run(60); sim.fresh(3); + auto &host = *sim.nodes[0], &first = *sim.nodes[1], &second = *sim.nodes[2]; + const auto id = [](const Session &s) { return s.transport.status().local_id; }; + const auto first_id = id(first), host_id = id(host); + + simulated_identities = {{first_id, IdentityList::banned}}; + sim.run(40); + check(first.mode == Mode::off && first.status == banned_notice, "A banned guest stayed in the session"); + check(!find_peer(host, first_id) && !find_peer(second, first_id), "The session kept a banned guest"); + check(second.mode == Mode::join && find_peer(host, id(second)), "Banning one guest took another out"); + // A banned player whose game does not stop itself is turned away by the host all the same. + first.mode = Mode::join; + first.host_id = host_id; + first.transport.join(host_id); + host.transport.poll(); + networking(host, sim.local, sim.now); + const auto &links = host.transport.status().peers; + check(std::none_of(links.begin(), links.end(), [&](const auto &link) { return link.id == first_id; }) && + !find_peer(host, first_id), + "The host let a banned player back in"); + stop(first, "Left"); + + simulated_identities = {{host_id, IdentityList::banned}}; + sim.run(40); + check(host.mode == Mode::off && host.status == banned_notice, "A banned host kept hosting"); + check(second.mode == Mode::off, "A guest stayed with a banned host"); + simulated_identities.clear(); + std::cout << "Global bans: a banned guest, a guest who comes back, the other guests and a banned host passed.\n"; +} // A host that is kept from reading for a few seconds (a server whose console held it up) then // reads everything its guests sent meanwhile in one go. That is not a flood: nobody is dropped // for it, while a real flood still is. @@ -1988,6 +2079,8 @@ int main(int argc, char **argv) { dingosdk::multiplayer::mesh_checks(); dingosdk::multiplayer::throwdown_routing_checks(); dingosdk::multiplayer::party_checks(); + dingosdk::multiplayer::role_checks(); + dingosdk::multiplayer::global_ban_checks(); dingosdk::multiplayer::physics_extras_checks(); dingosdk::multiplayer::stall_checks(); dingosdk::multiplayer::scoring_checks(); diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index 6921a412..32bffce6 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -331,8 +331,14 @@ void cosmetics_codec() { {board_recipe_key, 1, {0x3f800000}, {{13, "Own_Deck", {7}}}}}; const auto bytes = encode(p); const auto decoded = decode(bytes); - check(decoded && decoded->appearance == p.appearance, + check(decoded && decoded->appearance == p.appearance && !decoded->appearance.unmarked, "Cosmetic fields or opaque parameter bits were lost"); + // A player's choice to go without their backend marks travels with their outfit. + auto unmarked = p; + unmarked.appearance.unmarked = true; + const auto told = decode(encode(unmarked)); + check(told && told->appearance.unmarked && told->appearance == unmarked.appearance && !(told->appearance == p.appearance), + "A player's choice to hide their marks was lost"); for (std::size_t n = 0; n < bytes.size(); ++n) check(!decode(std::span(bytes).first(n)), "Truncated cosmetics accepted"); auto corrupt = bytes; diff --git a/Extension/Multiplayer/developer_identity.cpp b/Extension/Multiplayer/developer_identity.cpp new file mode 100644 index 00000000..83a7bca1 --- /dev/null +++ b/Extension/Multiplayer/developer_identity.cpp @@ -0,0 +1,62 @@ +#include "developer_identity.h" +#include "Engine/Core/Json/json.h" +#include +#include +#include +#include +#include + +// The lists themselves, shared by the game and the dedicated server. Each reads +// 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"}; +constexpr auto banned = static_cast(IdentityList::banned); + +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::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; + } + throw std::runtime_error("an entry is not a player's SteamID64"); +} +} // namespace + +IdentityLists parse_identity_lists(std::string_view json) { + const auto answer = Json::parse(json); + if (!answer.is_object() || !answer.contains("categories") || !answer.at("categories").is_object()) + 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) { + 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)); + 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]); + if (answer.contains("banned")) read(answer.at("banned"), lists[banned]); + return lists; +} +bool publish_identity_lists(IdentityLists lists) { + const auto previous = current.load(); + if (previous && *previous == lists) return false; + current.store(std::make_shared(std::move(lists))); + return true; +} +bool identity_listed(std::uint64_t id, IdentityList list) noexcept { + const auto lists = current.load(); + if (!id || !lists || list >= IdentityList::count) return false; + const auto &ids = (*lists)[static_cast(list)]; + return std::binary_search(ids.begin(), ids.end(), id); +} +} // namespace dingosdk::multiplayer diff --git a/Extension/Multiplayer/developer_identity.h b/Extension/Multiplayer/developer_identity.h index 9e2ca9db..183f3583 100644 --- a/Extension/Multiplayer/developer_identity.h +++ b/Extension/Multiplayer/developer_identity.h @@ -1,16 +1,55 @@ #pragma once #include +#include +#include #include +#include +#include +#include namespace dingosdk::multiplayer { -// Shared by developer tags and cosmetics. Steam authenticates these identities. -inline constexpr std::array reskate_developers{ - 76561198084159190ULL, // zee_x64 - 76561198255588397ULL, // reglitched -}; -inline bool reskate_developer(std::uint64_t id) noexcept { - for (const auto developer : reskate_developers) - if (developer == id) return true; - return false; +// Who the ReSkate backend lists in each of its Steam ID categories, and who it +// has banned. The lists are kept in its admin panel and read from +// 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, banned, 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"; + +// The game's way of reading them (the dedicated server's is Server/global_bans.h). Called +// every client tick: reads the lists in the background, at startup and every ten +// minutes after (a minute after a failure). Offline mode reads them too, for the +// player's own hoodie and board. +void refresh_identity_lists() noexcept; +// Any thread. Nobody is listed until the first answer has arrived. +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 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::content_creator, IdentityList::homie}) + if (identity_listed(id, list)) return list; + return std::nullopt; } +// The local player's own choice to go without their marks (the menu's Special page, which +// only a listed player gets). Their appearance tells everyone else (Appearance::unmarked). +inline std::atomic own_marks{true}; +inline bool own_marks_shown() noexcept { return own_marks.load(std::memory_order_relaxed); } +inline void show_own_marks(bool shown) noexcept { own_marks.store(shown, std::memory_order_relaxed); } +// A global ban is enforced by whoever a player would play with: a host and a dedicated server +// turn them away, and a guest leaves a lobby they host, which their own game does not start +// either (session_receive.cpp, Server/server_host.cpp). A server can opt out ("global_bans"), +// 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); } +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. +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); } // namespace dingosdk::multiplayer diff --git a/Extension/Multiplayer/developer_identity_fetch.cpp b/Extension/Multiplayer/developer_identity_fetch.cpp new file mode 100644 index 00000000..09765644 --- /dev/null +++ b/Extension/Multiplayer/developer_identity_fetch.cpp @@ -0,0 +1,59 @@ +#include "developer_identity.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Vfs/https_download.h" +#include +#include +#include +#include + +// The game's way of reading the lists: in the background, driven by the client tick. +namespace dingosdk::multiplayer { +namespace { +constexpr std::uint64_t max_bytes = 256 * 1024, refresh_ms = 10 * 60 * 1000, retry_ms = 60 * 1000; +std::atomic fetching{}; +std::atomic next_fetch{}; // GetTickCount64 time + +void fetch() noexcept { + // The log says when the lists arrive, change or stop arriving, not every refresh. + static bool failing{}; + bool ok{}; + try { + https::Download download; + const auto answer = https::get_text(std::wstring(identity_lists_url.begin(), identity_lists_url.end()), max_bytes, 10, + L"ReSkate-Identity/1", &download); + if (answer) { + auto lists = parse_identity_lists(*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) + logging::log(logging::Level::info, logging::Channel::runtime, + "Identity lists: {} developer(s), {} homie(s), {} content creator(s), {} banned.", developers, + homies, creators, bans); + ok = true; + } else if (!failing) { + logging::log(logging::Level::info, logging::Channel::runtime, + "Identity lists unavailable (HTTP {}, error {}); trying again every minute.", download.http_status, + download.error); + } + } catch (const std::exception &e) { + if (!failing) + logging::log(logging::Level::warning, logging::Channel::runtime, + "Identity lists are invalid ({}); trying again every minute.", e.what()); + } catch (...) {} + failing = !ok; + // A failure keeps the lists already in use. + next_fetch = GetTickCount64() + (ok ? refresh_ms : retry_ms); + fetching = false; +} +} // namespace + +void refresh_identity_lists() noexcept { + if (GetTickCount64() < next_fetch || fetching.exchange(true)) return; + try { + std::thread(fetch).detach(); + } catch (...) { + next_fetch = GetTickCount64() + retry_ms; + fetching = false; + } +} +} // namespace dingosdk::multiplayer diff --git a/Extension/UI/Overlay/multiplayer_menu.cpp b/Extension/UI/Overlay/multiplayer_menu.cpp index a150880f..06f3b5e4 100644 --- a/Extension/UI/Overlay/multiplayer_menu.cpp +++ b/Extension/UI/Overlay/multiplayer_menu.cpp @@ -243,8 +243,9 @@ void multiplayer_display_settings(SkateMenu &menu, const Model &model) { } bool custom = mp.custom_nametags; if (toggle_row(menu, "ReSkate nametags", - "Names with distance: purple for ReSkate developers, gold for server admins, blue for the host, " - "green for your Steam friends; far and off-screen players as dots. Off: the game's own nametags and arrows.", + "Names with distance: purple for ReSkate developers, red for content creators, gold for homies, " + "pink for server admins, blue for the host, green for your Steam friends; far and off-screen players as dots. " + "Off: the game's own nametags and arrows.", custom, mp.nametags, "OFF")) { std::array unused{}; send_private(menu, "nametag-style", custom ? "reskate" : "game", unused, false); @@ -266,6 +267,23 @@ void multiplayer_display_settings(SkateMenu &menu, const Model &model) { void multiplayer_network_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks) { debug_page(menu, model.multiplayer, callbacks); } +// SPECIAL: the page only a player on one of the backend's lists gets. Their tag, and the hoodie +// and board that come with it, on or off for everyone at once. +void special_page(SkateMenu &menu, const Model &model, const CallbacksV3 &) { + const auto &mp = model.multiplayer; + ImGui::BeginChild("special", ImVec2(0, page_body_height(menu))); + begin_card(menu, "special-marks", "YOUR TAG", mp.identity_tag.c_str()); + bool shown = mp.identity_marks; + if (toggle_row(menu, "Show my tag and items", + "Your tag in chat and on your nametag, and the animated hoodie and board that come with it. " + "Off: you look like any other player, to yourself and to everyone you skate with.", + shown)) { + std::array unused{}; + send_private(menu, "marks", shown ? "on" : "off", unused, false); + } + end_card(); + ImGui::EndChild(); +} void multiplayer_page(SkateMenu &menu, const Model &model, const CallbacksV3 &callbacks) { const auto &mp = model.multiplayer; const bool has_session = mp.active || mp.lobby_joining; diff --git a/Extension/UI/Overlay/nametag_gradient.h b/Extension/UI/Overlay/nametag_gradient.h index d8578e0c..dc6f6caa 100644 --- a/Extension/UI/Overlay/nametag_gradient.h +++ b/Extension/UI/Overlay/nametag_gradient.h @@ -2,26 +2,37 @@ #include "Extension/Multiplayer/Hud/custom_nametags.h" #include #include +#include #include #include namespace dingosdk::overlay::detail { inline constexpr double nametag_gradient_period = 4.0; -inline bool developer_nametag_colour(ImU32 colour) noexcept { - return (colour & ~IM_COL32_A_MASK) == (multiplayer::nametag_developer & ~IM_COL32_A_MASK); +// The roles whose name and badge shimmer, each between `from` and its role colour. +struct NametagGradient { ImU32 from, to; }; +inline constexpr std::array nametag_gradients{ + NametagGradient{multiplayer::nametag_developer_start, multiplayer::nametag_developer}, + NametagGradient{multiplayer::nametag_creator_start, multiplayer::nametag_creator}, + NametagGradient{multiplayer::nametag_homie_start, multiplayer::nametag_homie}, +}; +inline const NametagGradient* nametag_gradient(ImU32 colour) noexcept { + for (const auto& gradient : nametag_gradients) + if ((colour & ~IM_COL32_A_MASK) == (gradient.to & ~IM_COL32_A_MASK)) return &gradient; + return nullptr; } inline ImU32 animated_nametag_colour(ImU32 colour, double seconds, float across = 0.0f) noexcept { - if (!developer_nametag_colour(colour)) return colour; + const auto* gradient = nametag_gradient(colour); + if (!gradient) return colour; // Render-time phase: cached chat lines animate too, without changing their role colour. const double phase = std::clamp(across, 0.0f, 1.0f) * 0.5 - std::fmod(seconds, nametag_gradient_period) / nametag_gradient_period; const double blend = (1.0 - std::cos(2.0 * std::numbers::pi * phase)) * 0.5; ImU32 result = colour & IM_COL32_A_MASK; for (const int shift : {IM_COL32_R_SHIFT, IM_COL32_G_SHIFT, IM_COL32_B_SHIFT}) { - const double from = (multiplayer::nametag_developer_start >> shift) & 0xff; - const double to = (multiplayer::nametag_developer >> shift) & 0xff; + const double from = (gradient->from >> shift) & 0xff; + const double to = (gradient->to >> shift) & 0xff; result |= static_cast(std::lround(from + (to - from) * blend)) << shift; } return result; @@ -29,7 +40,7 @@ inline ImU32 animated_nametag_colour(ImU32 colour, double seconds, float across inline void shade_nametag_gradient(ImDrawList* draw, int first_vertex, float left, float width, ImU32 role_colour, double seconds) noexcept { - if (!developer_nametag_colour(role_colour) || width <= 0.0f) return; + if (!nametag_gradient(role_colour) || width <= 0.0f) return; for (int i = first_vertex; i < draw->VtxBuffer.Size; ++i) { auto& vertex = draw->VtxBuffer[i]; const auto colour = animated_nametag_colour(role_colour, seconds, (vertex.pos.x - left) / width); diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index e5547d81..3c3b5b8a 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -305,7 +305,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", "Admin", "Host", "Friend") + std::string tag; // role badge before the name ("Dev", "Creator", "Homie", "Admin", "Host", "Friend") float distance{}; // metres from the local skater bool talking{}; }; diff --git a/Extension/UI/Overlay/role_badge.h b/Extension/UI/Overlay/role_badge.h index 743772c8..f9e89857 100644 --- a/Extension/UI/Overlay/role_badge.h +++ b/Extension/UI/Overlay/role_badge.h @@ -4,8 +4,8 @@ #include #include -// The role badge ("Dev", "Admin", "Host", "Friend") before a player's name, in chat and on -// nametags: a pill in the role's colour (animated for developers) with dark text. +// The role badge ("Dev", "Creator", "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) { return tag.empty() ? 0.0f : font->CalcTextSizeA(size * 0.78f, FLT_MAX, 0.0f, tag.c_str()).x + size * 0.7f; diff --git a/Extension/UI/Overlay/skate_menu.cpp b/Extension/UI/Overlay/skate_menu.cpp index f794292b..0c70800e 100644 --- a/Extension/UI/Overlay/skate_menu.cpp +++ b/Extension/UI/Overlay/skate_menu.cpp @@ -276,12 +276,13 @@ ImFont* embedded_font(const wchar_t* name, float size, const ImWchar* ranges) { &config, ranges); } -enum Page { map, world, build, skater, training, multiplayer, progress, mods, settings, developer, page_count }; +enum Page { map, world, build, skater, training, multiplayer, progress, mods, settings, special, developer, page_count }; constexpr std::array page_names{ - "MAP", "WORLD", "BUILD", "SKATER", "TRAINER", "MULTIPLAYER", "PROGRESS", "MODS", "SETTINGS", "DEVELOPER"}; + "MAP", "WORLD", "BUILD", "SKATER", "TRAINER", "MULTIPLAYER", "PROGRESS", "MODS", "SETTINGS", "SPECIAL", "DEVELOPER"}; constexpr std::array page_subtitles{ "Pick your spot.", "Set the vibe.", "Make the park yours.", "Ride it your way.", "Tune it. Drill it. Measure it.", - "Bring your crew.", "Pick up where you want.", "Bring your own.", "Your controls, your screen.", "Under the hood."}; + "Bring your crew.", "Pick up where you want.", "Bring your own.", "Your controls, your screen.", "Not everyone gets this page.", + "Under the hood."}; } void load_skate_fonts(SkateMenu& menu) { @@ -369,12 +370,17 @@ void draw_skate_menu(SkateMenu& menu, const Model& model, const CallbacksV3& cal if (trainer_page_wanted()) menu.page = training; menu.page = std::clamp(menu.page, 0, static_cast(page_count) - 1); // Developer (the network view) is hidden for now; offline there is no Multiplayer either. - if (menu.page == developer || (model.steam_offline && menu.page == multiplayer)) menu.page = map; + // Special is only there for a player the backend lists. + const auto hidden = [&](int page) { + return page == developer || (model.steam_offline && page == multiplayer) || + (page == special && model.multiplayer.identity_tag.empty()); + }; + if (hidden(menu.page)) menu.page = map; ImGui::SetCursorPos(ImVec2(px(10), px(96))); ImGui::BeginChild("navigation", ImVec2(sidebar - px(20), size.y - px(140)), ImGuiChildFlags_None); const float tab_height = px(40); for (int i = 0; i < static_cast(page_count); ++i) { - if (i == developer || (model.steam_offline && i == multiplayer)) continue; + if (hidden(i)) continue; const auto at = ImGui::GetCursorScreenPos(); const bool selected = menu.page == i; ImGui::PushID(i); @@ -432,6 +438,7 @@ void draw_skate_menu(SkateMenu& menu, const Model& model, const CallbacksV3& cal ImGui::PopID(); break; case settings: settings_page(menu, model, callbacks); break; + case special: special_page(menu, model, callbacks); break; case developer: developer_page(menu, model, callbacks); break; } if (!menu.feedback.empty() && ImGui::GetTime() < menu.feedback_until) { diff --git a/Extension/UI/Overlay/skate_menu_internal.h b/Extension/UI/Overlay/skate_menu_internal.h index 9f07eeae..ebf9e26e 100644 --- a/Extension/UI/Overlay/skate_menu_internal.h +++ b/Extension/UI/Overlay/skate_menu_internal.h @@ -42,6 +42,7 @@ void debug_request(SkateMenu& menu, const CallbacksV3& callbacks, DebugRequest r // HOST shows the host form until a session exists, then that session (SESSION). inline constexpr int multiplayer_session_tab = 1, multiplayer_voice_tab = 2, multiplayer_bans_tab = 3; void multiplayer_display_settings(SkateMenu&, const Model&); +void special_page(SkateMenu&, const Model&, const CallbacksV3&); void multiplayer_network_page(SkateMenu&, const Model&, const CallbacksV3&); void atmosphere_menu(SkateMenu&, const Model&, const CallbacksV3&); void progression_page(SkateMenu&, const Model&, const CallbacksV3&); diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index 880f7201..8ee41e09 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -10,6 +10,7 @@ #include "Extension/UI/NativeMenu/native_menu.h" #include "Extension/Multiplayer/Hud/native_party.h" #include "Extension/Multiplayer/Hud/custom_nametags.h" +#include "Extension/Multiplayer/developer_identity.h" #include "Extension/Multiplayer/Hud/follow_camera.h" #include "Engine/Game/UI/game_view.h" #include "Extension/Multiplayer/Steam/steam_friend_join.h" @@ -991,6 +992,7 @@ void tick(std::uintptr_t client, std::uintptr_t update) { DINGO_PROFILE_ZONE("tick/multiplayer"); dingosdk::multiplayer::tick(r.base,client,multiplayer_ready,r.multiplayer_map,load_multiplayer_map); } + dingosdk::multiplayer::refresh_identity_lists(); dingosdk::tick_local_developer_hoodie(r.base, client, multiplayer_ready); dingosdk::tick_local_developer_board(r.base, client, multiplayer_ready); // The session spawns and places skaters and can teleport: check the camera again. diff --git a/Server/README-linux.md b/Server/README-linux.md index 07e1654b..b402ff1e 100644 --- a/Server/README-linux.md +++ b/Server/README-linux.md @@ -7,6 +7,9 @@ Self-update is disabled on Linux (V1) — update by replacing the binary. - 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`. - 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). @@ -65,8 +68,8 @@ cmake --build --preset linux-release -j$(nproc) ctest --test-dir build/linux --output-on-failure ``` -Expected: 8 passed (`multiplayer_parties`, `server_activity`, `server_speed_check`, -`server_config`, `word_filter`, `multiplayer_bandwidth`, `multiplayer_lanes`, +Expected: 9 passed (`multiplayer_parties`, `server_activity`, `server_speed_check`, +`server_config`, `server_global_bans`, `word_filter`, `multiplayer_bandwidth`, `multiplayer_lanes`, `multiplayer_prediction`). Windows-only tests (voice, lobbies, UI) stay `WIN32`-gated. ## Run diff --git a/Server/README.txt b/Server/README.txt index 6cc89fad..4c198d70 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -47,6 +47,10 @@ 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. 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 + and every ten minutes. false lets them in; the server's own + "bans" apply either way. votes Player votes, each off until turned on: "map": {"enabled": true, "percent": 60} /vote map "kick": {"enabled": true, "percent": 60} /vote kick @@ -104,6 +108,7 @@ world_layer_sync Force the "layers" below on every player. layers World layer key -> "on" / "off". admins SteamID64s (as strings) who may change settings in-game. bans Players who can never join. Managed with ban / unban. + The ReSkate team's own list is separate: see global_bans. Every change made from the console or by an admin is saved back to this file. diff --git a/Server/Test/global_bans_tests.cpp b/Server/Test/global_bans_tests.cpp new file mode 100644 index 00000000..51c567e5 --- /dev/null +++ b/Server/Test/global_bans_tests.cpp @@ -0,0 +1,57 @@ +#include "Server/global_bans.h" +#include "Extension/Multiplayer/developer_identity.h" +#include +#include +#include + +using namespace dingosdk::server; +using dingosdk::multiplayer::reskate_banned; +namespace { +void check(bool ok, const std::string &message) { + if (ok) return; + std::cerr << message << '\n'; + std::exit(1); +} +constexpr std::uint64_t griefer = 76561198000000009ULL, cheater = 76561198000000010ULL, player = 76561198000000011ULL; + +void lists() { + check(!reskate_banned(griefer), "A player was banned before any list was read"); + // The first list read counts as news even when it bans nobody, so the server can say it has it. + auto read = use_ban_list(R"({"categories":{"dev":["76561198000000011"]},"banned":[]})"); + check(read.ok && read.changed && read.banned == 0 && read.problem.empty() && !reskate_banned(griefer), + "A list with nobody on it was not taken as read"); + read = use_ban_list(R"({"categories":{},"banned":["76561198000000010","76561198000000009"]})"); + check(read.ok && read.changed && read.banned == 2 && reskate_banned(griefer) && reskate_banned(cheater) && !reskate_banned(player), + "The ban list did not ban the players on it"); + // The other lists changing is no news about bans. + read = use_ban_list(R"({"categories":{"homie":["76561198000000011"]},"banned":["76561198000000009","76561198000000010"]})"); + check(read.ok && !read.changed && read.banned == 2, "The same ban list counted as a change"); + // An answer that is not the lists (the backend down, a proxy's error page) lifts no ban. + for (const std::string_view wrong : {"", "502 Bad Gateway", R"({"banned":["76561198000000009"]})", + R"({"categories":{},"banned":["everyone"]})"}) { + read = use_ban_list(wrong); + check(!read.ok && !read.problem.empty() && reskate_banned(griefer) && reskate_banned(cheater), + "A bad answer was accepted, or lifted the bans: " + std::string(wrong)); + } + read = use_ban_list(R"({"categories":{},"banned":["76561198000000010"]})"); + check(read.ok && read.changed && read.banned == 1 && !reskate_banned(griefer) && reskate_banned(cheater), "A lifted ban stayed"); + // A backend from before it had bans bans nobody. + read = use_ban_list(R"({"categories":{"dev":[]}})"); + check(read.ok && read.changed && read.banned == 0 && !reskate_banned(cheater), "A list without bans kept one"); +} + +// --live: the deployed backend, read the way the server reads it. Not part of the +// test run, which stays off the network. +int live() { + const auto read = read_global_bans(); + if (read.ok) std::cout << "the backend's ban list was read: " << read.banned << " banned\n"; + else std::cout << "the backend's ban list could not be read: " << read.problem << '\n'; + return read.ok ? 0 : 1; +} +} // namespace + +int main(int count, char **arguments) { + if (count == 2 && std::string_view(arguments[1]) == "--live") return live(); + lists(); + std::cout << "global ban tests passed\n"; +} diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index b1c41fa4..c8f1627d 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -34,14 +34,15 @@ int run() { std::vector added; const auto config = load_config(file, &added); const auto has = [&](std::string_view name) { return std::ranges::find(added, name) != added.end(); }; - check(has("enforce_tuning") && has("votes.seconds") && has("votes.kick") && has("score_check") && has("score_allow"), + check(has("enforce_tuning") && has("votes.seconds") && has("votes.kick") && has("score_check") && has("score_allow") && + has("global_bans"), "New settings not reported"); check(!has("name") && !has("tps") && !has("votes.map"), "Settings the file had reported as new"); const auto written = text(file); check(written.find("\"enforce_tuning\"") != std::string::npos && written.find("\"seconds\"") != std::string::npos, "New settings not written into the file"); check(config.name == "Old Server" && config.tps == 60 && !config.boosts && config.votes.map.enabled && - config.votes.map.percent == 60 && config.enforce_tuning, + config.votes.map.percent == 60 && config.enforce_tuning && config.global_bans, "The file's own values or the new defaults were lost"); const auto reloaded = load_config(file); check(reloaded.name == "Old Server" && !reloaded.boosts && reloaded.votes.map.percent == 60, "Values lost on rewrite"); diff --git a/Server/global_bans.cpp b/Server/global_bans.cpp new file mode 100644 index 00000000..15cd4914 --- /dev/null +++ b/Server/global_bans.cpp @@ -0,0 +1,73 @@ +#include "global_bans.h" +#include "Extension/Multiplayer/developer_identity.h" +#ifdef _WIN32 +#include "Engine/Vfs/https_download.h" +#else +#include +#endif +#include +#include +#include +#include + +namespace dingosdk::server { +namespace { +constexpr std::size_t max_bytes = 256 * 1024; + +#ifdef _WIN32 +std::string download() { + const auto url = multiplayer::identity_lists_url; + https::Download result; + auto answer = https::get_text(std::wstring(url.begin(), url.end()), max_bytes, 15, 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: the Linux server has no HTTPS client of its own (server_update.cpp). +// The command is fixed text, with nothing from a player or the config in it. +std::string download() { + const auto command = "curl --silent --show-error --fail --max-time 15 --max-filesize " + std::to_string(max_bytes) + + " --proto =https --user-agent ReSkateServer/1 " + std::string(multiplayer::identity_lists_url) + " 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() < max_bytes) output.append(buffer, count); + if (pclose(pipe) != 0) { + // curl's own words (or the shell's, when curl is not installed), on one line. + 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 +} // namespace + +BanListCheck use_ban_list(std::string_view answer) { + static std::optional> last; + BanListCheck check; + try { + auto lists = multiplayer::parse_identity_lists(answer); + const auto &bans = lists[static_cast(multiplayer::IdentityList::banned)]; + check.banned = bans.size(); + check.changed = last != bans; + last = bans; + multiplayer::publish_identity_lists(std::move(lists)); + check.ok = true; + } catch (const std::exception &e) { + check.problem = std::string("the answer was not the lists: ") + e.what(); + } + return check; +} +BanListCheck read_global_bans() { + try { + return use_ban_list(download()); + } catch (const std::exception &e) { + BanListCheck check; + check.problem = e.what(); + return check; + } +} +} // namespace dingosdk::server diff --git a/Server/global_bans.h b/Server/global_bans.h new file mode 100644 index 00000000..26c2d1ee --- /dev/null +++ b/Server/global_bans.h @@ -0,0 +1,24 @@ +#pragma once +#include +#include +#include + +// The ReSkate backend's ban list (its admin panel's Bans page), which a dedicated +// server enforces beside its own: Host::tick turns those players away +// (multiplayer::reskate_banned). The server reads the list at startup and every +// ten minutes; until it has, and while the backend cannot be reached, it bans +// nobody by it. +namespace dingosdk::server { +struct BanListCheck { + bool ok{}; + bool changed{}; // the ban list is not the one read before (or is the first) + std::size_t banned{}; // players on it + std::string problem; // why it could not be read, or empty +}; +// Puts the backend's answer (the JSON of developer_identity.h) in use. An answer +// that is not the lists changes nothing. +BanListCheck use_ban_list(std::string_view answer); +// Asks the backend and does the same; blocks for a few seconds at most. Windows +// asks over WinHTTP; Linux runs curl, which has to be installed. +BanListCheck read_global_bans(); +} // namespace dingosdk::server diff --git a/Server/main.cpp b/Server/main.cpp index 6cd5e27c..f38bac12 100644 --- a/Server/main.cpp +++ b/Server/main.cpp @@ -2,6 +2,7 @@ // in-game server browser. Runs from its own folder, next to steam_api64.dll and // 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_config.h" #include "server_host.h" #include "server_update.h" @@ -393,6 +394,12 @@ int run(int argc, char **argv, bool skip_update) { auto next_update_check = next_advertise + update_interval; std::future update_check; bool update_now{}, update_waiting{}, restart{}; + // The backend's ban list (global_bans.h): read now and every ten minutes, a minute after a + // failure. "global_bans": false leaves it unread and lets those players in. + std::future ban_check; + auto next_ban_check = next_advertise; + bool bans_unread{}; + if (!config.global_bans) write_log("Global bans are off (\"global_bans\": false): only this server's own bans apply."); while (!stopping && !restart) { steam.run_callbacks(); try { @@ -447,6 +454,19 @@ int run(int argc, char **argv, bool skip_update) { } update_now = false; } + if (config.global_bans && !ban_check.valid() && now_time >= next_ban_check) + ban_check = std::async(std::launch::async, read_global_bans); + if (ban_check.valid() && ban_check.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { + const auto check = ban_check.get(); + next_ban_check = now_time + (check.ok ? std::chrono::minutes(10) : std::chrono::minutes(1)); + // Said when it changes, not every ten minutes. + if (check.ok && (check.changed || bans_unread)) + write_log("Global bans: " + std::to_string(check.banned) + " player(s) banned from ReSkate multiplayer cannot join."); + else if (!check.ok && !bans_unread) + write_log("The global ban list could not be read (" + check.problem + "). Trying again every minute; " + + "until then the bans already read hold."); + bans_unread = !check.ok; + } if (update_waiting && !restart && host.players() == 0) { write_log("Nobody is on; restarting to install server update " + update_version + "."); restart = true; @@ -478,6 +498,7 @@ int run(int argc, char **argv, bool skip_update) { input.shutdown(); #endif if (update_check.valid()) update_check.wait(); + if (ban_check.valid()) ban_check.wait(); write_log(restart ? "Restarting for an update." : "Shutting down."); host.stop(restart ? "The server is restarting for an update. Rejoin in a minute." : "The server is shutting down."); // Leaving scope closes the networking before Steam itself shuts down. diff --git a/Server/server_config.cpp b/Server/server_config.cpp index 30530426..76816150 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -21,6 +21,7 @@ Json to_json(const ServerConfig &c) { root["welcome"] = c.welcome; root["listed"] = c.listed; root["auto_update"] = c.auto_update; + root["global_bans"] = c.global_bans; root["activity_log"] = c.activity_log; root["announce_throwdowns"] = c.announce_throwdowns; root["parties"] = c.parties; @@ -115,6 +116,7 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector: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 Extension/Multiplayer/Remote/playback_buffers.cpp Extension/Multiplayer/Session/password.cpp @@ -135,7 +140,7 @@ add_executable(dingosdk_server Server/main.cpp Server/server_host.cpp Server/ser target_include_directories(dingosdk_server SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking") if(WIN32) target_link_libraries(dingosdk_server PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json - dingosdk_lz4 dingosdk_zstd dingosdk_logging dingosdk_miniz dingosdk_word_filter winhttp bcrypt winmm) + dingosdk_lz4 dingosdk_zstd dingosdk_logging dingosdk_miniz dingosdk_word_filter dingosdk_https winhttp bcrypt winmm) set_target_properties(dingosdk_server PROPERTIES OUTPUT_NAME "ReSkateServer") dingosdk_version_info(dingosdk_server "ReSkate dedicated server" "ReSkateServer.exe" VFT_APP) else() diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index c8ced382..5989d22a 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -60,6 +60,8 @@ add_library(dingosdk_runtime SHARED Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Session/password.cpp + Extension/Multiplayer/developer_identity.cpp + Extension/Multiplayer/developer_identity_fetch.cpp Extension/Settings/console_commands.cpp Extension/Settings/job_spin.cpp Extension/Settings/engine_tweaks.cpp