diff --git a/CMakeLists.txt b/CMakeLists.txt index 6c2700ef..48a31b19 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -86,6 +86,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) @@ -159,6 +164,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/EmotePacker/main.cpp b/EmotePacker/main.cpp index 7b18bc53..54a59d93 100644 --- a/EmotePacker/main.cpp +++ b/EmotePacker/main.cpp @@ -8,6 +8,7 @@ // // Dropping a folder on the exe writes the pack next to it, in "_pack". #include "Engine/Core/Json/json.h" +#include "Engine/Core/Platform/path_text.h" #include #include #include @@ -26,6 +27,7 @@ using Microsoft::WRL::ComPtr; namespace fs = std::filesystem; using dingosdk::Json; +using dingosdk::path_utf8; namespace { // ReSkate's loader limits (chat_emotes.cpp): frames at most 256 x 256, names of letters, @@ -198,10 +200,12 @@ std::vector tidy(std::vector frames, std::size_t limit) { return thinned; } +// Built from the wide name: path::string() throws on a character outside the ANSI code page, +// and one such file would stop the whole pack. std::string emote_name(const fs::path &file) { std::string name; - for (const char c : file.stem().string()) - name += std::isalnum(static_cast(c)) ? c : '_'; + for (const wchar_t c : file.stem().wstring()) + name += c < 128 && std::isalnum(static_cast(c)) ? static_cast(c) : '_'; if (name.size() > max_name) name.resize(max_name); return name.empty() ? std::string("emote") : name; } @@ -266,7 +270,7 @@ Options parse(int argc, wchar_t **argv) { if (arg == L"--size") o.size = std::stoi(value()); else if (arg == L"--max-frames") o.max_frames = static_cast(std::stoul(value())); else if (arg == L"--max-width") o.max_width = std::stoi(value()); - else if (arg == L"--name") o.name = fs::path(value()).string(); + else if (arg == L"--name") o.name = path_utf8(value()); else if (arg == L"--help" || arg == L"-h" || arg == L"/?") throw std::invalid_argument(""); else positional.emplace_back(arg); } @@ -281,16 +285,16 @@ Options parse(int argc, wchar_t **argv) { } int run(const Options &o) { - if (!fs::is_directory(o.input)) throw std::runtime_error("not a folder: " + o.input.string()); + if (!fs::is_directory(o.input)) throw std::runtime_error("not a folder: " + path_utf8(o.input)); std::vector files; for (const auto &entry : fs::directory_iterator(o.input)) { - const auto extension = lowered(entry.path().extension().string()); + const auto extension = lowered(path_utf8(entry.path().extension())); if (entry.is_regular_file() && (extension == ".png" || extension == ".gif" || extension == ".jpg" || extension == ".jpeg" || extension == ".bmp" || extension == ".webp")) files.push_back(entry.path()); } std::sort(files.begin(), files.end()); - if (files.empty()) throw std::runtime_error("no .png, .gif, .jpg, .bmp or .webp files in " + o.input.string()); + if (files.empty()) throw std::runtime_error("no .png, .gif, .jpg, .bmp or .webp files in " + path_utf8(o.input)); std::vector emotes; std::set names, folded; @@ -298,6 +302,8 @@ int run(const Options &o) { for (const auto &file : files) { try { auto name = emote_name(file); + if (std::none_of(name.begin(), name.end(), [](unsigned char c) { return std::isalnum(c); })) + throw std::runtime_error("no letters or digits to name it by; rename the file"); for (int n = 2; names.contains(name); ++n) { const auto suffix = "_" + std::to_string(n); name = emote_name(file).substr(0, max_name - suffix.size()) + suffix; @@ -315,10 +321,10 @@ int run(const Options &o) { names.insert(name); std::printf(" :%s: %dx%d%s\n", name.c_str(), frames.front().image.width, frames.front().image.height, frames.size() > 1 ? (", " + std::to_string(frames.size()) + " frames").c_str() : ""); - emotes.push_back({name, file.filename().string(), std::move(frames)}); + emotes.push_back({name, path_utf8(file.filename()), std::move(frames)}); } catch (const std::exception &e) { ++failed; - std::printf(" skipped %s: %s\n", file.filename().string().c_str(), e.what()); + std::printf(" skipped %s: %s\n", path_utf8(file.filename()).c_str(), e.what()); } } if (emotes.empty()) throw std::runtime_error("no pictures could be read"); @@ -367,13 +373,14 @@ int run(const Options &o) { std::printf("\n%zu emotes (%zu frames) in a %dx%d atlas%s.\nWrote %s\\emotes.json and emotes.png.\n" "Copy both into the repository's assets\\emotes and rebuild ReSkate.dll.\n", emotes.size(), total, o.max_width, height, failed ? (", " + std::to_string(failed) + " skipped").c_str() : "", - o.output.string().c_str()); + path_utf8(o.output).c_str()); return failed ? 2 : 0; } } // namespace int wmain(int argc, wchar_t **argv) { std::setvbuf(stdout, nullptr, _IONBF, 0); // progress shows as it happens, even through a pipe + SetConsoleOutputCP(CP_UTF8); // file names are printed as UTF-8 const bool own_console = [] { DWORD processes[2]; return GetConsoleProcessList(processes, 2) == 1; // started by double-click or drag-and-drop diff --git a/Engine/Core/Platform/launcher_pe.cpp b/Engine/Core/Platform/launcher_pe.cpp index affed24e..4517e929 100644 --- a/Engine/Core/Platform/launcher_pe.cpp +++ b/Engine/Core/Platform/launcher_pe.cpp @@ -1,5 +1,6 @@ #include "launcher_support.h" #include "launcher_support_internal.h" +#include "path_text.h" #ifdef _WIN32 #include @@ -137,15 +138,56 @@ std::string rva_string(const MappedFile& file, const ParsedPe& pe, std::uint32_t fail("Unterminated PE export name"); } +// Opens a file to read all of it. A file that was written a moment ago (a download) is often +// held by the antivirus scanning it, so a sharing violation is waited out for a few seconds. +// A file that still cannot be opened is named along with the reason: with ReSkate.dll that is +// nearly always an antivirus blocking it, and "cannot open file" told the player nothing. +Handle open_to_read(const fs::path& path) { + DWORD error{}; + for (int attempt = 0; attempt < 50; ++attempt) { + Handle file(CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_SEQUENTIAL_SCAN, nullptr)); + if (file.get() != INVALID_HANDLE_VALUE) return file; + error = GetLastError(); + if (error != ERROR_SHARING_VIOLATION && error != ERROR_LOCK_VIOLATION) break; + Sleep(100); + } + const auto name = path_utf8(path.filename()); + const auto folder = path_utf8(path.parent_path()); + std::string message; + switch (error) { + case ERROR_VIRUS_INFECTED: + case ERROR_VIRUS_DELETED: + message = "Your antivirus blocked " + name + ". Add the folder " + folder + + " to its exclusions (in Windows Security: Virus & threat protection > Exclusions), then try again."; + break; + case ERROR_ACCESS_DENIED: + message = name + " cannot be read: access was denied. An antivirus is probably blocking it: add the folder " + folder + + " to its exclusions, then try again."; + break; + case ERROR_SHARING_VIOLATION: + case ERROR_LOCK_VIOLATION: + message = name + " is in use by another program, usually an antivirus scan. Close Skate if it is running, wait a moment and try again; " + "if it keeps happening, add the folder " + folder + " to your antivirus exclusions."; + break; + case ERROR_FILE_NOT_FOUND: + case ERROR_PATH_NOT_FOUND: + message = name + " is missing from " + folder + ". If it was just downloaded, an antivirus removed it: add the folder to its " + "exclusions, then try again."; + break; + default: + message = "Cannot read " + name + " in " + folder; + break; + } + throw std::runtime_error(message + " (Windows error " + std::to_string(error) + ")"); +} } // namespace #endif // _WIN32 std::string sha256_file(const fs::path& path) { #ifdef _WIN32 - Handle file(CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_DELETE, - nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_SEQUENTIAL_SCAN, nullptr)); - if (file.get() == INVALID_HANDLE_VALUE) detail::fail("Cannot open file for SHA-256"); + const auto file = open_to_read(path); Algorithm algorithm; if (BCryptOpenAlgorithmProvider(&algorithm.value, BCRYPT_SHA256_ALGORITHM, nullptr, 0) < 0) diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index 486272ae..a2f7ef58 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,24 @@ 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 its role colour, and whether they show it, and the animated items + // that come with it, to everyone. + std::string identity_tag; + std::uint32_t identity_tag_colour{}; + bool identity_tag_shown{true}, identity_items_shown{true}; + // Local: how each of this player's marked cosmetics is coloured, for the Special page: what + // they wear in each slot ("Top", "Shoes", ...), then the parts of their board. mode: 0 what + // their list gives (`identity_animation`: "RAINBOW", "RED" or "GOLD"), 1 off, 2 a gradient + // between the two colours they picked, 3 the first of them alone. speed: 0 normal, 1 slow, + // 2 fast. + struct IdentityStyle { + std::string name; + int mode{}, speed{}; + std::array from{}, to{}; + }; + std::vector identity_styles; + std::string identity_animation; float voice_range = default_voice_range; // how far the host (or server) forwards proximity voice // In a dedicated server's session: the server is the host but not a player. // 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..53115122 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) { @@ -298,27 +385,39 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, // The mod's unchanged copies of assets another mod changed: the change // goes into the merged patch, next to this bundle's other files. std::map overridden; - if (propagate && casBacked) { + if (propagate && (casBacked || !region.inlineManifest.empty())) { std::string example; - for (std::size_t index = 0; index < casManifest.ebx.size(); ++index) { - auto& asset = casManifest.ebx[index]; - const auto named = overrides.changed.find(lower(asset.name)); - if (named == overrides.changed.end()) continue; - const auto change = named->second.find(asset.sha1); - const auto at = 1 + index; - if (change == named->second.end() || change->second.mod == modName || at >= region.files.size()) - continue; - try { - overridden[at] = store.write(region.files[at].location.installChunk, manifestArchive, - change->second.encoded); - asset.sha1 = change->second.sha1; - asset.originalSize = change->second.originalSize; - if (example.empty()) example = asset.name + " from " + change->second.mod; - } catch (const std::exception& error) { - report.notes.push_back(modName + ": " + bundle.name + ": " + asset.name + - " kept the game's copy, the change could not be copied (" + error.what() + ")"); + auto manifest = casBacked ? std::move(casManifest) : fb::read_binary_bundle(region.inlineManifest); + const auto replace = [&](std::vector& assets, const auto& changes, std::size_t first) { + for (std::size_t index = 0; index < assets.size(); ++index) { + auto& asset = assets[index]; + const auto named = changes.find(lower(asset.name)); + if (named == changes.end()) continue; + const auto change = named->second.find(asset.sha1); + const auto at = (casBacked ? 1 : 0) + first + index; + if (change == named->second.end() || change->second.mod == modName || at >= region.files.size()) + continue; + const auto& resource = change->second.resource; + if (asset.kind == fb::AssetKind::resource && + (!resource || resource->resourceType != asset.resourceType || resource->resourceId != asset.resourceId)) + continue; + try { + overridden[at] = store.write(region.files[at].location.installChunk, manifestArchive, + change->second.encoded); + asset.sha1 = change->second.sha1; + asset.originalSize = change->second.originalSize; + if (resource) asset.resourceMeta = resource->resourceMeta; + if (example.empty()) example = asset.name + " from " + change->second.mod; + } catch (const std::exception& error) { + report.notes.push_back(modName + ": " + bundle.name + ": " + asset.name + + " kept the game's copy, the change could not be copied (" + error.what() + ")"); + } } - } + }; + replace(manifest.ebx, overrides.changed, 0); + replace(manifest.resources, overrides.scripts, manifest.ebx.size()); + if (casBacked) casManifest = std::move(manifest); + else if (!overridden.empty()) region.inlineManifest = fb::write_binary_bundle(manifest); if (!overridden.empty()) report.notes.push_back(modName + ": " + bundle.name + ": " + std::to_string(overridden.size()) + " asset(s) take another mod's change, e.g. " + example); @@ -431,9 +530,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 +582,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 +615,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 +649,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 +703,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 +722,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 +764,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/Engine/Vfs/mod_merge_internal.h b/Engine/Vfs/mod_merge_internal.h index af6dc054..6d864ed1 100644 --- a/Engine/Vfs/mod_merge_internal.h +++ b/Engine/Vfs/mod_merge_internal.h @@ -174,7 +174,11 @@ struct AssetOverride { fb::Sha1 sha1; // the mod's version std::uint64_t originalSize{}; std::vector encoded; // its payload, as stored in cas + // Present for a Lua resource replacement: its identity and metadata must + // travel with the script bytes, including source and bytecode sizes. + std::optional resource; }; +inline constexpr std::uint32_t luaScriptResourceType = 0xEC383B87; // An EBX asset a mod added to one of the game's bundles, or a resource it added // under the same name as such an asset (a wave's sound-bank). Maps carry their own // copy of a bundle like bam_coregameassets, and that copy loads instead of the @@ -192,6 +196,8 @@ struct AssetAddition { struct AssetOverrides { // By lower-case asset name, then the game's sha1 the change replaces. std::map, std::less<>> changed; + // Separate from EBX: a script's EBX wrapper can have the same asset name. + std::map, std::less<>> scripts; // By lower-case bundle name, in priority order. std::map, std::less<>> added; // By mod folder name: TOC chunks the mod adds that its carried additions name @@ -200,11 +206,11 @@ struct AssetOverrides { // the mod's own archives; the merge shifts them to where those archives landed. std::map, std::less<>> chunks; - [[nodiscard]] bool empty() const noexcept { return changed.empty() && added.empty(); } + [[nodiscard]] bool empty() const noexcept { return changed.empty() && scripts.empty() && added.empty(); } }; // The changes and additions asset mods (mods that add no levels) make to the -// game's own EBX, the highest-priority mod's version winning. Never throws: an +// game's own EBX and Lua scripts, the highest-priority mod's version winning. Never throws: an // unreadable mod is noted and simply changes nothing elsewhere. AssetOverrides collect_asset_overrides(const std::vector& mods, const std::map& modFiles, diff --git a/Engine/Vfs/mod_merge_overrides.cpp b/Engine/Vfs/mod_merge_overrides.cpp index 17e9adaf..d69a742b 100644 --- a/Engine/Vfs/mod_merge_overrides.cpp +++ b/Engine/Vfs/mod_merge_overrides.cpp @@ -78,6 +78,30 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, imported.insert(reference.fileGuid); } catch (const std::exception&) {} } + // Maps carry stock SkaterLoader too. Replacing it only in the game's + // bundles loses custom cosmetic material targets on a map transition. + // Other resource types retain their own table/dependency merge rules. + std::map> gameScripts; + for (const auto& asset : gameListing->manifest.resources) + if (asset.resourceType == luaScriptResourceType) gameScripts.emplace(lower(asset.name), &asset); + for (std::size_t index = 0; index < modListing->manifest.resources.size(); ++index) { + const auto& asset = modListing->manifest.resources[index]; + if (asset.resourceType != luaScriptResourceType) continue; + const auto name = lower(asset.name); + const auto originalScript = gameScripts.find(name); + if (originalScript == gameScripts.end() || originalScript->second->sha1 == asset.sha1 || + originalScript->second->resourceId != asset.resourceId) continue; + auto& versions = out.scripts[name]; + if (versions.contains(originalScript->second->sha1)) continue; + const auto at = modListing->first + modListing->manifest.ebx.size() + index; + if (at >= modListing->files.size()) continue; + const auto& file = modListing->files[at]; + versions.emplace(originalScript->second->sha1, + AssetOverride{mod->name, asset.sha1, asset.originalSize, + store.read(file.location.patch ? mod->directory : baseRoot, + file.location, file.offset, file.size), asset}); + ++changed; + } // Resources the mod adds, kept to go with an added EBX of the same name // (a wave's sound-bank resource registers the wave with the audio system). std::set> shippedResources; diff --git a/Extension/Customization/developer_board.cpp b/Extension/Customization/developer_board.cpp index 0df1cbf1..5b5442f9 100644 --- a/Extension/Customization/developer_board.cpp +++ b/Extension/Customization/developer_board.cpp @@ -20,12 +20,12 @@ void report_board(std::uintptr_t entity, std::uint64_t steam_id, std::string_vie } if (entry->entity == entity && (entry->message == hash || now - entry->at < 5000)) return; *entry = {entity, now, hash}; - logging::log(level, logging::Channel::customization, "Developer board (Steam {}, actor {:#x}): {}", steam_id, entity, message); + logging::log(level, logging::Channel::customization, "Board items (Steam {}, actor {:#x}): {}", steam_id, entity, message); } bool write_color(std::uintptr_t base, const developer_board_detail::Binding &binding, const developer_board_detail::Color &color) noexcept { __try { if (!binding.node) { - const developer_board_detail::NativeColor value{color, 0, binding.type}; + const developer_board_detail::NativeColor value{color, 0, base + addr::native_cosmetics::color_rgb_type}; // Let the engine allocate/copy/hash a missing typed parameter. Priority // zero allows later native appearance presets to replace this override. return reinterpret_cast; +Animations board_animations(std::uint64_t steam_id, const multiplayer::MarkStyles &styles) noexcept { + const auto mark = multiplayer::identity_mark(steam_id); + Animations out; + for (std::size_t i = 0; i < out.size(); ++i) + out[i] = developer_board_detail::item_animation(mark, styles[multiplayer::skater_mark_items + i]); + return out; +} +bool any_on(const Animations &animations) noexcept { + return std::any_of(animations.begin(), animations.end(), [](const auto &animation) { return animation.on; }); +} } // 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); + std::uint64_t generation, DeveloperBoardState &state, + const multiplayer::MarkStyles &styles) noexcept { + const auto animations = board_animations(steam_id, styles); + const bool listed = any_on(animations); if (!board) { state = {}; return; } - if (!developer && !state.count) return; + if (!listed && state.colors.empty()) 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 +78,16 @@ 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); - const auto wanted = static_cast(std::count_if(live.bindings.begin(), live.bindings.begin() + live.count, - [](const auto &binding) { return binding.eligible; })); + developer_board_detail::animate(state, live, board, generation, animations, GetTickCount64(), write, publish); + // The colours of the parts that are on: found, and taken. + const auto wanted = std::count_if(live.bindings.begin(), live.bindings.end(), + [&](const auto &binding) { return animations[binding.part].on; }); + const auto taken = std::count_if(state.colors.begin(), state.colors.end(), + [&](const auto &saved) { return animations[saved.binding.part].on; }); 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 && taken < 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 && taken) report_board(board, steam_id, "animation active on the board", logging::Level::info); } catch (const std::exception &error) { report_board(board, steam_id, error.what(), logging::Level::warning); } catch (...) {} @@ -81,15 +97,16 @@ 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_items_shown() ? social->local.id : 0; + const auto styles = developer_hoodie_detail::own_styles.load(); + if (!any_on(board_animations(id, styles)) && state.colors.empty()) 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); }; // Cosmetic changes can replace the board while retaining the skater. // Re-resolve the actual local association, rather than capture's cached board. const auto board = multiplayer::read_native_board(read, base, local.entity); - update_developer_board(base, board.entity, id, local.context, state); + update_developer_board(base, board.entity, id, local.context, state, styles); } catch (...) {} } } // namespace dingosdk diff --git a/Extension/Customization/developer_board.h b/Extension/Customization/developer_board.h index 678eb74a..fbb203e3 100644 --- a/Extension/Customization/developer_board.h +++ b/Extension/Customization/developer_board.h @@ -2,7 +2,10 @@ #include "developer_board_material.h" namespace dingosdk { +// Colours the board a listed player rides: `styles` is how they have each marked cosmetic, the +// board's deck, grip tape, trucks and wheels after the skater's. void update_developer_board(std::uintptr_t base, std::uintptr_t board, std::uint64_t steam_id, - std::uint64_t generation, DeveloperBoardState &state) noexcept; + std::uint64_t generation, DeveloperBoardState &state, + const multiplayer::MarkStyles &styles) noexcept; void tick_local_developer_board(std::uintptr_t base, std::uintptr_t client, bool ready) noexcept; } // namespace dingosdk diff --git a/Extension/Customization/developer_board_material.h b/Extension/Customization/developer_board_material.h index 1b432b9c..fb0c95bd 100644 --- a/Extension/Customization/developer_board_material.h +++ b/Extension/Customization/developer_board_material.h @@ -4,9 +4,15 @@ namespace dingosdk { namespace developer_board_detail { +using developer_hoodie_detail::Binding; using developer_hoodie_detail::Color; +using developer_hoodie_detail::ItemAnimation; +using developer_hoodie_detail::ParameterKey; +using developer_hoodie_detail::animate; +using developer_hoodie_detail::item_animation; +using developer_hoodie_detail::item_color; using developer_hoodie_detail::material_hash; -using developer_hoodie_detail::rainbow; +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}; @@ -14,58 +20,40 @@ inline constexpr std::array slots{ "Characters/Customization/Board/Slots/Skateboard_Deck_Slot", "Characters/Customization/Board/Slots/Skateboard_Trucks_Slot", "Characters/Customization/Board/Slots/Skateboard_Wheels_Slot"}; +// One colour of the board: a named material of the controller assigned one of `slots`, the +// shader key, and which of the board's marked cosmetics it colours (deck, grip tape, trucks, +// wheels: the order of their styles after the skater's, multiplayer::mark_item_names). struct Target { - std::string_view asset, material; - std::uint32_t recipe_slot; + std::string_view material; std::size_t controller; std::uint64_t key; + std::uint8_t part; }; +inline constexpr std::size_t part_count = multiplayer::mark_items - multiplayer::skater_mark_items; // The board loader registers Deck_Mat / DeckTop_Mat for the compiled sections -// Skateboard_base_mat / mat_skateboard_top. Use the live named map identities. -// Recipe identities, assigned board controllers and serialized shader keys from -// the four pink cosmetics. The deck's wood follows its Solid Pink graphic. +// Skateboard_base_mat / mat_skateboard_top, whatever deck is on it: use the live named map +// identities. The deck's wood follows its graphic. inline constexpr std::array targets{{ - {"Own_DeckGraphic_Gen_Popsicle_00021", "Deck_Mat", 0x66071628, 0, graphic_color}, - {"Own_DeckGraphic_Gen_Popsicle_00021", "Deck_Mat", 0x66071628, 0, base_color}, - {"Own_DeckGripColor_Gen_Popsicle_00006", "DeckTop_Mat", 0x755d5ee0, 0, graphic_color}, - {"Own_Truck_Gen_Default_00002", "Truck_Mat", 0x0add98be, 1, base_color}, - {"Own_WheelColor_Gen_Classic_00005", "Wheel_mat", 0xc83bb94e, 2, base_color}, + {"Deck_Mat", 0, graphic_color, 0}, + {"Deck_Mat", 0, base_color, 0}, + {"DeckTop_Mat", 0, graphic_color, 1}, + {"Truck_Mat", 1, base_color, 2}, + {"Wheel_mat", 2, base_color, 3}, }}; -// Native ParamDbKey and inline NativeValue layout used by addEsVector's setter. -// The key's first word is the final two serialized GUID bytes. Its type index -// comes from the live ColorRgb descriptor, not a serialized Vec3 or boxed value. -struct ParameterKey { - std::uint16_t identity{}, size{}, type{}, count{}; - std::uint64_t hash{}; -}; +// The inline NativeValue layout used by addEsVector's setter. Its type is the live ColorRgb +// descriptor, not a serialized Vec3 or boxed value. struct NativeColor { Color color{}; std::uint32_t padding{}; std::uintptr_t type{}; std::uint32_t flags{3}, reserved{}; }; -static_assert(sizeof(ParameterKey) == 16 && offsetof(ParameterKey, hash) == 8); static_assert(sizeof(NativeColor) == 32 && offsetof(NativeColor, type) == 16 && offsetof(NativeColor, flags) == 24); inline ParameterKey parameter_key(std::uint64_t hash, std::uint16_t type) noexcept { return {static_cast(hash == graphic_color ? 0xd442 : 0x8c54), sizeof(Color), type, 1, hash}; } -struct Binding { - std::uintptr_t item{}, material{}, node{}, type{}; - ParameterKey key{}; - Color color{}; - bool eligible{}; -}; -struct SavedColor { - Binding binding; - Color original{}, last{}; -}; inline constexpr std::size_t max_bindings = targets.size() * 4; -struct Materials { - std::uintptr_t component{}, appearance{}; - std::array bindings{}; - std::size_t count{}; - bool pending{}; -}; +using developer_hoodie_detail::Materials; template std::uintptr_t parameter(Memory &m, std::uintptr_t material, std::uint64_t key) { const auto buckets = m.ptr(material, 0x268); @@ -85,21 +73,9 @@ template Materials materials(Read &read, std::uintptr_t base, std::u using namespace multiplayer; CosmeticMemory m{read, base}; Materials out; - m.check(m.ptr(entity) == base + addr::engine::board_entity_vtable, "RGB board actor type differs."); + m.check(m.ptr(entity) == base + addr::engine::board_entity_vtable, "Board actor type differs."); out.component = m.component(entity); out.pending = m.template get(out.component, 0x10a) || m.template get(out.component, 0x10d); - const auto recipe = m.ptr(out.component, 0x158); - const auto count = m.count(recipe, sizeof(NativeCosmeticItem), max_cosmetic_slots); - std::array eligible{}; - for (std::size_t i = 0; i < count; ++i) { - const auto item = m.template get(recipe, i * sizeof(NativeCosmeticItem)); - for (std::size_t t = 0; t < targets.size(); ++t) { - const auto &target = targets[t]; - if (item.slot != target.recipe_slot || item.hash != cosmetic_asset_hash(target.asset)) continue; - const auto name = item.asset ? m.text(reinterpret_cast(item.asset)) : std::string{}; - eligible[t] = name.empty() || name == target.asset; - } - } const auto type = base + addr::native_cosmetics::color_rgb_type; const auto info = m.ptr(type); m.check(info >= base && info < base + 0x09144000 && m.template get(info) == 0x885eff59 && @@ -126,8 +102,7 @@ template Materials materials(Read &read, std::uintptr_t base, std::u if (!total) { out.pending = true; continue; } m.check(buckets && size && size <= 256 && total <= 128, "Board material map exceeds bounds."); const auto sentinel = m.ptr(buckets, size * 8ULL); - for (std::size_t t = 0; t < targets.size(); ++t) { - const auto &target = targets[t]; + for (const auto &target : targets) { if (target.controller != slot) continue; const auto hash = material_hash(target.material); auto node = m.ptr(buckets, (hash % size) * 8ULL); @@ -152,62 +127,17 @@ template Materials materials(Read &read, std::uintptr_t base, std::u color = m.template get(p, 0x10); m.check(std::all_of(color.begin(), color.end(), [](float c) { return std::isfinite(c); }), "Board color is invalid."); } - m.check(out.count < out.bindings.size(), "Board colors exceed bounds."); - out.bindings[out.count++] = {item, material, p, type, key, color, eligible[t]}; + m.check(out.bindings.size() < max_bindings, "Board colors exceed bounds."); + out.bindings.push_back({item, material, p, key, color, target.part}); } } } return out; } -inline bool same_binding(const Binding &live, const Binding &saved) noexcept { - // A successful native insertion acquires its node on the next fresh walk. - return live.item == saved.item && live.material == saved.material && live.type == saved.type && live.key.hash == saved.key.hash && - (saved.node ? live.node == saved.node : live.node != 0); -} } // namespace developer_board_detail -struct DeveloperBoardState { - std::uintptr_t entity{}, component{}, appearance{}; - std::uint64_t generation{}; - std::array colors{}; - std::size_t count{}; -}; -namespace developer_board_detail { -// Re-discover ownership before every write. Changing the recipe restores only -// colors that still equal our last write; native preset updates take precedence. -// Missing keys inherit .5 from these compiled board shaders. Keep inserted -// overrides at native preset priority and restore that default on deactivation. +// What was saved of a board's parts while they are coloured. A key a part's material does not +// have inherits .5 from the compiled board shaders: one is added at native preset priority, so +// a later native preset replaces it, and goes back to that default when the colouring stops. // 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) { - 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); - std::array changed_items{}; - std::size_t changed{}; - for (std::size_t i = 0; i < live.count; ++i) { - const auto &binding = live.bindings[i]; - const SavedColor *saved{}; - 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) { - 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); - if (!wrote) next.colors[next.count++] = *saved; - } - if (wrote && std::find(changed_items.begin(), changed_items.begin() + changed, binding.item) == changed_items.begin() + changed) - changed_items[changed++] = binding.item; - } - state = next; - for (std::size_t i = 0; i < changed; ++i) publish(changed_items[i]); -} -} // namespace developer_board_detail +struct DeveloperBoardState : developer_hoodie_detail::ItemState {}; } // namespace dingosdk diff --git a/Extension/Customization/developer_hoodie.cpp b/Extension/Customization/developer_hoodie.cpp index 0cb4dc84..a5541ac0 100644 --- a/Extension/Customization/developer_hoodie.cpp +++ b/Extension/Customization/developer_hoodie.cpp @@ -5,6 +5,7 @@ #include "Extension/Multiplayer/Remote/native_skater.h" #include #include +#include namespace dingosdk { namespace { @@ -23,7 +24,7 @@ void report_hoodie(std::uintptr_t entity, std::uint64_t steam_id, std::string_vi } if (entry->entity == entity && (entry->message == hash || now - entry->at < 5000)) return; *entry = {entity, now, hash}; - logging::log(level, logging::Channel::customization, "Developer hoodie (Steam {}, actor {:#x}): {}", + logging::log(level, logging::Channel::customization, "Skater items (Steam {}, actor {:#x}): {}", steam_id, entity, message); } // NativeValue is owned by this material. Preserve its padding, type, priority, @@ -41,12 +42,34 @@ bool publish_materials(std::uintptr_t base, std::uintptr_t item) noexcept { return true; } __except (EXCEPTION_EXECUTE_HANDLER) { return false; } } +// What each of the skater's marked cosmetics does for this player: top, bottoms, shoes, ... +using Animations = std::array; +Animations skater_animations(std::uint64_t steam_id, const multiplayer::MarkStyles &styles) noexcept { + const auto mark = multiplayer::identity_mark(steam_id); + Animations out; + for (std::size_t i = 0; i < out.size(); ++i) out[i] = developer_hoodie_detail::item_animation(mark, styles[i]); + return out; +} +bool any_on(const Animations &animations) noexcept { + return std::any_of(animations.begin(), animations.end(), [](const auto &animation) { return animation.on; }); +} +// Which cosmetics are coloured, and how many colours of each: what is equipped decides. +std::string coloured(const DeveloperHoodieState &state) { + std::array colors{}; + for (const auto &saved : state.colors) ++colors[saved.binding.part]; + std::string out; + for (std::size_t i = 0; i < colors.size(); ++i) + if (colors[i]) out += std::format("{}{} ({})", out.empty() ? "" : ", ", multiplayer::mark_item_names[i], colors[i]); + return out.empty() ? "nothing equipped has a color to change" : "animation active on " + out; +} } // 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); + std::uint64_t generation, DeveloperHoodieState &state, + const multiplayer::MarkStyles &styles) noexcept { + const auto animations = skater_animations(steam_id, styles); + const bool listed = any_on(animations); if (!entity) { state = {}; return; } - if (!developer && !state.count) return; + if (!listed && state.colors.empty()) return; try { auto read = [](std::uintptr_t address, void *out, std::size_t size) { return memory::peek_bytes(address, out, size); }; std::array prefix{}; @@ -57,12 +80,10 @@ 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); + auto publish = [base, &published](std::uintptr_t item) { if (!publish_materials(base, item)) published = false; }; + developer_hoodie_detail::animate(state, live, entity, generation, animations, 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) - report_hoodie(entity, steam_id, "waiting for the assigned top slot's color parameters", logging::Level::warning); + else if (listed && !live.pending) report_hoodie(entity, steam_id, coloured(state), logging::Level::info); } catch (const std::exception &error) { report_hoodie(entity, steam_id, error.what(), logging::Level::warning); } catch (...) { @@ -77,10 +98,11 @@ 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_items_shown() ? social->local.id : 0; + const auto styles = developer_hoodie_detail::own_styles.load(); + if (!any_on(skater_animations(id, styles)) && state.colors.empty()) return; const auto local = multiplayer::capture_local(base, client, false); - if (local.ready) update_developer_hoodie(base, local.entity, id, local.context, state); + if (local.ready) update_developer_hoodie(base, local.entity, id, local.context, state, styles); else report_hoodie(client, id, local.detail, logging::Level::warning); } catch (...) {} } diff --git a/Extension/Customization/developer_hoodie.h b/Extension/Customization/developer_hoodie.h index 3842763a..97235e98 100644 --- a/Extension/Customization/developer_hoodie.h +++ b/Extension/Customization/developer_hoodie.h @@ -4,7 +4,10 @@ namespace dingosdk { // Verified client update thread only. Remote identity comes from the Steam roster, // generation from the current native actor; neither is a client cosmetic parameter. +// Colours what a listed player wears (developer_hoodie_material.h): `styles` is how they have +// each marked cosmetic, the skater's first. void update_developer_hoodie(std::uintptr_t base, std::uintptr_t entity, std::uint64_t steam_id, - std::uint64_t generation, DeveloperHoodieState &state) noexcept; + std::uint64_t generation, DeveloperHoodieState &state, + const multiplayer::MarkStyles &styles) noexcept; void tick_local_developer_hoodie(std::uintptr_t base, std::uintptr_t client, bool ready) noexcept; } // namespace dingosdk diff --git a/Extension/Customization/developer_hoodie_material.h b/Extension/Customization/developer_hoodie_material.h index 1c3d2b24..29e0a48f 100644 --- a/Extension/Customization/developer_hoodie_material.h +++ b/Extension/Customization/developer_hoodie_material.h @@ -1,48 +1,93 @@ #pragma once #include "Extension/Multiplayer/developer_identity.h" +#include "Extension/Multiplayer/Remote/cosmetics.h" #include "Extension/Multiplayer/Remote/native_cosmetics_layout.h" #include #include +#include #include +#include #include +#include +#include +#include +#include +// The cosmetics of a player on one of the backend's lists (developer_identity.h) are coloured +// for them: whatever they wear and ride, on their own material instances, as they choose on the +// menu's Special page. It began as the developers' rainbow hoodie and pink board, which the +// names here still say. namespace dingosdk { namespace developer_hoodie_detail { -inline constexpr std::string_view asset = "Own_TopShirt_Gen_HoodieRelaxed_00001"; -// The compiled appearance preset names controllers by their assigned CAS slot. -// "TopItem" is a Lua construction name, not the live controller's name. -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. -inline constexpr std::array color_keys{ - 0x8c46cd354b18a9e2ULL, 0x1567b5742f4ec0b2ULL, 0x1564a942cb33c53bULL, - 0xf526305270d94cb3ULL, 0x99879bf202d1960aULL, 0x998450425dbd9044ULL}; +// The compiled appearance preset names a skater's item controllers by their assigned CAS slot +// ("TopItem" and the like are Lua construction names, not the live controllers' names). +inline constexpr std::string_view slot_folder = "Characters/MainCharacters/Generic/CAS/Common/Slots/"; +// The skater's marked cosmetics, in the order of their styles (multiplayer::mark_item_names): +// top, bottoms, shoes, socks, hat, glasses, outfit, costume. +inline constexpr std::array slots{ + "CAS_Top_Slot", "CAS_Bottom_Slot", "CAS_Footwear_Slot", "CAS_Sock_Slot", + "CAS_Headgear_Slot", "CAS_Eyewear_Slot", "CAS_Outfit_Slot", "CAS_CostumeFullBody_Slot"}; +// Serialized ParamDbKey identities of the ColorRgb parameters the game's clothing presets set +// (AppearanceShaderExpressionPreset.ParamInfo; read from 114 presets of every kind of item, +// 2026-10-04): the colours of an item's regions. Every kind of clothing draws on the same +// ones, the first thirteen by far the most, and each item colours some and leaves the rest at +// the shaders' neutral grey (`neutral`), where its textures show as they are. +// On the relaxed hoodie, 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. +inline constexpr std::array color_keys{ + 0x8c46cd354b18a9e2ULL, 0x1567b5742f4ec0b2ULL, 0x1564a942cb33c53bULL, 0xf526305270d94cb3ULL, + 0x99879bf202d1960aULL, 0x998450425dbd9044ULL, 0x18984e85c679da8dULL, 0x2bf96fc9d8da57bbULL, + 0x13cfb51bd8474129ULL, 0x7c06880748ded394ULL, 0xfca496001ea60718ULL, 0xfca79ddc325249ceULL, + 0x5e1943a7c20ab77cULL, 0x0adafe3e13df4057ULL, 0xe3ab5f6a33dd1684ULL, 0xc31ad6ca613dcf43ULL, + 0xc1c70aa67f8bde7cULL, 0xc1c4554f6b58eee8ULL, 0x24e6723cc9f17eccULL, 0xd98b435969485311ULL, + 0x27e785e804edea5cULL, 0x27e45bf8d67d959fULL, 0xae823ac5acb2abbaULL, 0x89cbc6b088ae9b1bULL, + 0x593aacb9e12dcd6bULL, 0x4ec6aeb742a2c383ULL, 0xa19416e977675f88ULL, 0x0a94b0e28d102968ULL}; using Color = std::array; +inline constexpr Color neutral{.5f, .5f, .5f}; +// Native ParamDbKey, as a material's parameter node holds it. The first word is the final two +// serialized GUID bytes; the type index is the live ColorRgb descriptor's. +struct ParameterKey { + std::uint16_t identity{}, size{}, type{}, count{}; + std::uint64_t hash{}; +}; +static_assert(sizeof(ParameterKey) == 16 && offsetof(ParameterKey, hash) == 8); +// One colour of one material instance. `part` is which marked cosmetic it belongs to. struct Binding { - std::uintptr_t material{}, node{}, type{}; - std::uint64_t key{}; + std::uintptr_t item{}, material{}, node{}; // node 0: no such parameter yet (the board adds one) + ParameterKey key{}; Color color{}; - bool operator==(const Binding &) const = default; + std::uint8_t part{}; }; struct SavedColor { Binding binding; Color original{}, last{}; + std::uint8_t settling{}; // publishes still owed for `last` (settle_publishes) }; -inline constexpr std::size_t max_bindings = color_keys.size() * 4; +// The colours found on an actor in one walk. struct Materials { - std::uintptr_t component{}, appearance{}, item{}; - std::array bindings{}; - std::size_t count{}; - bool eligible{}; + std::uintptr_t component{}, appearance{}; + std::vector bindings; + bool pending{}; + // Clothing: a colour at `neutral` is a region the item leaves to its textures, and is left + // alone, unless the item colours none of its regions: then all of them are coloured. + bool regions_only{}; }; +// What was saved of an actor's colours while they are coloured here. +struct ItemState { + std::uintptr_t entity{}, component{}, appearance{}; + std::uint64_t generation{}; + std::vector colors; +}; +// What is equipped can be anything: this many colours of a skater, and no more. +inline constexpr std::size_t max_bindings = 512; inline std::uint32_t material_hash(std::string_view name) noexcept { std::uint32_t hash = 5381; for (auto c : name) hash = hash * 33 ^ static_cast(c >= 'A' && c <= 'Z' ? c + 32 : c); return hash; } -inline Color rainbow(std::uint64_t milliseconds) noexcept { - // One continuous hue cycle every eight seconds, in the material's existing gamut. - const auto hue = static_cast(milliseconds % 8000) * (6.f / 8000.f); +inline Color rainbow(std::uint64_t milliseconds, std::uint64_t period = 8000) noexcept { + // One continuous hue cycle every eight seconds (or `period`), in the material's existing gamut. + const auto hue = static_cast(milliseconds % period) * (6.f / static_cast(period)); const auto sector = static_cast(hue); constexpr float high = .8f, low = .025f; const auto rising = low + (high - low) * (hue - static_cast(sector)); @@ -56,126 +101,267 @@ inline Color rainbow(std::uint64_t milliseconds) noexcept { default: return {high, low, falling}; } } +// What one of a listed player's marked cosmetics does right now: nothing, the rainbow, or a +// walk through three colours and back (one colour three times stands still). +struct ItemAnimation { + bool on{}, rainbow{}; + std::array stops{}; + std::uint64_t period = 3000; // milliseconds for a round +}; +// A colour as picked on screen, as the material takes it: linear, and inside the gamut the +// rainbow keeps to. +inline Color material_color(const std::array &picked) noexcept { + Color out{}; + for (std::size_t i = 0; i < out.size(); ++i) + out[i] = std::max(.004f, std::pow(static_cast(picked[i]) / 255.f, 2.2f) * .8f); + return out; +} +// The colours the pickers start from: about where each list's own animation begins and ends. +inline std::pair, std::array> standard_picks(multiplayer::IdentityList mark) noexcept { + using multiplayer::IdentityList; + if (mark == IdentityList::content_creator) return {{0x88, 0x23, 0x23}, {0xff, 0x48, 0x70}}; + if (mark == IdentityList::homie) return {{0xad, 0x6c, 0x1c}, {0xff, 0xe6, 0x67}}; + return {{0x6e, 0x20, 0xff}, {0xb4, 0x78, 0xff}}; +} +// What a cosmetic does for a player on `mark`'s list (nobody else's does anything) with the +// style they chose on the Special page. Left standard, a developer's cycles the rainbow, a +// content creator's goes from deep red through bright red to a reddish pink and back, and a +// homie's from deep gold through bright gold to yellow and back. No style asks for the rainbow: +// it is a developer's standard, and nobody else's. +inline ItemAnimation item_animation(std::optional mark, const multiplayer::MarkStyle &style) noexcept { + using multiplayer::IdentityList; + using multiplayer::MarkMode; + ItemAnimation out; + if (!mark || style.mode == MarkMode::off) return out; + out.on = true; + out.rainbow = style.mode == MarkMode::standard && *mark == IdentityList::developer; + const std::uint64_t normal = out.rainbow ? 8000 : 3000; + out.period = style.speed == 1 ? normal * 2 : style.speed == 2 ? normal / 2 : normal; + if (style.mode == MarkMode::solid) { + out.stops.fill(material_color(style.from)); + } else if (style.mode == MarkMode::gradient) { + const auto from = material_color(style.from), to = material_color(style.to); + out.stops = {from, Color{(from[0] + to[0]) * .5f, (from[1] + to[1]) * .5f, (from[2] + to[2]) * .5f}, to}; + } else if (!out.rainbow) { + // 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. + 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}}}; + out.stops = *mark == IdentityList::content_creator ? red : gold; + } + return out; +} +inline Color item_color(const ItemAnimation &animation, std::uint64_t milliseconds) noexcept { + if (animation.rainbow) return rainbow(milliseconds, animation.period); + // 0 to 2 and back, once a round. + const auto along = 1.f - std::cos(static_cast(milliseconds % animation.period) * + (6.2831853f / static_cast(animation.period))); + const auto &from = animation.stops[along < 1.f ? 0 : 1], &to = animation.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}; +} +// The local player's own styles (the Special page): sent with their appearance, and kept in +// their profile as text, two hex digits a byte. +inline std::atomic own_styles{}; +inline std::string mark_styles_text(const multiplayer::MarkStyles &styles) { + constexpr char digits[] = "0123456789abcdef"; + std::string text; + const auto add = [&](std::uint8_t byte) { text += digits[byte >> 4], text += digits[byte & 15]; }; + for (const auto &style : styles) { + add(static_cast(style.mode)); + for (const auto part : style.from) add(part); + for (const auto part : style.to) add(part); + add(style.speed); + } + return text; +} +inline std::optional parse_mark_styles(std::string_view text) noexcept { + multiplayer::MarkStyles styles{}; + if (text.size() != styles.size() * 16) return std::nullopt; + std::size_t at = 0; + bool valid = true; + const auto next = [&]() -> std::uint8_t { + unsigned byte = 0; + for (int digit = 0; digit < 2; ++digit) { + const char c = text[at++]; + if (c >= '0' && c <= '9') byte = byte * 16 + static_cast(c - '0'); + else if (c >= 'a' && c <= 'f') byte = byte * 16 + static_cast(c - 'a' + 10); + else valid = false; + } + return static_cast(byte); + }; + for (auto &style : styles) { + style.mode = static_cast(next()); + for (auto &part : style.from) part = next(); + for (auto &part : style.to) part = next(); + style.speed = next(); + valid = valid && multiplayer::valid_mark_style(style); + } + return valid ? std::optional(styles) : std::nullopt; +} +// A material instance's own parameters: a hash map of typed nodes. +struct ParameterMap { + std::uintptr_t buckets{}, stop{}; + std::uint32_t size{}, total{}; +}; +template ParameterMap parameter_map(Memory &m, std::uintptr_t material) { + ParameterMap out{m.ptr(material, 0x268), 0, m.template get(material, 0x270), + m.template get(material, 0x274)}; + if (!out.buckets || !out.size || out.size > 1024 || !out.total || out.total > 512) return {}; + out.stop = m.ptr(out.buckets, out.size * 8ULL); + return out; +} +// The node of a material's ColorRgb parameter with this key, or 0. The EBX boxed ColorRgb has a +// serialized gamut word; the loaded native ColorRgb is three floats (12 bytes): this goes by +// the actual type descriptor, not the serialized asset size. A parameter of another shape is +// passed over, not reported: what is equipped can be anything. +template +std::uintptr_t color_parameter(Memory &m, std::uintptr_t base, const ParameterMap &map, std::uint64_t key, ParameterKey &native, + Color &color) { + if (!map.size) return 0; + auto node = m.ptr(map.buckets, (key % map.size) * 8ULL); + for (std::uint32_t visited = 0; node && node != map.stop && visited < map.total; ++visited, node = m.ptr(node, 0x40)) { + if (m.template get(node, 8) != key) continue; + native = m.template get(node); + const auto type = m.ptr(node, 0x20); + if (type < base || type >= base + 0x09144000 || native.size != sizeof(Color) || native.count != 1 || + !(m.template get(node, 0x28) & 1) || m.template get(type, 8) != native.type) + return 0; + const auto info = m.ptr(type); + if (m.template get(info) != 0x885eff59U || m.template get(info, 6) != sizeof(Color)) return 0; + color = m.template get(node, 0x10); + return std::all_of(color.begin(), color.end(), [](float c) { return std::isfinite(c); }) ? node : 0; + } + return 0; +} // 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) { using namespace multiplayer; CosmeticMemory m{read, base}; Materials out; - m.check(m.ptr(entity) == base + addr::engine::skater_entity_vtable, "Hoodie actor type differs."); + out.regions_only = true; + m.check(m.ptr(entity) == base + addr::engine::skater_entity_vtable, "Skater actor type differs."); out.component = m.component(entity); - const bool pending = m.template get(out.component, 0x10a) || m.template get(out.component, 0x10d); - const auto recipe = m.ptr(out.component, 0x158); - const auto items = m.count(recipe, sizeof(NativeCosmeticItem), max_cosmetic_slots); - const auto wanted = cosmetic_asset_hash(asset); - for (std::size_t i = 0; i < items; ++i) { - const auto item = m.template get(recipe, i * sizeof(NativeCosmeticItem)); - if (item.hash == wanted) { - // Native recipes may store only the hash (an empty CString). - const auto name = m.text(reinterpret_cast(item.asset)); - out.eligible = !pending && (name.empty() || name == asset); - } - } + out.pending = m.template get(out.component, 0x10a) || m.template get(out.component, 0x10d); out.appearance = read_native_component(read, entity, base + addr::engine::skater_appearance_vtable); const auto begin = m.ptr(out.appearance, 0x90), end = m.ptr(out.appearance, 0x98), capacity = m.ptr(out.appearance, 0xa0); m.check(begin <= end && end <= capacity && (end - begin) % 8 == 0 && (capacity - begin) % 8 == 0 && - (capacity - begin) / 8 <= 64 && (begin || !capacity), "Hoodie controller array exceeds bounds."); + (capacity - begin) / 8 <= 64 && (begin || !capacity), "Skater controller array exceeds bounds."); for (auto entry = begin; entry < end; entry += 8) { const auto item = m.ptr(entry); - if (!item || m.ptr(item, 0x48) != out.appearance + 0x40 || m.text(m.ptr(item, 0x30)) != top_slot) continue; - m.check(!out.item, "Hoodie has multiple top controllers."); - out.item = item; - if (!(m.template get(item, 0xb0) & 2)) { out.eligible = false; continue; } + if (!item || m.ptr(item, 0x48) != out.appearance + 0x40) continue; + const auto name = m.text(m.ptr(item, 0x30)); + if (!name.starts_with(slot_folder)) continue; + const auto slot = std::find(slots.begin(), slots.end(), std::string_view(name).substr(slot_folder.size())); + if (slot == slots.end()) continue; + const auto part = static_cast(slot - slots.begin()); + // A slot with nothing in it, or with an item still on its way, has no materials. + if (!(m.template get(item, 0xb0) & 2)) continue; const auto buckets = m.ptr(item, 0x1f0); const auto size = m.template get(item, 0x1f8), total = m.template get(item, 0x1fc); if (!total) continue; - m.check(size && size <= 256 && total <= 128, "Hoodie material map exceeds bounds."); - const auto sentinel = m.ptr(buckets, size * 8ULL); - const auto hash = material_hash("Top_mat"); - auto node = m.ptr(buckets, (hash % size) * 8ULL); - for (std::uint32_t visited = 0; node && node != sentinel && visited < total; ++visited, node = m.ptr(node, 0x28)) { - if (m.template get(node) != hash) continue; - std::array seen{}; - for (std::size_t variant = 0; variant < seen.size(); ++variant) { - const auto material = m.ptr(node, 8 + variant * 8); - if (!material || std::find(seen.begin(), seen.begin() + variant, material) != seen.begin() + variant) continue; - seen[variant] = material; - const auto params = m.ptr(material, 0x268); - const auto n = m.template get(material, 0x270), count = m.template get(material, 0x274); - if (!count) continue; - m.check(n && n <= 1024 && count <= 512, "Hoodie parameter map exceeds bounds."); - const auto stop = m.ptr(params, n * 8ULL); - for (const auto key : color_keys) { - auto parameter = m.ptr(params, (key % n) * 8ULL); - for (std::uint32_t v = 0; parameter && parameter != stop && v < count; ++v, parameter = m.ptr(parameter, 0x40)) { - if (m.template get(parameter, 8) != key) continue; - // The EBX boxed ColorRgb has a serialized gamut word; the - // loaded native ColorRgb is three floats (12 bytes). Validate - // the actual type descriptor, not the serialized asset size. - const auto type = m.ptr(parameter, 0x20); - m.check(type >= base && type < base + 0x09144000 && - m.template get(parameter, 2) == sizeof(Color) && - m.template get(m.ptr(type)) == 0x885eff59U && - m.template get(m.ptr(type), 6) == sizeof(Color) && m.template get(parameter, 6) == 1 && - (m.template get(parameter, 0x28) & 1) && - m.template get(type, 8) == m.template get(parameter, 4), - "Hoodie color parameter type differs."); - const auto color = m.template get(parameter, 0x10); - m.check(std::all_of(color.begin(), color.end(), [](float c) { return std::isfinite(c); }) && - out.count < out.bindings.size(), "Hoodie color exceeds bounds."); - out.bindings[out.count++] = {material, parameter, type, key, color}; - break; + m.check(buckets && size && size <= 256 && total <= 128, "Skater material map exceeds bounds."); + // Every named material of the item, whatever its mesh calls them ("Top_mat", "Bottom_mat", ...). + std::array heads{}; + m.check(read(buckets, heads.data(), (size + 1ULL) * 8), "Skater material map cannot be read."); + std::uint32_t visited{}; + for (std::uint32_t bucket = 0; bucket < size; ++bucket) { + for (auto node = heads[bucket]; node && node != heads[size]; node = m.ptr(node, 0x28)) { + if (m.template get(node) % size != bucket) break; // the next bucket's + m.check(visited++ < total, "Skater material chain exceeds bounds."); + std::array seen{}; + for (std::size_t variant = 0; variant < seen.size(); ++variant) { + const auto material = m.ptr(node, 8 + variant * 8); + if (!material || std::find(seen.begin(), seen.begin() + variant, material) != seen.begin() + variant) continue; + seen[variant] = material; + const auto map = parameter_map(m, material); + for (const auto key : color_keys) { + if (out.bindings.size() == max_bindings) break; + ParameterKey native; + Color color{}; + if (const auto parameter = color_parameter(m, base, map, key, native, color)) + out.bindings.push_back({item, material, parameter, native, color, static_cast(part)}); } } } - break; } } return out; } -inline bool same_binding(const Binding &a, const Binding &b) noexcept { - return a.material == b.material && a.node == b.node && a.type == b.type && a.key == b.key; +inline bool same_binding(const Binding &live, const Binding &saved) noexcept { + // A parameter the board added has its node from the next fresh walk on. + return live.item == saved.item && live.material == saved.material && live.key.hash == saved.key.hash && + (saved.node ? live.node == saved.node : live.node != 0); } -} // namespace developer_hoodie_detail -struct DeveloperHoodieState { - std::uintptr_t entity{}, component{}, appearance{}, item{}; - std::uint64_t generation{}; - std::array colors{}; - std::size_t count{}; -}; -namespace developer_hoodie_detail { -// 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) { - 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); - DeveloperHoodieState next; - if (enabled) { - next.entity = entity; next.generation = generation; next.component = live.component; - next.appearance = live.appearance; next.item = live.item; - } - bool changed{}; - for (std::size_t i = 0; i < live.count; ++i) { +// The renderer shows a color one publish late. While a color keeps changing the next tick's +// publish brings it; one that stands still (a solid color, or the item's own once the +// animation is off) needs publishing again after the write that set it, or the item keeps the +// color before it on screen (seen in game, 2026-10-04). +inline constexpr std::uint8_t settle_publishes = 3; +// Ownership is discovered afresh before every write, and writes go only to what that walk +// found. A color is restored only while it is still the one written here: a new native +// preset takes precedence. `animations`: what each part does, or nothing. +template +void animate(ItemState &state, const Materials &live, std::uintptr_t entity, std::uint64_t generation, + const std::array &animations, std::uint64_t milliseconds, Write &write, Publish &publish) { + if (state.entity != entity || state.generation != generation || state.component != live.component || state.appearance != live.appearance) + state = {}; + // Pausing, loading and a recipe on its way keep what was saved until a walk succeeds. + if (live.pending) return; + // What was saved of each color found: a walk finds them in the same order as the last. + std::vector saved(live.bindings.size()); + std::array regions{}; + for (std::size_t i = 0, from = 0; i < live.bindings.size(); ++i) { const auto &binding = live.bindings[i]; - const SavedColor *saved{}; - for (std::size_t j = 0; j < state.count; ++j) - if (same_binding(binding, state.colors[j].binding)) { saved = &state.colors[j]; break; } - if (enabled) { - const auto original = saved && binding.color == saved->last ? saved->original : binding.color; - if (binding.color == color || write(binding, color)) { - 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; + for (std::size_t n = 0; n < state.colors.size(); ++n) { + const auto at = (from + n) % state.colors.size(); + if (same_binding(binding, state.colors[at].binding)) { saved[i] = &state.colors[at], from = at + 1; break; } } + const auto &own = saved[i] && binding.color == saved[i]->last ? saved[i]->original : binding.color; + regions[binding.part] = regions[binding.part] || own != neutral; } - state = next; - if (changed) publish(live.item); + ItemState next; + next.entity = entity; next.generation = generation; next.component = live.component; next.appearance = live.appearance; + next.colors.reserve(live.bindings.size()); + std::array changed_items{}; + std::size_t changed{}; + for (std::size_t i = 0; i < live.bindings.size(); ++i) { + const auto &binding = live.bindings[i]; + const auto *was = saved[i]; + const auto &animation = animations[binding.part]; + const bool ours = was && binding.color == was->last; + const auto original = ours ? was->original : binding.color; + bool show{}; + if (animation.on && !(live.regions_only && regions[binding.part] && original == neutral)) { + const auto color = item_color(animation, milliseconds); + if (binding.node && binding.color == color) { + // Already this color: nothing to write, but it may still be owed to the renderer. + const std::uint8_t left = ours ? was->settling : 0; + next.colors.push_back({binding, original, color, static_cast(left ? left - 1 : 0)}); + show = left != 0; + } else if (write(binding, color)) { + next.colors.push_back({binding, original, color, settle_publishes}); + show = true; + } else if (was) next.colors.push_back(*was); + } else if (was && (binding.color == was->last || binding.color == was->original)) { + // Back to the item's own color, and published until it shows. + const bool first = was->last != was->original; + show = binding.color == was->original || write(binding, was->original); + const std::uint8_t left = first ? settle_publishes : was->settling ? was->settling - 1 : 0; + if (!show) next.colors.push_back(*was); + else if (left) next.colors.push_back({binding, was->original, was->original, left}); + } + if (show && changed < changed_items.size() && + std::find(changed_items.begin(), changed_items.begin() + changed, binding.item) == changed_items.begin() + changed) + changed_items[changed++] = binding.item; + } + state.entity = next.entity, state.generation = next.generation, state.component = next.component, state.appearance = next.appearance; + state.colors = std::move(next.colors); + for (std::size_t i = 0; i < changed; ++i) publish(changed_items[i]); } } // namespace developer_hoodie_detail +// What was saved of a skater's items while they are coloured. +struct DeveloperHoodieState : developer_hoodie_detail::ItemState {}; } // namespace dingosdk 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..559ab567 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 + mark_items * 8; 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,13 @@ 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.hide_tag ? 1 : 0) | (p.appearance.hide_items ? 2 : 0), 1); + for (const auto &style : p.appearance.marks) { + w.integer(static_cast(style.mode), 1); + for (const auto part : style.from) w.integer(part, 1); + for (const auto part : style.to) w.integer(part, 1); + w.integer(style.speed, 1); + } } else if (p.kind == PacketKind::audio) { w.integer(p.audio.size(), 2); AudioState previous; @@ -674,7 +681,18 @@ 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.hide_tag = (flags & 1) != 0; + p.appearance.hide_items = (flags & 2) != 0; + bool styled = true; + for (auto &style : p.appearance.marks) { + style.mode = static_cast(r.integer(1)); + for (auto &part : style.from) part = static_cast(r.integer(1)); + for (auto &part : style.to) part = static_cast(r.integer(1)); + style.speed = static_cast(r.integer(1)); + styled = styled && valid_mark_style(style); + } + if (flags > 3 || !styled || !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..a86d70ff 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 = 41; 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..7b152912 100644 --- a/Extension/Multiplayer/Remote/cosmetics.h +++ b/Extension/Multiplayer/Remote/cosmetics.h @@ -28,9 +28,31 @@ struct PlayerCard { std::uint32_t background{}, emblem{}, title{}; bool operator==(const PlayerCard &) const = default; }; +// How one of a listed player's marked cosmetics is coloured (developer_identity.h), chosen on +// the menu's Special page and carried in their Appearance, so everyone sees the same. +enum class MarkMode : std::uint8_t { standard, off, gradient, solid }; +struct MarkStyle { + MarkMode mode{}; // standard: what the player's list gives + std::array from{}, to{}; // as picked on screen. gradient: between the two; solid: `from` + std::uint8_t speed{}; // 0 normal, 1 slow, 2 fast + bool operator==(const MarkStyle &) const = default; +}; +// The marked cosmetics, in this order: whatever the skater has in each of these slots, then the +// parts of whatever board they ride. +inline constexpr std::array mark_item_names{ + "Top", "Bottoms", "Shoes", "Socks", "Hat", "Glasses", "Outfit", "Costume", "Deck", "Grip tape", "Trucks", "Wheels"}; +inline constexpr std::size_t mark_items = mark_item_names.size(), skater_mark_items = 8; +using MarkStyles = std::array; +inline bool valid_mark_style(const MarkStyle &style) noexcept { + return style.mode <= MarkMode::solid && style.speed <= 2; +} struct Appearance { CosmeticRecipe skater, board; PlayerCard card; + // The player has turned off what the ReSkate backend gives them (developer_identity.h): + // nobody shows their tag, or nobody animates their hoodie and board. + bool hide_tag{}, hide_items{}; + MarkStyles marks{}; 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/room.h b/Extension/Multiplayer/Session/room.h index 2b8655d1..f1688390 100644 --- a/Extension/Multiplayer/Session/room.h +++ b/Extension/Multiplayer/Session/room.h @@ -160,11 +160,17 @@ struct JoinBackoff { }); } }; +// How much one peer may send in a second. `when` is when the packet arrived (the transport's +// TransportMessage::arrived), not when it is read: a receiver that could not read for a few +// seconds (a server whose console held it up, a game loading a map) reads everything that +// arrived meanwhile at once, and counted by reading time every peer looked like a flood and +// was dropped. Packets are not always read in arrival order (Steam's lanes are read one after +// another): one from before the second being counted is counted in it. struct ReceiveBudget { std::uint64_t since{}, bytes{}, packets{}; - bool accept(std::uint64_t now, std::size_t size, unsigned sources = 1) { - if (now - since >= 1000000) { - since = now; + bool accept(std::uint64_t when, std::size_t size, unsigned sources = 1) { + if (when > since && when - since >= 1000000) { + since = when; bytes = packets = 0; } bytes += size; diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 71e5b68d..2500dff2 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -257,6 +257,11 @@ 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_tag(profile_runtime::local_preference("IdentityTag").value_or(true)); + show_own_items(profile_runtime::local_preference("IdentityItems").value_or(true)); + if (const auto saved = profile_runtime::local_value("IdentityStyles"); saved && saved->is_string()) + if (const auto styles = developer_hoodie_detail::parse_mark_styles(saved->string())) + developer_hoodie_detail::own_styles.store(*styles); apply_nametags(s); } // The party's limit: parties players form hold up to eight (the game's Party panel rows), @@ -425,7 +430,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_items(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)) @@ -434,8 +441,10 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui // A far player between samples: their skater keeps its pose; sound, the label and // the spectate position are still refreshed (sound events are released one per frame). if (was_visible && local.ready && !p.render_failed && p.far_interval && now < p.next_far_sample) { - update_developer_hoodie(s.base, remote_skater_entity(), developer_id(p), remote_skater_generation(), p.developer_hoodie); - update_developer_board(s.base, remote_board_entity(), developer_id(p), remote_skater_generation(), p.developer_board); + update_developer_hoodie(s.base, remote_skater_entity(), developer_id(p), remote_skater_generation(), p.developer_hoodie, + mark_styles(p)); + update_developer_board(s.base, remote_board_entity(), developer_id(p), remote_skater_generation(), p.developer_board, + mark_styles(p)); present_audio(p); update_party_position(&p.render_pose); if (labels) label(p); @@ -512,9 +521,9 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui } } update_developer_hoodie(s.base, p.visible ? remote_skater_entity() : 0, developer_id(p), - remote_skater_generation(), p.developer_hoodie); + remote_skater_generation(), p.developer_hoodie, mark_styles(p)); update_developer_board(s.base, p.visible ? remote_board_entity() : 0, developer_id(p), - remote_skater_generation(), p.developer_board); + remote_skater_generation(), p.developer_board, mark_styles(p)); if (!p.visible) { stop_remote_audio(); p.presented_audio.reset(); diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index ff18d237..c3f67082 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -324,6 +325,43 @@ 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, or +// without the animation on their items. Their next appearance packet tells everyone +// (session_send.cpp). +std::string edit_own_tag(Session &, std::string_view argument) { + const auto shown = parse_switch(argument, own_tag_shown()); + if (!shown) return "Use on, off, or toggle for your tag."; + show_own_tag(*shown); + profile_runtime::set_local_preference("IdentityTag", *shown); + return *shown ? "Your tag shows." : "Your tag is hidden, for you and everyone you skate with."; +} +std::string edit_own_items(Session &, std::string_view argument) { + const auto shown = parse_switch(argument, own_items_shown()); + if (!shown) return "Use on, off, or toggle for your items."; + show_own_items(*shown); + profile_runtime::set_local_preference("IdentityItems", *shown); + return *shown ? "Your items animate." : "Your items are plain, for you and everyone you skate with."; +} +// The Special page: how one of the player's marked cosmetics is coloured, as +// " " (MarkStyle). It goes out with their next +// appearance packet and is kept in their profile. +std::string edit_mark_style(Session &, std::string_view argument) { + std::istringstream in{std::string(argument)}; + unsigned item{}, mode{}, from{}, to{}, speed{}; + if (!(in >> item >> mode >> std::hex >> from >> to >> std::dec >> speed) || item >= mark_items || mode > 3 || + from > 0xffffff || to > 0xffffff || speed > 2) + return "That is not a cosmetic style."; + const auto colour = [](unsigned value) { + return std::array{static_cast(value >> 16), static_cast(value >> 8), + static_cast(value)}; + }; + auto styles = developer_hoodie_detail::own_styles.load(); + styles[item] = {static_cast(mode), colour(from), colour(to), static_cast(speed)}; + developer_hoodie_detail::own_styles.store(styles); + profile_runtime::set_local_values({{"IdentityStyles", Json(developer_hoodie_detail::mark_styles_text(styles))}}); + return std::string(mark_item_names[item]) + + (mode == 1 ? ": off." : mode == 2 ? ": your gradient." : mode == 3 ? ": your color." : ": back to the usual."); +} // 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); @@ -436,12 +474,18 @@ std::string send_admin(Session &s, std::string text) { if (!send_packet(s, s.host_id, request, true, false)) return "Could not reach the server."; return "Sent to the server."; } +// The Special page's commands. They are a player's own offline as well, where their hoodie and +// board still animate. +bool own_mark_command(std::string_view action) { + return action == "mark-tag" || action == "mark-items" || action == "mark-style"; +} } // namespace bool queue_command(std::string_view action, std::string_view argument, std::string_view password) { - if (launcher::offline_mode()) return false; + if (launcher::offline_mode() && !own_mark_command(action)) return false; if ((action != "host" && action != "host-config" && action != "join" && action != "join-lobby" && action != "join-friend-lobby" && action != "stop" && action != "distances" && action != "object-placement" && action != "kick" && action != "clear-objects" && action != "nametags" && action != "nametag-style" && action != "chat-visible" && action != "chat-filter" && + !own_mark_command(action) && 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 +509,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() && !own_mark_command(action)) + 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 +665,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}, + {"mark-tag", edit_own_tag}, {"mark-items", edit_own_items}, {"mark-style", edit_mark_style}, {"chat-visible", edit_chat_visible}, {"chat-filter", edit_chat_filter}}; for (const auto &[name, edit] : settings) if (action == name) { @@ -705,6 +751,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..46ca45ad 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -310,6 +310,23 @@ 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 tag the backend gives them, and whether the items that come with +// it animate: not until their appearance has arrived, and not when it says they turned that +// off (Appearance::hide_tag, Appearance::hide_items). +inline bool shows_tag(const Peer &peer) { + const auto &look = peer.appearance.value(); + return look && !look->hide_tag; +} +inline bool shows_items(const Peer &peer) { + const auto &look = peer.appearance.value(); + return look && !look->hide_items; +} +// How a player has each of their marked cosmetics animate: what their appearance says. +inline const MarkStyles &mark_styles(const Peer &peer) { + static const MarkStyles standard{}; + const auto &look = peer.appearance.value(); + return look ? look->marks : standard; +} 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 +344,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 dbc59e16..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."); @@ -334,7 +349,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { if (!link) continue; const bool direct_link = s.mode == Mode::join && message.peer != s.host_id; - if (!link->budget.accept(now, message.bytes.size(), + if (!link->budget.accept(message.arrived ? message.arrived : now, message.bytes.size(), s.mode == Mode::join && !direct_link ? max_remote_players : 1U)) { disconnect(s, message.peer, "Peer exceeded the multiplayer packet limit."); if (s.mode == Mode::off) diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index 9249f441..604092c0 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -595,7 +595,14 @@ 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 choices to go without their tag or their animated items travel with their + // outfit, so a change is sent like one and reaches players who join later. + if (appearance) { + appearance->hide_tag = !own_tag_shown(); + appearance->hide_items = !own_items_shown(); + appearance->marks = developer_hoodie_detail::own_styles.load(); + } 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..21c9ac75 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -172,6 +172,29 @@ 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)) { + std::tie(view.identity_tag_colour, view.identity_tag) = mark_role(*mark); + view.identity_animation = + *mark == IdentityList::developer ? "RAINBOW" : *mark == IdentityList::content_creator ? "RED" : "GOLD"; + const auto styles = developer_hoodie_detail::own_styles.load(); + const auto standard = developer_hoodie_detail::standard_picks(*mark); + view.identity_styles.resize(styles.size()); + for (std::size_t i = 0; i < styles.size(); ++i) { + // A cosmetic never given colours shows its list's own in the pickers. + const bool picked = styles[i].mode == MarkMode::gradient || styles[i].mode == MarkMode::solid || + styles[i].from != styles[i].to || styles[i].from != std::array{}; + const auto &from = picked ? styles[i].from : standard.first, &to = picked ? styles[i].to : standard.second; + auto &shown = view.identity_styles[i]; + shown.name = mark_item_names[i]; + shown.mode = static_cast(styles[i].mode); + shown.speed = styles[i].speed; + for (std::size_t part = 0; part < 3; ++part) + shown.from[part] = static_cast(from[part]) / 255.f, shown.to[part] = static_cast(to[part]) / 255.f; + } + } + view.identity_tag_shown = own_tag_shown(); + view.identity_items_shown = own_items_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 +524,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 +538,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 tag off (the Special page), which their appearance tells everyone. + if (vouched && (local ? own_tag_shown() : shows_tag(*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/Steam/steam_transport.cpp b/Extension/Multiplayer/Steam/steam_transport.cpp index 4b2d4b8a..16ccf320 100644 --- a/Extension/Multiplayer/Steam/steam_transport.cpp +++ b/Extension/Multiplayer/Steam/steam_transport.cpp @@ -696,7 +696,8 @@ std::vector SteamTransport::receive() { continue; } const auto *data = static_cast(message->m_pData); - result.push_back({id, {data, data + message->m_cbSize}}); + result.push_back({id, {data, data + message->m_cbSize}, + message->m_usecTimeReceived > 0 ? static_cast(message->m_usecTimeReceived) : 0}); ++p.state.received; p.state.received_bytes += static_cast(message->m_cbSize); } diff --git a/Extension/Multiplayer/Steam/steam_transport.h b/Extension/Multiplayer/Steam/steam_transport.h index 8ab05b04..d035b136 100644 --- a/Extension/Multiplayer/Steam/steam_transport.h +++ b/Extension/Multiplayer/Steam/steam_transport.h @@ -16,6 +16,10 @@ struct TransportPeer { struct TransportMessage { std::uint64_t peer{}; std::vector bytes; + // When Steam received it, in microseconds on a clock of its own (only differences mean + // anything); 0 when the transport does not say. A receiver that was kept from reading for a + // while gets everything that arrived meanwhile in one go: this is what tells that from a flood. + std::uint64_t arrived{}; }; // One message of SteamTransport::send_batch, with send()'s arguments; `sent` receives what // send() would have returned for it. diff --git a/Extension/Multiplayer/Test/developer_identity_tests.cpp b/Extension/Multiplayer/Test/developer_identity_tests.cpp new file mode 100644 index 00000000..53df6ed7 --- /dev/null +++ b/Extension/Multiplayer/Test/developer_identity_tests.cpp @@ -0,0 +1,323 @@ +#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"); + // Their tag and their items are each their own to hide. + check(own_tag_shown() && own_items_shown(), "A player's tag or items start hidden"); + show_own_tag(false); + check(!own_tag_shown() && own_items_shown() && identity_mark(dev) == L::developer, + "Hiding their own tag did not take, hid their items, or changed the lists"); + show_own_tag(true); + show_own_items(false); + check(own_tag_shown() && !own_items_shown(), "Hiding their own items did not take, or hid their tag"); + show_own_items(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: what a list gives a cosmetic, and what a player makes of it on the Special page. +void items() { + using namespace dingosdk::developer_hoodie_detail; + using dingosdk::multiplayer::MarkMode; + using dingosdk::multiplayer::MarkStyle; + using dingosdk::multiplayer::MarkStyles; + const MarkStyle standard{}; + const auto spectrum = item_animation(L::developer, standard), red = item_animation(L::content_creator, standard), + gold = item_animation(L::homie, standard); + check(spectrum.on && spectrum.rainbow && red.on && !red.rainbow && gold.on && !gold.rainbow && + !item_animation(std::nullopt, standard).on, + "A list did not give its animation, or a player on none got one"); + check(item_color(spectrum, 0) == rainbow(0) && item_color(spectrum, 3000) == rainbow(3000), "The developer's rainbow changed"); + // Red and gold go from a deep shade to a bright one, on into the colour next to it, and back + // every three seconds. + for (const auto *animation : {&red, &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 == &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"); + } + } + + // A cosmetic turned off, and one given the player's own two colours. + const MarkStyle off{MarkMode::off, {}, {}, 0}, own{MarkMode::gradient, {255, 0, 0}, {0, 0, 255}, 0}; + check(!item_animation(L::developer, off).on && !item_animation(L::homie, off).on, "A cosmetic turned off still animates"); + const auto custom = item_animation(L::homie, own); + const auto first = item_color(custom, 0), last = item_color(custom, 1500), half = item_color(custom, 750); + check(custom.on && !custom.rainbow && first[0] > .7f && first[2] < .01f && last[2] > .7f && last[0] < .01f && half[0] > .3f && + half[2] > .3f && item_color(custom, 3000) == first, + "A player's own colours are not what their cosmetic moves between"); + // A developer can leave the rainbow for colours of their own; no style gives anyone else the rainbow. + check(!item_animation(L::developer, own).rainbow && item_animation(L::developer, own).on, "A developer cannot have their own colours"); + // One colour of their own stands still, at any speed. + const auto still = item_animation(L::homie, {MarkMode::solid, {0, 255, 0}, {9, 9, 9}, 2}); + check(still.on && !still.rainbow && item_color(still, 0) == material_color({0, 255, 0}) && item_color(still, 0)[1] > .7f && + item_color(still, 777) == item_color(still, 0) && item_color(still, 1500) == item_color(still, 0), + "A solid colour is not the colour picked, or does not stand still"); + for (const auto mode : {MarkMode::standard, MarkMode::gradient, MarkMode::solid}) + check(!item_animation(L::content_creator, {mode, {}, {}, 0}).rainbow && !item_animation(L::homie, {mode, {}, {}, 0}).rainbow, + "A style gave the rainbow to someone who is not a developer"); + // Slow takes twice as long and fast half, for the rainbow too. + check(item_animation(L::homie, {MarkMode::gradient, {}, {}, 1}).period == 6000 && + item_animation(L::homie, {MarkMode::standard, {}, {}, 2}).period == 1500 && + item_animation(L::developer, {MarkMode::standard, {}, {}, 1}).period == 16000 && + item_animation(L::developer, {MarkMode::standard, {}, {}, 2}).period == 4000 && + item_color(item_animation(L::developer, {MarkMode::standard, {}, {}, 2}), 1000) == rainbow(2000), + "The speed is not half or twice the usual"); + // The pickers start from colours close to the list's own. + const auto picks = standard_picks(L::content_creator); + const auto start = material_color(picks.first), finish = material_color(picks.second); + check(std::abs(start[0] - .2f) < .02f && start[1] < .02f && std::abs(finish[0] - .8f) < .01f && std::abs(finish[2] - .13f) < .02f, + "A content creator's pickers do not start from their red"); + + // The styles are kept in the profile as text. + MarkStyles styles{}; + styles[0] = own, styles[2] = off, styles[4] = {MarkMode::gradient, {1, 2, 3}, {254, 253, 252}, 2}; + styles.back() = {MarkMode::solid, {7, 8, 9}, {}, 0}; + const auto text = mark_styles_text(styles); + check(text.size() == styles.size() * 16 && parse_mark_styles(text) == styles && parse_mark_styles(mark_styles_text({})) == MarkStyles{}, + "The styles did not come back from their text"); + check(!parse_mark_styles("") && !parse_mark_styles("00") && !parse_mark_styles(std::string(text.size(), 'f')) && + !parse_mark_styles(std::string(text.size(), 'g')) && !parse_mark_styles(text + "00") && + !parse_mark_styles(std::string_view(text).substr(16)), + "Text that is not styles was read as some"); + // Every marked cosmetic has a name, the skater's before the board's. + check(dingosdk::multiplayer::mark_item_names.size() == slots.size() + dingosdk::developer_board_detail::part_count && + dingosdk::multiplayer::mark_item_names[slots.size()] == "Deck" && slots[2] == "CAS_Footwear_Slot", + "The marked cosmetics and their names do not line up"); +} + +// Whatever a player wears is coloured where the item colours itself, each cosmetic on its own; +// turning one off (the Special page, or a list that no longer has the player) puts its own +// colours back, and publishes them the extra times the renderer needs to show them. +void settling() { + using namespace dingosdk::developer_hoodie_detail; + using dingosdk::multiplayer::MarkMode; + const auto red = item_animation(L::content_creator, {}), gold = item_animation(L::homie, {}); + const ItemAnimation none; + using Skater = std::array; + const auto wearing = [&](const ItemAnimation &top, const ItemAnimation &shoes) { + Skater out{}; + out[0] = top, out[2] = shoes; + return out; + }; + const Skater bare{}; + const Color black{.02f, .02f, .02f}, navy{.02f, .02f, .2f}, teal{.02f, .3f, .3f}; + const auto bound = [](std::uintptr_t item, std::uintptr_t node, std::uint64_t key, const Color &color, std::uint8_t part) { + return Binding{item, item + 40, node, {0, sizeof(Color), 1, 1, key}, color, part}; + }; + // What the game holds for each colour, read afresh each tick as in game: a top with two + // regions it colours and one it leaves to its texture, and shoes that colour none of theirs. + std::array held{black, navy, neutral, neutral}; + dingosdk::DeveloperHoodieState state; + Materials live; + live.component = 1, live.appearance = 2, live.regions_only = true; + live.bindings = {bound(10, 100, color_keys[0], black, 0), bound(10, 101, color_keys[3], navy, 0), + bound(10, 102, color_keys[7], neutral, 0), bound(11, 103, color_keys[0], neutral, 2)}; + std::vector published; + int writes{}; + auto write = [&](const Binding &binding, const Color &color) { + held[binding.node - 100] = color; + ++writes; + return true; + }; + auto publish = [&](std::uintptr_t item) { published.push_back(item); }; + const auto tick = [&](const Skater &animations, 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, animations, at, write, publish); + }; + using Items = std::vector; + tick(wearing(red, none), 0); + tick(wearing(red, none), 700); + check(state.colors.size() == 2 && held[0] == item_color(red, 700) && held[1] == held[0] && held[2] == neutral && + held[3] == neutral && published == Items{10, 10}, + "The top's own regions were not coloured, or more than them was"); + // Shoes that colour none of their regions take the colour on all of them. + tick(wearing(red, gold), 800); + check(held[3] == item_color(gold, 800) && held[2] == neutral && held[0] == item_color(red, 800) && state.colors.size() == 3, + "An item with no colours of its own was not coloured, or cosmetics did not each take their own"); + published.clear(); + tick(bare, 900); + check(held[0] == black && held[1] == navy && held[3] == neutral && published == Items{10, 11}, + "Turning it off did not bring the items' own colours back"); + for (std::uint64_t at = 901; at < 911; ++at) tick(bare, at); + check(published.size() == 2 * (1 + settle_publishes) && state.colors.empty() && held[0] == black && held[1] == navy, + "The own colours were not published the extra times, or the items were not left alone after"); + // A colour the game itself sets meanwhile is the game's to keep. + tick(wearing(gold, none), 1000); + held[0] = teal; + tick(bare, 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(bare, at); + tick(wearing(red, none), 2000); + tick(bare, 2100); + check(held[0] == teal && held[1] == navy, "A second round lost the top's own colours"); + for (std::uint64_t at = 2200; at < 2210; ++at) tick(bare, at); + + // A colour that stands still is written once, and published until the renderer shows it. + const auto still = item_animation(L::homie, {MarkMode::solid, {0, 255, 0}, {}, 0}); + published.clear(), writes = 0; + for (std::uint64_t at = 3000; at < 3010; ++at) tick(wearing(still, none), at); + check(held[0] == item_color(still, 0) && held[1] == held[0] && writes == 2 && published == Items(1 + settle_publishes, 10), + "A solid colour was written again and again, or not published until it showed"); + // While a recipe is on its way nothing is touched, and what was saved is kept. + live.pending = true; + tick(bare, 4000); + check(held[0] == item_color(still, 0) && state.colors.size() == 2 && writes == 2, "A pending recipe was written to"); + live.pending = false; + tick(bare, 4100); + check(held[0] == teal && held[1] == navy, "The own colours were lost while a recipe was pending"); + + // The board does the same, each of its parts on its own: here the deck and the wheels, whose + // material has no colour of its own until one is added. + namespace board = dingosdk::developer_board_detail; + std::array painted{black, board::shader_default}; + dingosdk::DeveloperBoardState deck; + Materials parts; + parts.component = 1, parts.appearance = 2; + parts.bindings = {Binding{30, 10, 100, board::parameter_key(board::base_color, 1), black, 0}, + Binding{31, 11, 0, board::parameter_key(board::base_color, 1), board::shader_default, 3}}; + int shown{}; + bool added{}; + auto paint = [&](const Binding &binding, const Color &color) { + if (!binding.node) added = true; // the engine adds the missing parameter + painted[binding.part ? 1 : 0] = color; + return true; + }; + auto show = [&](std::uintptr_t) { ++shown; }; + using Board = std::array; + const auto roll = [&](const ItemAnimation &on_deck, const ItemAnimation &on_wheels, std::uint64_t at) { + parts.bindings[0].color = painted[0], parts.bindings[1].color = painted[1]; + parts.bindings[1].node = added ? 101 : 0; + animate(deck, parts, 5, 6, Board{on_deck, none, none, on_wheels}, at, paint, show); + }; + roll(gold, red, 0); + roll(gold, red, 700); + check(deck.colors.size() == 2 && added && painted[0] == item_color(gold, 700) && painted[1] == item_color(red, 700), + "The board's parts did not each take their own animation, or a missing colour was not added"); + // The wheels alone turned off: back to the shaders' default, while the deck goes on. + for (std::uint64_t at = 800; at < 810; ++at) roll(gold, none, at); + check(painted[0] == item_color(gold, 809) && painted[1] == board::shader_default && deck.colors.size() == 1, + "Turning one part off stopped another, or did not stop it"); + shown = 0; + for (std::uint64_t at = 900; at < 910; ++at) roll(none, none, at); + check(painted[0] == black && painted[1] == board::shader_default && shown == 1 + settle_publishes && deck.colors.empty(), + "Turning it off did not bring the board's own colours back and publish them 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 42c4bf3b..e0895a89 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -35,10 +35,15 @@ bool clear_lobby_guest_objects() { ++simulated_guest_wipes; return true; } namespace dingosdk { bool teleport_local_skater(const std::array&) { return true; } void update_board_lock(std::uintptr_t, std::uintptr_t, bool) noexcept {} -void update_developer_hoodie(std::uintptr_t, std::uintptr_t, std::uint64_t, std::uint64_t, DeveloperHoodieState &) noexcept {} -void update_developer_board(std::uintptr_t, std::uintptr_t, std::uint64_t, std::uint64_t, DeveloperBoardState &) noexcept {} +void update_developer_hoodie(std::uintptr_t, std::uintptr_t, std::uint64_t, std::uint64_t, DeveloperHoodieState &, + const multiplayer::MarkStyles &) noexcept {} +void update_developer_board(std::uintptr_t, std::uintptr_t, std::uint64_t, std::uint64_t, DeveloperBoardState &, + const multiplayer::MarkStyles &) 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 {} @@ -89,6 +94,8 @@ struct SimulatedNetwork { static inline std::uint64_t hold_control_to{}; static inline std::array lane_sends{}; static inline std::map queues; + // What a message's arrival time is (TransportMessage::arrived); 0: the transport does not say. + static inline std::uint64_t clock{}; static std::pair pair(std::uint64_t a, std::uint64_t b) { return {std::min(a, b), std::max(a, b)}; } @@ -200,7 +207,7 @@ bool SteamTransport::send(std::uint64_t id, std::span bytes, return true; if (!reliable && SimulatedNetwork::lose_unreliable && ++p.unreliable % 5 == 0) return true; - Impl::bus.at(id)->inbox.push_back({{p.state.local_id, {bytes.begin(), bytes.end()}}, lane}); + Impl::bus.at(id)->inbox.push_back({{p.state.local_id, {bytes.begin(), bytes.end()}, SimulatedNetwork::clock}, lane}); return true; } void SteamTransport::send_batch(std::span messages) { @@ -469,6 +476,8 @@ struct Simulation { NativeFrame local; std::vector positions; unsigned capacity, tps; + std::size_t held = static_cast(-1); // a node that is not run: held up, reading nothing + bool stamp{}; // messages carry when they arrived explicit Simulation(unsigned limit = max_players, unsigned rate = 20) : capacity(limit), tps(rate) { local.ready = true; local.pose.skater.resize(395); @@ -503,9 +512,10 @@ struct Simulation { void run(unsigned frames) { for (unsigned i = 0; i < frames; ++i) { now += network_tick_us; + SimulatedNetwork::clock = stamp ? now : 0; for (std::size_t n = 0; n < nodes.size(); ++n) { auto &s = *nodes[n]; - if (s.mode == Mode::off) + if (s.mode == Mode::off || n == held) continue; local.pose.root.position[0] = static_cast(now - 10000000) / 1000000; local.pose.root.position[2] = n < positions.size() ? positions[n] : static_cast(n); @@ -639,6 +649,164 @@ 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 tag off (the Special page) is whatever they are in the lobby, + // to everyone: their appearance carries the choice through the host. Their items are a + // separate choice, which leaves the tag alone. + auto plain = packet(host, PacketKind::cosmetics, sim.now); + plain.appearance = {{skater_recipe_key, 2, {}, {{1, "Outfit0", {}}}}, {board_recipe_key, 1, {}, {{2, "Deck", {}}}}}; + plain.appearance.hide_items = true; + host.cosmetic_packet = encode_wire(plain); + broadcast(host, plain, true, false, sim.now); + sim.run(20); + const auto told = [&](const Session &s) { + const auto *peer = find_peer(const_cast(s), id(host)); + return peer && !shows_items(*peer) && shows_tag(*peer); + }; + check(told(first) && told(second) && player_role(first, id(host), false) == Role{nametag_homie, "Homie"}, + "A homie who turned their items off was not seen to, or lost their tag"); + // A later packet, as the host's game would send after the change. + const auto look = plain.appearance; + plain = packet(host, PacketKind::cosmetics, sim.now); + plain.appearance = look; + plain.appearance.hide_items = false, plain.appearance.hide_tag = 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 tag off still shows as a homie"); + const auto *shown = find_peer(first, id(host)); + check(shown && shows_items(*shown), "Turning their tag off turned a player's items off"); + check(player_role(host, id(first), false) == Role{nametag_creator, "Creator"}, "One player's choice hid another's tag"); + show_own_items(false); + check(player_role(first, id(first), true) == Role{nametag_creator, "Creator"}, "Turning their items off hid a player's own tag"); + show_own_items(true); + show_own_tag(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 tag from themselves, or hid it from others before they were told"); + show_own_tag(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. +void stall_checks() { + { + // 16 s of a player's ordinary traffic read at once, counted by when it arrived. + ReceiveBudget backlog; + bool kept = true; + for (std::uint64_t i = 0; i < 16 * 90; ++i) kept = kept && backlog.accept(5000000 + i * (1000000 / 90), 300); + check(kept, "A backlog read after a stall was taken for a flood"); + ReceiveBudget flood; + bool refused = false; + for (std::uint64_t i = 0; i < 2000; ++i) refused = refused || !flood.accept(5000000 + i * 100, 300); + check(refused, "A flood was let through the packet limit"); + // Lanes are read one after another, so arrival times step back and forth. + ReceiveBudget lanes; + refused = false; + for (std::uint64_t i = 0; i < 2000; ++i) refused = refused || !lanes.accept(i % 2 ? 9000000 : 9500000, 300); + check(refused, "Packets read out of arrival order started the count again"); + } + Simulation sim(max_players, 120); + sim.stamp = true; + for (unsigned i = 0; i < 4; ++i) sim.add(); + sim.run(240); + auto &host = *sim.nodes[0]; + const auto connected = [&] { + unsigned count{}; + for (std::size_t n = 1; n < sim.nodes.size(); ++n) { + const auto *peer = find_peer(host, sim.nodes[n]->transport.status().local_id); + count += peer && peer->handshaken && sim.nodes[n]->mode == Mode::join; + } + return count; + }; + check(connected() == 3, "The stall fixture did not connect its guests"); + // Nine seconds unread: under the ten after which a silent player is given up on, and more + // than a second's allowance from every guest. + const auto sent = sim.nodes[1]->transport.status().sent; + sim.held = 0; + sim.run(static_cast(9000000 / network_tick_us)); + sim.held = static_cast(-1); + check(sim.nodes[1]->transport.status().sent - sent > 2ULL * multiplayer_tick_rates.back() + 80 + 32, + "The stall fixture did not queue more than the packet limit"); + sim.run(240); + if (host.mode != Mode::host || connected() != 3) + for (std::size_t n = 0; n < sim.nodes.size(); ++n) + std::cerr << " node " << n << " mode " << static_cast(sim.nodes[n]->mode) << ": " << sim.nodes[n]->status << "\n"; + check(host.mode == Mode::host && connected() == 3, "Guests were dropped after their host was held up for a few seconds"); + SimulatedNetwork::clock = 0; + std::cout << "Stalls: a held-up host keeps its guests; floods and out-of-order arrival are still limited.\n"; +} // What a host changes outside its physics tuning (the trainer's class values and trick // multipliers) reaches its guests while it sets everyone's physics: when it changes, to a // player who joins later, and from nobody but the host. @@ -1933,7 +2101,10 @@ 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(); dingosdk::multiplayer::object_sync_checks(); dingosdk::multiplayer::session_controls_checks(); diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index 6921a412..223296db 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -331,8 +331,30 @@ 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.hide_tag && !decoded->appearance.hide_items, "Cosmetic fields or opaque parameter bits were lost"); + // A player's choices to go without their backend tag, or its animated items, travel with + // their outfit, each on its own. + for (const auto &[tag, items] : {std::pair{true, false}, std::pair{false, true}, std::pair{true, true}}) { + auto hidden = p; + hidden.appearance.hide_tag = tag, hidden.appearance.hide_items = items; + const auto told = decode(encode(hidden)); + check(told && told->appearance.hide_tag == tag && told->appearance.hide_items == items && + told->appearance == hidden.appearance && !(told->appearance == p.appearance), + "A player's choice to hide their tag or their items was lost"); + } + // So does how they have each marked cosmetic animate. + auto styled = p; + styled.appearance.marks[0] = {MarkMode::gradient, {1, 2, 3}, {250, 251, 252}, 2}; + styled.appearance.marks[4] = {MarkMode::off, {}, {}, 1}; + styled.appearance.marks.back() = {MarkMode::solid, {9, 8, 7}, {}, 0}; + const auto kept = decode(encode(styled)); + check(kept && kept->appearance == styled.appearance && kept->appearance.marks[0].to[2] == 252 && + kept->appearance.marks.back().mode == MarkMode::solid && kept->appearance.marks.back().from[0] == 9 && + kept->appearance.marks[1] == MarkStyle{} && !(kept->appearance == p.appearance), + "A player's cosmetic styles were lost"); + check(!valid_mark_style({static_cast(4), {}, {}, 0}) && !valid_mark_style({MarkMode::standard, {}, {}, 3}), + "A cosmetic style no menu can make was accepted"); 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..e03ceb87 100644 --- a/Extension/Multiplayer/developer_identity.h +++ b/Extension/Multiplayer/developer_identity.h @@ -1,16 +1,58 @@ #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 choices to go without their tag, and without their animated items +// (the menu's Special page, which only a listed player gets). Their appearance tells everyone +// else (Appearance::hide_tag, Appearance::hide_items). +inline std::atomic own_tag{true}, own_items{true}; +inline bool own_tag_shown() noexcept { return own_tag.load(std::memory_order_relaxed); } +inline void show_own_tag(bool shown) noexcept { own_tag.store(shown, std::memory_order_relaxed); } +inline bool own_items_shown() noexcept { return own_items.load(std::memory_order_relaxed); } +inline void show_own_items(bool shown) noexcept { own_items.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/Settings/local_native_settings.cpp b/Extension/Settings/local_native_settings.cpp index 42341baf..64c8f80b 100644 --- a/Extension/Settings/local_native_settings.cpp +++ b/Extension/Settings/local_native_settings.cpp @@ -6,6 +6,8 @@ #include "Engine/Game/Build/20260929/local_native_settings.h" #include "Engine/Game/Build/20260929/named_settings.h" #include "local_user_settings.h" +#include "Extension/Profile/local_profile_runtime.h" +#include #include #include #include @@ -88,6 +90,50 @@ const dingosdk::Json* saved_native_option(unsigned location, const char* key) { } } +namespace { +// Options the game keeps on this device (location 0) whose default ReSkate changes. The game +// saves these itself, so nothing is stored here but the fact that the default was applied: +// once per profile, the first time the game asks for the option. After that the option is the +// player's, to switch in the game's own menus. +// Accessibility_Party_Menu_Narration: on in the game's data, so the party menu was read +// aloud to every new player until they found the switch. +struct OwnDefault { + std::string_view key; + bool value; + const char* applied; // the ReSkate preference that records it +}; +constexpr OwnDefault own_defaults[]{{"Accessibility_Party_Menu_Narration", false, "Defaults.PartyMenuNarration"}}; + +bool per_device_group(std::uintptr_t group) { + auto& s = local_runtime(); + std::uintptr_t manager{}, begin{}, end{}, first{}; + return group && s.active.load(std::memory_order_acquire) && + memory::peek(s.base + addr::local_native_settings::profile_settings_manager, manager) && manager && + memory::peek(manager, begin) && memory::peek(manager + 8, end) && begin && end >= begin && + end - begin == 3 * sizeof(std::uintptr_t) && memory::peek(begin, first) && first == group; +} +// Runs on every get of an option outside the cloud groups: the key is compared first, and +// only a key from the table costs anything more. +void apply_own_default(std::uintptr_t group, const char* key, const NativeSettingValue* result) { + char text[64]; + const auto length = memory::peek_cstring(reinterpret_cast(key), text, sizeof(text)); + if (length <= 0) return; + const std::string_view name(text, static_cast(length)); + for (const auto& own : own_defaults) { + if (name != own.key) continue; + if (!per_device_group(group) || local_preference(own.applied).value_or(false)) return; + NativeSettingValue native{}; + const auto current = native_setting_json(result); + if (!current || !current->is_boolean() || !memory::peek(reinterpret_cast(result), native)) return; + if (current->get() != own.value && !restore_native_scalar(group, key, native.type, dingosdk::Json(own.value))) return; + set_local_preference(own.applied, true); + dingosdk::logging::event(dingosdk::logging::Channel::settings, + dingosdk::Json{{"event", "local_native_default_applied"}, {"key", std::string(own.key)}, {"value", own.value}}.dump().c_str()); + return; + } +} +} + bool restore_native_setting(std::uintptr_t group, const char* key, std::uintptr_t type, const dingosdk::Json& saved) { const auto base = local_runtime().base; @@ -115,7 +161,11 @@ const NativeSettingValue* native_setting_get(std::uintptr_t group, const char* k PreserveError preserve; try { const auto location = native_settings_location(group); - const auto* saved = location ? saved_native_option(location, key) : nullptr; + if (!location) { + apply_own_default(group, key, result); + return result; + } + const auto* saved = saved_native_option(location, key); if (!saved) return result; const auto current = native_setting_json(result); if (!current || *current == *saved) return result; diff --git a/Extension/UI/Overlay/multiplayer_menu.cpp b/Extension/UI/Overlay/multiplayer_menu.cpp index a150880f..322360c5 100644 --- a/Extension/UI/Overlay/multiplayer_menu.cpp +++ b/Extension/UI/Overlay/multiplayer_menu.cpp @@ -1,8 +1,12 @@ #include "multiplayer_menu_internal.h" +#include "role_badge.h" #include "Engine/Game/World/world_names.h" #include #include +#include +#include #include +#include #include namespace dingosdk::overlay { @@ -243,8 +247,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 +271,131 @@ 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 as everyone +// sees it, and whether it shows; then the items that come with it, on or off together and +// each on its own: what their list gives it, two colours of their own, or nothing. +void special_page(SkateMenu &menu, const Model &model, const CallbacksV3 &) { + const auto &mp = model.multiplayer; + std::array unused{}; + ImGui::BeginChild("special", ImVec2(0, page_body_height(menu))); + begin_card(menu, "special-tag", "YOUR TAG"); + { + // Drawn the way chat and nametags draw it (role_badge.h), and faint while it is off. + field(menu, "Looks like", "Your tag and name, as chat and your nametag show them."); + const std::string name = mp.local_name.empty() ? std::string("You") : mp.local_name; + const float size = px(18), height = ImGui::GetFrameHeight(), alpha = mp.identity_tag_shown ? 1.0f : .3f; + auto *draw = ImGui::GetWindowDrawList(); + const auto at = ImGui::GetCursorScreenPos(); + const float badge = overlay::detail::role_badge_width(menu.bold, size, mp.identity_tag) + px(6); + overlay::detail::draw_role_badge(draw, menu.bold, size, at, height, mp.identity_tag, mp.identity_tag_colour, alpha); + const auto extent = menu.bold->CalcTextSizeA(size, FLT_MAX, 0, name.c_str()); + const ImVec2 name_at(at.x + badge, at.y + (height - extent.y) * .5f); + const int name_vertices = draw->VtxBuffer.Size; + draw->PushClipRect(at, ImVec2(at.x + ImGui::GetContentRegionAvail().x, at.y + height), true); + draw->AddText(menu.bold, size, name_at, + (mp.identity_tag_colour & ~IM_COL32_A_MASK) | (static_cast(255.0f * alpha) << IM_COL32_A_SHIFT), name.c_str()); + draw->PopClipRect(); + overlay::detail::shade_nametag_gradient(draw, name_vertices, name_at.x, extent.x, mp.identity_tag_colour, ImGui::GetTime()); + ImGui::Dummy(ImVec2(std::min(badge + extent.x, ImGui::GetContentRegionAvail().x), height)); + } + bool tag = mp.identity_tag_shown; + if (toggle_row(menu, "Show my tag", + "Your tag and the color of your name, in chat and on your nametag. " + "Off: your name looks like any other player's, to yourself and to everyone you skate with.", + tag)) + send_private(menu, "mark-tag", tag ? "on" : "off", unused, false); + end_card(); + + begin_card(menu, "special-items", "YOUR ITEMS"); + bool shown = mp.identity_items_shown; + if (toggle_row(menu, "Show my items", + "The colors that come with your tag, on whatever you wear and ride. " + "Off: it all keeps its own colors, for you and for everyone you skate with.", + shown)) + send_private(menu, "mark-items", shown ? "on" : "off", unused, false); + note("Below, each one on its own: it colors whatever you have on in that slot. Everyone you skate with sees what you pick."); + end_card(); + + // The colours being picked: held here while the mouse has them, and after it lets go until + // the saved ones catch up, so a swatch never jumps back to the colour it had. + struct Picking { + std::array from{}, to{}; + bool changed{}; + int waiting{}; // frames + }; + static std::vector picking; + picking.resize(mp.identity_styles.size()); + for (int item = 0; item < static_cast(mp.identity_styles.size()); ++item) { + const auto &style = mp.identity_styles[static_cast(item)]; + auto &picked = picking[static_cast(item)]; + const auto same = [](const std::array &a, const std::array &b) { + return std::abs(a[0] - b[0]) < .003f && std::abs(a[1] - b[1]) < .003f && std::abs(a[2] - b[2]) < .003f; + }; + if (picked.waiting && same(picked.from, style.from) && same(picked.to, style.to)) picked.waiting = 0; + else if (picked.waiting) --picked.waiting; + if (!picked.changed && !picked.waiting) picked.from = style.from, picked.to = style.to; + const auto send = [&](int mode, int speed) { + const auto hex = [](const std::array &colour) { + unsigned value{}; + for (const float part : colour) value = value << 8 | static_cast(std::clamp(part, 0.0f, 1.0f) * 255.0f + 0.5f); + return value; + }; + send_private(menu, "mark-style", std::format("{} {} {:06x} {:06x} {}", item, mode, hex(picked.from), hex(picked.to), speed), + unused, false); + }; + // The card is headed by the cosmetic: whatever is in that slot is what gets coloured. + std::string title = style.name; + for (auto &letter : title) letter = static_cast(std::toupper(static_cast(letter))); + ImGui::PushID(item); + begin_card(menu, title.c_str(), title.c_str()); + // The options in the order shown; a style's mode is 0 what the list gives, 1 off, 2 a + // gradient between the player's two colours, 3 their one colour. + static constexpr std::array modes{0, 2, 3, 1}; + int option = static_cast(std::find(modes.begin(), modes.end(), style.mode) - modes.begin()) % 4; + field(menu, "Color"); + if (choice(menu, "mode", option, {mp.identity_animation.c_str(), "GRADIENT", "SOLID", "OFF"}, shown)) + send(modes[static_cast(option)], style.speed); + if (style.mode == 2 || style.mode == 3) { + const bool gradient = style.mode == 2; + field(menu, gradient ? "Colors" : "Pick", gradient ? "The two colors it moves between. Click one to change it." + : "Click the color to change it."); + ImGui::BeginDisabled(!shown); + constexpr auto flags = ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoLabel | ImGuiColorEditFlags_NoAlpha; + if (ImGui::ColorEdit3("##from", picked.from.data(), flags)) picked.changed = true; + // Beside the swatch, what it gives: the one colour, or all that lies between the two. + ImGui::SameLine(0, px(6)); + const auto at = ImGui::GetCursorScreenPos(); + const float swatch = gradient ? ImGui::GetFrameHeight() + px(6) : 0.0f; + const ImVec2 bar(std::max(px(20), ImGui::GetContentRegionAvail().x - swatch), ImGui::GetFrameHeight()); + const auto solid = [&](const std::array &colour) { + return ImGui::ColorConvertFloat4ToU32(ImVec4(colour[0], colour[1], colour[2], shown ? 1.0f : .4f)); + }; + const auto left = solid(picked.from), right = solid(gradient ? picked.to : picked.from); + ImGui::GetWindowDrawList()->AddRectFilledMultiColor(at, ImVec2(at.x + bar.x, at.y + bar.y), left, right, right, left); + ImGui::Dummy(bar); + if (gradient) { + ImGui::SameLine(0, px(6)); + if (ImGui::ColorEdit3("##to", picked.to.data(), flags)) picked.changed = true; + } + ImGui::EndDisabled(); + // One save when the mouse lets go, not one for every shade dragged through. + if (picked.changed && !ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + picked.changed = false; + picked.waiting = 120; + send(style.mode, style.speed); + } + } + // A colour that stands still has no speed. + if (style.mode == 0 || style.mode == 2) { + int speed = style.speed; + field(menu, "Speed"); + if (choice(menu, "speed", speed, {"NORMAL", "SLOW", "FAST"}, shown)) send(style.mode, speed); + } + end_card(); + ImGui::PopID(); + } + 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 8ce5575a..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" @@ -139,9 +140,11 @@ void apply_performance_settings() { // of older cards resetting the device while loading (DXGI_ERROR_DEVICE_RESET in // gameRendBeginFrame on an RX 580, 2026-10-03). A quarter of the card's dedicated memory, from // the game's own 520 MB up to 3.5 GiB (from 16 GB cards up); cards of 6 GB or less keep the -// game's value. +// game's value. Cards of up to 12 GB stop at 2 GiB: a quarter was 3 GiB of a 12 GB card, and +// with a big map and texture mods on top the game ran out of video memory (E_OUTOFMEMORY from +// CreateCommittedResource on an RTX 4070 SUPER, 2026-10-04). namespace mesh_pool { -constexpr std::uint32_t stock_kb = 532960, most_kb = 3584u * 1024u; +constexpr std::uint32_t stock_kb = 532960, most_kb = 3584u * 1024u, most_to_12gb_kb = 2048u * 1024u; static_assert((std::uint64_t{most_kb} + 24576u) * 1024u <= 0xffffffffull); struct Card { std::uint64_t memory{}; std::string name; }; // The hardware adapter with the most dedicated memory: the one the game renders on (a laptop's @@ -169,9 +172,11 @@ Card largest_card() { } // KiB for the pool on this card, or 0 to keep the game's own value. std::uint32_t size_kb(std::uint64_t memory) { - constexpr std::uint64_t six_gib = 6ull << 30, slack = 256ull << 20; // "6 GB" cards report a little under + // Cards report a little under or over their nominal size. + constexpr std::uint64_t six_gib = 6ull << 30, twelve_gib = 12ull << 30, slack = 256ull << 20; if (memory <= six_gib + slack) return 0; - return static_cast(std::clamp(memory / 4 / 1024, stock_kb, most_kb)); + const auto most = memory <= twelve_gib + slack ? most_to_12gb_kb : most_kb; + return static_cast(std::clamp(memory / 4 / 1024, stock_kb, most)); } } void apply_mesh_streaming_pool() { @@ -987,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 a836fab0..2e841f79 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 97b10683..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" @@ -23,6 +24,7 @@ #endif #include #include +#include #include #include #include @@ -44,6 +46,57 @@ constexpr int restart_for_update = -2; std::string update_version; std::mutex log_mutex; std::ofstream log_file; +// The console is written by a thread of its own. Windows holds a console's output while text +// in its window is selected, and a pipe nobody reads fills up: whoever writes then waits. When +// that was the server loop, the server read nothing from its players until the console let go, +// and they were gone by then. The log file is still written where the line is made. +struct Console { + std::mutex mutex; + std::condition_variable more, drained; + std::deque lines; + std::size_t skipped{}; + bool writing{}, started{}; + void write(std::string line) { + std::lock_guard lock(mutex); + if (!started) { + started = true; + std::thread([this] { run(); }).detach(); + } + // A console that stays held must not grow this without end: the oldest lines go, and + // the file has them all. + if (lines.size() >= 4096) { + lines.pop_front(); + ++skipped; + } + lines.push_back(std::move(line)); + more.notify_one(); + } + void run() { + for (;;) { + std::unique_lock lock(mutex); + more.wait(lock, [&] { return !lines.empty(); }); + const auto line = std::move(lines.front()); + lines.pop_front(); + const auto missed = std::exchange(skipped, 0); + writing = true; + lock.unlock(); + if (missed) std::printf("(%zu earlier lines are only in ReSkateServer.log: the console was not taking output)\n", missed); + std::fputs(line.c_str(), stdout); + lock.lock(); + writing = false; + if (lines.empty()) drained.notify_all(); + } + } + // Waits until everything written so far is on screen, but not for a console that is held. + void flush(std::chrono::milliseconds limit) { + std::unique_lock lock(mutex); + drained.wait_for(lock, limit, [&] { return lines.empty() && !writing; }); + } +}; +Console &console() { + static auto *value = new Console; // outlives every thread that may still be writing at exit + return *value; +} void write_log(const std::string &text) { std::lock_guard lock(log_mutex); const auto now = std::time(nullptr); @@ -55,8 +108,8 @@ void write_log(const std::string &text) { #endif char stamp[32]{}; std::strftime(stamp, sizeof stamp, "%Y-%m-%d %H:%M:%S", &local); - std::printf("[%s] %s\n", stamp + 11, text.c_str()); if (log_file) log_file << '[' << stamp << "] " << text << std::endl; + console().write(std::string("[") + (stamp + 11) + "] " + text + "\n"); } std::atomic finished{}; #ifdef _WIN32 @@ -171,6 +224,16 @@ Input &console_input() { return input; } constexpr auto update_interval = std::chrono::minutes(30); +#ifdef _WIN32 +// Double-clicked, the server is alone on a visible console that closes with it. +// From a terminal, a script, a hosting panel or a hidden scheduled task it is not. +bool own_window() { + DWORD processes[2]{}; + const auto window = GetConsoleWindow(); + return GetConsoleProcessList(processes, 2) == 1 && window && IsWindowVisible(window) && + GetFileType(GetStdHandle(STD_INPUT_HANDLE)) == FILE_TYPE_CHAR; +} +#endif } // namespace #ifdef _WIN32 @@ -331,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 { @@ -385,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; @@ -416,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. @@ -434,6 +517,7 @@ int wmain(int argc, wchar_t **argv) { write_log("Installing server update " + update_version + "..."); install_update(folder()); write_log("Server update " + update_version + " installed; starting it."); + console().flush(std::chrono::seconds(2)); if (relaunch()) { code = 0; break; @@ -447,6 +531,15 @@ int wmain(int argc, wchar_t **argv) { } } finished = true; + // Keep a failure on screen until the host has read it, instead of the window + // vanishing with it. Closing the window or Ctrl+C still ends it at once. + if (code != 0 && !stopping && own_window()) { + auto &input = console_input(); + input.take(); + console().write("Press Enter to close.\n"); + while (!stopping && input.take().empty()) Sleep(50); + } + console().flush(std::chrono::seconds(2)); return code; } #else @@ -460,6 +553,7 @@ int main(int argc, char **argv) { write_log("Installing server update " + update_version + "..."); install_update(folder()); write_log("Server update " + update_version + " installed; starting it."); + console().flush(std::chrono::seconds(2)); if (relaunch()) { code = 0; break; @@ -473,6 +567,7 @@ int main(int argc, char **argv) { } } finished = true; + console().flush(std::chrono::seconds(2)); return code; } #endif 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 bytes) { +void Host::receive(std::uint64_t peer, std::span bytes, std::uint64_t received_at) { auto *link = find(peer); if (!link) return; - if (!link->budget.accept(now_, bytes.size(), 1U)) return drop(peer, "Peer exceeded the multiplayer packet limit."); + // Counted by arrival: after the server was held up, everyone's packets are read at once. + if (!link->budget.accept(received_at ? received_at : now_, bytes.size(), 1U)) + return drop(peer, "Peer exceeded the multiplayer packet limit."); bool missing_reference{}; const auto decoded = link->receiver.receive(bytes, missing_reference, world_); if (missing_reference) return; @@ -747,6 +750,11 @@ void Host::tick(std::uint64_t now) { transport_.disconnect(link.id, "You are banned from this server."); continue; } + // The backend's list (global_bans.h), which can reach a player who is already on. + if (config_.global_bans && multiplayer::reskate_banned(link.id)) { + transport_.disconnect(link.id, multiplayer::banned_notice.data()); + continue; + } auto *guest = find(link.id); if (!guest && join_backoff_.waiting(link.id, now_)) { transport_.disconnect(link.id, "Too many failed attempts to join. Wait a little and try again."); @@ -765,7 +773,7 @@ void Host::tick(std::uint64_t now) { } if (link.connected && !guest->connected_at) guest->connected_at = now_; } - for (const auto &message : transport_.receive()) receive(message.peer, message.bytes); + for (const auto &message : transport_.receive()) receive(message.peer, message.bytes, message.arrived); receive_cosmetics(); for (auto it = guests_.begin(); it != guests_.end();) { auto &g = *(it++)->second; diff --git a/Server/server_host.h b/Server/server_host.h index 198ebeb2..8ca26a4c 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -158,7 +158,8 @@ class Host { void change_map(std::string_view map); // a level name, level path or destination bool same_map(std::string_view asset) const { return map_hash(map_destination(asset)) == map_; } bool accept_data(Guest &source, const Packet &); - void receive(std::uint64_t peer, std::span bytes); + // `received_at`: the transport's arrival time for the message (TransportMessage::arrived). + void receive(std::uint64_t peer, std::span bytes, std::uint64_t received_at); void receive_cosmetics(); void sync_objects(); void apply_layers(); diff --git a/assets/launcher/background.jpg b/assets/launcher/background.jpg index 2c80aeba..1bea5f35 100644 Binary files a/assets/launcher/background.jpg and b/assets/launcher/background.jpg differ diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index b9c8f25d..35c239cb 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -56,6 +56,10 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) target_link_libraries(dingosdk_mod_merge_added_assets_tests PRIVATE dingosdk_mods) target_include_directories(dingosdk_mod_merge_added_assets_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME mod_merge_added_assets COMMAND dingosdk_mod_merge_added_assets_tests) + add_executable(dingosdk_item_list_merge_tests Engine/Vfs/Test/item_list_merge_tests.cpp) + target_link_libraries(dingosdk_item_list_merge_tests PRIVATE dingosdk_mods) + target_include_directories(dingosdk_item_list_merge_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME item_list_merge COMMAND dingosdk_item_list_merge_tests "${DINGOSDK_TEST_GAME_ROOT}") endif() if(WIN32) @@ -128,6 +132,7 @@ endif() add_executable(dingosdk_server Server/main.cpp Server/server_host.cpp Server/server_party.cpp Extension/Multiplayer/Session/party_book.cpp Server/server_config.cpp Server/steam_server.cpp Server/server_update.cpp $<$:Launcher/updater.cpp> + Server/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