From 2cdc5ee8df91caf32beb91eb372cf2d3640d37d1 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Mon, 5 Oct 2026 23:39:16 -0500 Subject: [PATCH] Fix costume merging, restrict server names, add Centrix tag - Mod merge: rebuild a merged bundle's chunk metadata the way the game writes it (one record per chunk, in chunk GUID order), so costumes from different mods built on the same game costume keep their textures and meshes - Merge audit checks that layout, and there is a new unit test for it - Server names: only letters, numbers, spaces and - _ [ ] ( ), 1 to 64 characters; the server browser hides servers with other names - New Centrix tag from the backend's "centrix" list: blue and white name, badge and item animation, ranked after Dev and before Creator --- Engine/Game/Multiplayer/session_model.h | 21 +- .../Vfs/Test/chunk_metadata_merge_tests.cpp | 203 ++++++++++++++++++ Engine/Vfs/Test/mod_merge_audit_tests.cpp | 153 ++++++++++++- Engine/Vfs/mod_merge_combine.cpp | 175 ++++++++++++++- Engine/Vfs/mod_merge_internal.h | 34 +++ Engine/Vfs/native_db.cpp | 2 +- Engine/Vfs/native_db.h | 4 + .../Customization/developer_hoodie_material.h | 7 +- Extension/Multiplayer/Hud/custom_nametags.h | 7 +- .../Multiplayer/Session/session_internal.h | 2 +- .../Multiplayer/Session/session_view.cpp | 4 +- .../Steam/steam_server_browser.cpp | 5 +- .../Test/developer_identity_tests.cpp | 16 +- Extension/Multiplayer/Test/mesh_tests.cpp | 6 + Extension/Multiplayer/developer_identity.cpp | 2 +- Extension/Multiplayer/developer_identity.h | 8 +- Extension/UI/Overlay/nametag_gradient.h | 1 + Extension/UI/Overlay/overlay.h | 2 +- Extension/UI/Overlay/role_badge.h | 2 +- Server/README.txt | 2 +- Server/Test/server_config_tests.cpp | 10 + Server/server_config.cpp | 2 +- Server/server_host.cpp | 2 +- cmake/Apps.cmake | 4 + 24 files changed, 645 insertions(+), 29 deletions(-) create mode 100644 Engine/Vfs/Test/chunk_metadata_merge_tests.cpp diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index 0594ed5..4b3a838 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -8,10 +8,25 @@ #include #include #include +#include #include namespace dingosdk { constexpr unsigned max_map_rotation = 1440; // minutes a server's rotation keeps one map, at most +// A dedicated server's name: 1 to 64 letters, digits, spaces and - _ [ ] ( ), with a +// letter or digit among them and no space at either end. A server refuses any other +// name, and the server browser does not show one. +inline constexpr char server_name_rule[] = "1 to 64 letters, numbers, spaces and - _ [ ] ( )"; +[[nodiscard]] constexpr bool valid_server_name(std::string_view name) noexcept { + if (name.empty() || name.size() > 64 || name.front() == ' ' || name.back() == ' ') return false; + bool named{}; + for (const auto c : name) { + const bool word = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'); + if (!word && std::string_view(" -_[]()").find(c) == std::string_view::npos) return false; + named |= word; + } + return named; +} struct MultiplayerLobby { std::uint64_t id{}, owner{}; std::string name, map, code; @@ -71,7 +86,7 @@ struct MultiplayerChatLine { std::string name, text; bool local{}; // sent by this player // The sender's role, as their nametag shows it: its colour (IM_COL32 layout, 0 = none) - // and a tag shown in a box before the name ("Dev", "Creator", "Homie", "Admin", "Host", "Friend" or empty). + // and a tag shown in a box before the name ("Dev", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend" or empty). std::uint32_t color{}; std::string tag; // With the chat filter on, `text` is masked and this is the line as sent (same length), so @@ -127,7 +142,7 @@ struct MultiplayerModel { float chat_bubbles_distance{40.f}; float chat_bubbles_duration{5.f}; int chat_bubbles_history{3}; - // Local: the tag the ReSkate backend gives this player ("Dev", "Creator" or "Homie"; empty + // Local: the tag the ReSkate backend gives this player ("Dev", "Creator", "Centrix" or "Homie"; empty // for most players) and its role colour, and whether they show it, and the animated items // that come with it, to everyone. std::string identity_tag; @@ -135,7 +150,7 @@ struct MultiplayerModel { bool identity_tag_shown{true}, identity_items_shown{true}; // Local: how each of this player's marked cosmetics is coloured, for the Special page: what // they wear in each slot ("Top", "Shoes", ...), then the parts of their board. mode: 0 what - // their list gives (`identity_animation`: "RAINBOW", "RED" or "GOLD"), 1 off, 2 a gradient + // their list gives (`identity_animation`: "RAINBOW", "RED", "BLUE" or "GOLD"), 1 off, 2 a gradient // between the two colours they picked, 3 the first of them alone. speed: 0 normal, 1 slow, // 2 fast. struct IdentityStyle { diff --git a/Engine/Vfs/Test/chunk_metadata_merge_tests.cpp b/Engine/Vfs/Test/chunk_metadata_merge_tests.cpp new file mode 100644 index 0000000..67aab2d --- /dev/null +++ b/Engine/Vfs/Test/chunk_metadata_merge_tests.cpp @@ -0,0 +1,203 @@ +// A bundle's chunk metadata has one record for each of its chunks: whose the chunk is (the hash of +// the resource's name) and, for a texture, the first mip the bundle holds. In the game's bundles +// the records are in the order of the chunks' guids, which is not the order the chunks are listed +// in. A mod's tool starts from the game's list and writes a record where each chunk it adds or +// replaces is LISTED: right for a chunk it adds at the end, but a replaced chunk's record lands +// on another chunk's, which is then gone from the mod's list. +// +// When two mods each ship a copy of one game bundle with chunks of their own (two costumes built +// on the same game costume), the merged bundle needs a list that describes every chunk and is in +// the game's form. With the game's list alone, or with records taken from where the mods' lists +// have them, the costumes' meshes and textures never streamed in. +#include "Engine/Vfs/mod_merge_internal.h" + +#include +#include +#include +#include + +namespace db = dingosdk::native_db; +namespace fb = dingosdk::frostbite; +using namespace dingosdk::mods::detail; + +namespace { +using Bytes = std::vector; + +int failures = 0; +void check(bool condition, const char* what) { + if (!condition) { std::cerr << "FAILED: " << what << "\n"; ++failures; } +} + +// A chunk's guid: the first byte decides where it sorts. +fb::Guid guid(unsigned char first) { + fb::Guid value; + value.bytes.fill(std::byte{0x11}); + value.bytes[0] = static_cast(first); + return value; +} +// One chunk's record: the hash of what it belongs to, and for a texture its first mip. +struct Record { + std::uint64_t owner{}; + std::optional firstMip; + bool operator==(const Record&) const = default; +}; +db::Node number(unsigned type, const char* name, std::uint64_t value, std::size_t size) { + db::Node node; + node.type = type; + node.named = true; + node.name = name; + for (std::size_t byte = 0; byte < size; ++byte) node.owned.push_back(static_cast(value >> (8 * byte))); + return node; +} +Bytes list_of(const std::vector& records) { + db::Node list; + list.type = 1; + list.named = true; + list.name = "chunkMeta"; + list.terminated = true; + for (const auto& record : records) { + db::Node meta; + meta.type = 2; + meta.named = true; + meta.name = "meta"; + meta.terminated = true; + if (record.firstMip) meta.children.push_back(number(8, "firstMip", *record.firstMip, 4)); + db::Node row; + row.type = 2; + row.terminated = true; + row.children.push_back(number(9, "h64", record.owner, 8)); + row.children.push_back(std::move(meta)); + list.children.push_back(std::move(row)); + } + const auto written = db::write(list); + const auto* bytes = reinterpret_cast(written.data()); + return {bytes, bytes + written.size()}; +} +// What a list says, record by record; a record that says nothing reads as owner 0. +std::vector read_list(const Bytes& list) { + const std::span bytes(reinterpret_cast(list.data()), list.size()); + std::size_t used{}; + const auto root = db::read(bytes, "chunk metadata", &used); + check(used == bytes.size() && root.type == 1 && root.name == "chunkMeta", "the merged list is one list, named as the game names it"); + std::vector records; + for (const auto& row : root.children) { + Record record; + if (const auto* owner = row.field("h64"); owner && owner->payload().size() == 8) + std::memcpy(&record.owner, owner->payload().data(), 8); + if (const auto* meta = row.field("meta")) + if (const auto* mip = meta->field("firstMip"); mip && mip->payload().size() == 4) { + std::uint32_t value{}; + std::memcpy(&value, mip->payload().data(), 4); + record.firstMip = value; + } + records.push_back(record); + } + return records; +} +ChunkRecord chunk(unsigned char first, std::size_t copy, std::size_t index, std::size_t shipped = ChunkRecord::none) { + return {guid(first), copy, index, shipped}; +} +} // namespace + +int main() { + // The game's bundle lists five chunks: something of its own, a texture, a mesh in two chunks + // around another texture. Its list has their records by guid: 10, 30, 50, 60, 70. + const Record mesh{0x1111}, textureA{0xAAAA, 2}, textureB{0xBBBB, 3}, other{0x9999}; + const std::vector shipped{guid(0x50), guid(0x30), guid(0x70), guid(0x10), guid(0x60)}; + const std::vector game{mesh, textureA, other, textureB, mesh}; + constexpr std::size_t gameList = 0; + + // One costume adds a texture (guid 20) and a mesh chunk (guid 80), listed after the game's five, + // with their records at those two places. + const Record firstTexture{0xF001, 0}, firstMesh{0xF002}; + auto first = game; + first.insert(first.end(), {firstTexture, firstMesh}); + // Another, made with an older tool, replaces the game's second texture (guid 60, listed fifth) + // with one whose first mip is 1: its record went over the fifth record, the mesh's, with no hash. + // It also adds a texture (guid 40). + const Record secondTexture{0x5001, 0}; + auto second = game; + second[4] = Record{0, 1}; + second.push_back(secondTexture); + const std::vector lists{list_of(game), list_of(first), list_of(second)}; + + // The merged bundle: the game's five as the higher-priority mod has them, then each mod's own. + const std::vector merged{chunk(0x50, 2, 0, 0), chunk(0x30, 2, 1, 1), chunk(0x70, 2, 2, 2), chunk(0x10, 2, 3, 3), + chunk(0x60, 2, 4, 4), chunk(0x20, 1, 5), chunk(0x80, 1, 6), chunk(0x40, 2, 5)}; + const auto replaced = Record{textureB.owner, 1}; + const std::vector expected{mesh, firstTexture, textureA, secondTexture, other, replaced, mesh, firstMesh}; + const auto list = read_list(merge_chunk_metadata(lists, merged, gameList, shipped)); + check(list == expected, "a record for every chunk, in the order of the chunks' guids"); + check(list.size() == merged.size(), "as many records as chunks"); + check(std::ranges::count(list, mesh) == 2, "the record the older tool wrote over is back: the mesh has both of its"); + check(std::ranges::find(list, replaced) != list.end() && std::ranges::find(list, textureB) == list.end(), + "the replaced texture keeps its own name and takes the first mip the mod gave it"); + check(std::ranges::none_of(list, [](const Record& record) { return record.owner == 0; }), "no record is left without a hash"); + // The fault this is for: the game's own list is three records short, and the mods' lists have + // their records where the chunks are listed, which is not where the game looks. + check(read_list(lists[gameList]).size() + 3 == merged.size(), "the game's own list is three records short of the merged bundle"); + + // No mod's chunk in it: the game's list is not written again. + const std::vector untouched{chunk(0x50, 0, 0, 0), chunk(0x30, 0, 1, 1), chunk(0x70, 0, 2, 2), chunk(0x10, 0, 3, 3), chunk(0x60, 0, 4, 4)}; + check(merge_chunk_metadata(lists, untouched, gameList, shipped) == lists[gameList], "a bundle that is all the game's keeps the game's list as it is"); + // One mod's copy alone is still written in the game's form: its additions go where their guids put them. + const std::vector oneMod{chunk(0x50, 1, 0, 0), chunk(0x30, 1, 1, 1), chunk(0x70, 1, 2, 2), chunk(0x10, 1, 3, 3), + chunk(0x60, 1, 4, 4), chunk(0x20, 1, 5), chunk(0x80, 1, 6)}; + check(read_list(merge_chunk_metadata(lists, oneMod, gameList, shipped)) == + std::vector{mesh, firstTexture, textureA, other, textureB, mesh, firstMesh}, + "one mod's additions are put in guid order with the game's"); + // A newer tool writes the replaced texture's own hash with its first mip; the first mip is what counts. + auto newer = game; + newer[4] = Record{textureB.owner, 0}; + const std::vector withNewer{list_of(game), list_of(newer)}; + const std::vector keepsNewer{chunk(0x50, 1, 0, 0), chunk(0x30, 1, 1, 1), chunk(0x70, 1, 2, 2), chunk(0x10, 1, 3, 3), chunk(0x60, 1, 4, 4)}; + check(read_list(merge_chunk_metadata(withNewer, keepsNewer, gameList, shipped)) == + std::vector{mesh, textureA, other, Record{textureB.owner, 0}, mesh}, + "a replaced texture's new first mip goes into its own record, and the record written over is back"); + // A mod that only carries the game's version of a chunk another mod replaced changes nothing of it. + const std::vector carried{chunk(0x50, 1, 0, 0), chunk(0x30, 1, 1, 1), chunk(0x70, 1, 2, 2), chunk(0x10, 1, 3, 3), + chunk(0x60, 1, 4, 4), chunk(0x20, 1, 5), chunk(0x80, 1, 6)}; + check(read_list(merge_chunk_metadata(lists, carried, gameList, shipped))[4] == textureB, "a chunk a mod only carries keeps the game's record"); + + // A chunk whose copy has no list, or a shorter one, still takes up a place. + const std::vector unknown{chunk(0x50, 0, 0, 0), chunk(0x30, 0, 1, 1), chunk(0x70, 0, 2, 2), chunk(0x10, 0, 3, 3), + chunk(0x60, 0, 4, 4), chunk(0x20, ChunkRecord::none, 5), chunk(0x80, 1, 9)}; + check(read_list(merge_chunk_metadata(lists, unknown, gameList, shipped)) == + std::vector{mesh, Record{}, textureA, other, textureB, mesh, Record{}}, + "a chunk no list describes gets a record that says nothing, and the others stay in step"); + + // A bundle the game has no list for (it has no chunks there, or does not ship the bundle): every + // chunk is a mod's, with the record of the copy it came in. Maps each add their loading screen + // to one such bundle. + const std::vector modsOnly{list_of({firstTexture, firstMesh}), list_of({firstTexture, firstMesh, secondTexture})}; + const std::vector theirs{chunk(0x90, 1, 0), chunk(0x20, 1, 1), chunk(0x40, 1, 2)}; + check(merge_chunk_metadata(modsOnly, theirs) == modsOnly[1], "a bundle that is one mod's copy alone keeps that copy's list"); + const std::vector both{chunk(0x90, 0, 0), chunk(0x20, 0, 1), chunk(0x40, 1, 2)}; + check(read_list(merge_chunk_metadata(modsOnly, both)) == std::vector{firstMesh, secondTexture, firstTexture}, + "and otherwise each chunk's record from the copy it came in, by guid like any other"); + // A game list that is not one record a chunk is of some other kind: nothing is put in order by it. + const std::vector shortGame{list_of({mesh, textureA}), list_of(first)}; + check(read_list(merge_chunk_metadata(shortGame, oneMod, gameList, shipped)) == first, + "a game list that is not a record a chunk leaves the records where the copies have them"); + + // No chunks, no list to write: what the first copy has stays. + check(merge_chunk_metadata({}, {}).empty(), "no list from no lists"); + check(merge_chunk_metadata(lists, {}, gameList, shipped) == lists[gameList], "a bundle without chunks keeps the first copy's list"); + const std::vector none{Bytes{}, Bytes{}}; + const std::vector bare{chunk(0x10, ChunkRecord::none, 0), chunk(0x20, ChunkRecord::none, 1)}; + check(merge_chunk_metadata(none, bare).empty(), "nor from copies that have none"); + + // A list that is needed and cannot be read is the caller's to deal with. + const std::vector broken{lists[0], Bytes(7, std::byte{0x41})}; + bool threw{}; + try { (void)merge_chunk_metadata(broken, oneMod, gameList, shipped); } + catch (const std::exception&) { threw = true; } + check(threw, "an unreadable list throws"); + + if (failures) { + std::cerr << "chunk metadata merge: " << failures << " check(s) failed\n"; + return 1; + } + std::cout << "chunk metadata merge: ok\n"; + return 0; +} diff --git a/Engine/Vfs/Test/mod_merge_audit_tests.cpp b/Engine/Vfs/Test/mod_merge_audit_tests.cpp index 692688c..321ba3e 100644 --- a/Engine/Vfs/Test/mod_merge_audit_tests.cpp +++ b/Engine/Vfs/Test/mod_merge_audit_tests.cpp @@ -12,7 +12,10 @@ // every reference a mod appended to one of its lists, and has every value a mod changed in // place changed: no value in it is the merge's own invention; // * a list whose entries the game keeps beside it (an item list, the music playlist) names -// nothing its own bundle does not hold, in every copy of that bundle the patch carries. +// nothing its own bundle does not hold, in every copy of that bundle the patch carries; +// * a bundle's chunk metadata has one record for each of its chunks, in the order of the +// chunks' guids as the game writes it: every texture's own record, with the first mip its +// header gives, is at its chunk's place. // What is only reported: assets two mods add under one name, and assets of the game two mods // change where the patch has one mod's copy whole. // @@ -30,7 +33,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -225,6 +230,12 @@ struct Audit { std::map facts; std::map guids; std::vector contested; + // The patch's bundles (by TOC and bundle) that a mod ships a readable copy of: the ones whose + // chunk metadata the merge may have written. + std::set> chunkBundles; + // Those of them the patch has exactly as one mod ships them, chunks and list: the merge wrote + // nothing there. + std::set> chunkBundlesAsShipped; std::chrono::steady_clock::time_point started = std::chrono::steady_clock::now(); void progress(const std::string& what) const { @@ -305,6 +316,84 @@ struct Audit { } return result; } + // One record of a bundle's chunk metadata: whose the chunk is, and a texture's first mip. + struct ChunkMeta { + std::optional owner; + std::optional firstMip; + }; + // A copy's chunk metadata, record by record; nothing when the list cannot be read. + static std::optional> records_of(const Bundle& bundle) { + std::vector records; + const auto& list = bundle.listing->manifest.chunkMetadata; + if (list.empty()) return records; + try { + const std::span bytes(reinterpret_cast(list.data()), list.size()); + const auto root = native_db::read(bytes, "chunk metadata", nullptr, {.unique_fields = false, .max_entries = bytes.size()}); + for (const auto& row : root.children) { + ChunkMeta record; + if (const auto* hash = row.field("h64"); hash && hash->payload().size() == 8) + std::memcpy(&record.owner.emplace(), hash->payload().data(), 8); + if (const auto* meta = row.field("meta")) + if (const auto* mip = meta->field("firstMip"); mip && mip->payload().size() == 4) + std::memcpy(&record.firstMip.emplace(), mip->payload().data(), 4); + records.push_back(record); + } + } catch (const std::exception&) { + return std::nullopt; + } + return records; + } + // The hash a record names a resource by: of its lower-case name. + static std::uint64_t owner_hash(std::string_view name) { + std::uint64_t value = 5381; + for (const auto c : name) value = value * 33U ^ static_cast(std::tolower(static_cast(c))); + return value; + } + // The textures of a copy of a bundle whose pixels are one of the bundle's chunks, each against + // the record at its chunk's place when the chunks are in the order of their guids: how many + // there are, how many have another resource's record (or none) there, how many their own with + // another first mip than the texture's header gives, and one of those that are wrong. + struct TextureRecords { + std::size_t textures{}, misplaced{}, mips{}; + std::string example; + }; + TextureRecords texture_records(int from, const Bundle& bundle, const std::vector& records) const { + constexpr std::uint32_t textureType = 0x6BDE20BA; + const auto& manifest = bundle.listing->manifest; + std::vector order(manifest.chunks.size()); + for (std::size_t index = 0; index < order.size(); ++index) order[index] = index; + std::ranges::sort(order, [&](std::size_t left, std::size_t right) { return manifest.chunks[left].guid < manifest.chunks[right].guid; }); + std::map guidPlace; + for (std::size_t at = 0; at < order.size(); ++at) guidPlace.emplace(manifest.chunks[order[at]].guid, at); + TextureRecords result; + for (std::size_t index = 0; index < manifest.resources.size(); ++index) { + const auto& resource = manifest.resources[index]; + if (resource.resourceType != textureType || resource.resourceMeta.size() < 4) continue; + Bytes header; + try { header = payload(place(from, bundle.assets[manifest.ebx.size() + index].file)); } catch (const std::exception&) { continue; } + if (header.size() != 144 && header.size() != 180) continue; + // The header: type and format, a version's extra field, then flags and sizes, the mip + // count and first mip, padding, and the chunk the pixels are in. + std::uint32_t version{}; + std::memcpy(&version, resource.resourceMeta.data(), 4); + const std::size_t mips = 8 + 4 + 4 + (version >= 12 ? 4 : 0) + 10; + const auto firstMip = static_cast(header[mips + 1]); + fb::Guid chunk; + std::memcpy(chunk.bytes.data(), header.data() + mips + 2 + (header.size() == 180 ? 8 : 4), 16); + const auto at = guidPlace.find(chunk); + if (at == guidPlace.end()) continue; // its pixels are not in this bundle + ++result.textures; + const auto* record = at->second < records.size() ? &records[at->second] : nullptr; + if (!record || record->owner != owner_hash(resource.name)) { + if (!result.misplaced++ && result.example.empty()) result.example = resource.name + " has " + (record ? "another resource's record" : "no record") + " at its chunk's place"; + } else if (record->firstMip != firstMip) { + if (!result.mips++ && result.example.empty()) + result.example = resource.name + " is told first mip " + (record->firstMip ? std::to_string(*record->firstMip) : std::string("none")) + + ", its header says " + std::to_string(firstMip); + } + } + return result; + } std::string mod_names(const std::vector& list) const { std::set unique; for (const auto index : list) unique.insert(catalog.mods[index].name); @@ -567,6 +656,11 @@ struct Audit { const auto mine = read_bundle(static_cast(index + 2), bundle); if (!mine.listing || !patched.listing) { ++byName; continue; } const auto* game = game_bundle(bundle.name); + chunkBundles.emplace(merged->second, at->second); + if (mine.listing->manifest.chunkMetadata == patched.listing->manifest.chunkMetadata && + std::ranges::equal(mine.listing->manifest.chunks, patched.listing->manifest.chunks, {}, + &fb::BundleAsset::guid, &fb::BundleAsset::guid)) + chunkBundlesAsShipped.emplace(merged->second, at->second); for (const auto& entry : mine.assets) { ++assets; const auto key = key_of(*entry.asset); @@ -884,6 +978,62 @@ struct Audit { std::cout << copies << " such list(s) with entries mods added, across every copy in the patch; " << entries << " added entries checked\n"; } + // ---------------------------------------------------------------- chunk metadata + // The engine reads a chunk's record before it streams the chunk. The game writes a bundle's + // list with a record for each chunk, in the order of the chunks' guids, so a texture's record + // (the hash of its name, and its first mip) is at its chunk's place in that order. A bundle + // the patch has from more than the game's copy (two mods' costumes built on one game costume, + // each with chunks of its own) has to be in that form as well: with fewer records than + // chunks, or with records where the mods' own lists have them, textures and meshes get + // another's record or none and never appear. Checked wherever the game's own copy of the + // bundle is in that form, which is everywhere it has been looked at. + void chunk_metadata() { + section("Chunk metadata"); + std::size_t bundles{}, untouched{}, asShipped{}, checked{}, textures{}, unknownForm{}; + for (const auto& where : chunkBundles) { + const auto& toc = tocs[where.first]; + const auto& patched = toc.bundles[where.second]; + const auto& kept = patched.listing->manifest.chunks; + if (kept.empty()) continue; + ++bundles; + const auto what = toc.relative + ": " + patched.name; + const auto* game = game_bundle(patched.name); + // The game's own list on the game's own chunks is what the game shipped. + if (game && game->listing->manifest.chunkMetadata == patched.listing->manifest.chunkMetadata && + game->listing->manifest.chunks.size() == kept.size() && + std::ranges::equal(game->listing->manifest.chunks, kept, {}, &fb::BundleAsset::guid, &fb::BundleAsset::guid)) { + ++untouched; + continue; + } + // One mod's copy passed through whole is that mod's to have right: its tool's list is + // not in the game's form, and the merge does not rewrite what only one mod ships. + if (chunkBundlesAsShipped.contains(where)) { ++asShipped; continue; } + const auto records = records_of(patched); + if (!records) { fail("chunk metadata", what + ": the patch's list cannot be read"); continue; } + if (records->size() != kept.size() && !(records->empty() && (!game || game->listing->manifest.chunkMetadata.empty()))) { + fail("chunk metadata", what + ": " + std::to_string(records->size()) + " record(s) for " + + std::to_string(kept.size()) + " chunk(s)"); + continue; + } + // Only a bundle whose own copy in the game is in guid order says what the patch's has to be. + if (!game) { ++unknownForm; continue; } + const auto shipped = records_of(*game); + if (!shipped) { ++unknownForm; continue; } + const auto original = texture_records(0, *game, *shipped); + if (original.misplaced || original.mips) { ++unknownForm; continue; } + ++checked; + const auto merged = texture_records(1, patched, *records); + textures += merged.textures; + if (merged.misplaced || merged.mips) + fail("chunk metadata", what + ": of " + std::to_string(merged.textures) + " texture(s) with a chunk in the bundle, " + + std::to_string(merged.misplaced) + " do not have their own record at their chunk's place and " + + std::to_string(merged.mips) + " have another first mip than their header, e.g. " + merged.example); + } + std::cout << bundles << " bundle(s) mods ship that hold chunks: " << untouched << " with the game's own list on the game's own chunks, " + << asShipped << " exactly as one mod ships them, " << checked << " others checked, " << textures + << " texture record(s) in them; " << unknownForm << " whose own copy in the game gives nothing to check against\n"; + } + int summary() const { section("Summary"); std::size_t total{}; @@ -908,6 +1058,7 @@ int main(int argc, char** argv) try { audit.mods_against_patch(); audit.outcomes(); audit.lists(); + audit.chunk_metadata(); return audit.summary(); } catch (const std::exception& error) { std::cerr << "FAIL: " << error.what() << '\n'; diff --git a/Engine/Vfs/mod_merge_combine.cpp b/Engine/Vfs/mod_merge_combine.cpp index 3695e4c..dc6b4cf 100644 --- a/Engine/Vfs/mod_merge_combine.cpp +++ b/Engine/Vfs/mod_merge_combine.cpp @@ -36,6 +36,9 @@ struct Asset { fb::BundleFileInfo file; bool base{}; const std::string* mod{}; // whose copy of the bundle it came in; null for the game's + // For a chunk: where its record is in the chunk metadata of the copy this version + // of it came in, and where the game's copy lists the chunk (merge_chunk_metadata). + ChunkRecord record; }; // Where one contributor's copy of an asset lives, so conflicting copies can be @@ -95,7 +98,13 @@ struct BundleState { std::uint32_t manifestChunk{}; std::vector assets; std::vector files; - std::vector metadata; + // Each copy's chunk metadata; copies that carry the same list share an entry. + // Every chunk remembers its record in one of these (Asset::record), and the + // list is written again when the manifest is rebuilt (merge_chunk_metadata). + std::vector> metadata; + // Which of those is the game's own, and the chunks the game's copy lists. + std::size_t gameMetadata{ChunkRecord::none}; + std::vector shippedChunks; // Keyed position of each asset, so replacing one stays constant time: these // bundles hold tens of thousands of entries. std::map> seen; @@ -196,8 +205,115 @@ void build_manifest(const std::vector& assets, std::span emit(fb::AssetKind::chunk, manifest.chunks); } +std::span unsigned_bytes(std::span data) { + return {reinterpret_cast(data.data()), data.size()}; +} + } // namespace +std::vector merge_chunk_metadata(std::span> lists, + std::span chunks, std::size_t game, + std::span shipped) { + // A bundle with no chunks has nothing to describe; what the first copy has stays. + if (chunks.empty()) return lists.empty() ? std::vector{} : lists.front(); + const bool shippedList = game < lists.size() && !lists[game].empty(); + + // Nothing moved: nearly every bundle is this, and its list is not read at all. + // With the game's list, every chunk is the game's own where the game lists it; + // without one, every chunk keeps one copy's record at its own place. + const auto first = shippedList ? game : chunks.front().copy; + bool whole = first < lists.size() && (!shippedList || chunks.size() == shipped.size()); + for (std::size_t index = 0; whole && index < chunks.size(); ++index) + whole = chunks[index].copy == first && chunks[index].index == index && + (!shippedList || chunks[index].shipped == index); + if (whole) return lists[first]; + + std::vector> read(lists.size()); + const auto list = [&](std::size_t copy) -> const native_db::Node* { + if (copy >= lists.size() || lists[copy].empty()) return nullptr; + // A level's root bundle has thousands of chunks; every value takes a byte at least. + if (!read[copy]) + read[copy] = native_db::read(unsigned_bytes(lists[copy]), "chunk metadata", nullptr, + {.unique_fields = false, .max_entries = lists[copy].size()}); + return &*read[copy]; + }; + const auto row = [&](std::size_t copy, std::size_t index) -> const native_db::Node* { + const auto* from = list(copy); + return from && index < from->children.size() ? &from->children[index] : nullptr; + }; + + // The game's list, when it is the kind this is written as: a record for each of + // the game's chunks, in the order of their guids. + const auto* original = shippedList ? list(game) : nullptr; + if (original && original->children.size() != shipped.size()) original = nullptr; + std::vector place(shipped.size()); // where each of the game's chunks has its record + if (original) { + std::vector order(shipped.size()); + for (std::size_t index = 0; index < order.size(); ++index) order[index] = index; + std::ranges::sort(order, [&](std::size_t left, std::size_t right) { return shipped[left] < shipped[right]; }); + for (std::size_t at = 0; at < order.size(); ++at) place[order[at]] = at; + } + + std::vector records; + std::vector described(chunks.size()); + records.reserve(chunks.size()); + for (std::size_t index = 0; index < chunks.size(); ++index) { + const auto& chunk = chunks[index]; + const auto* written = row(chunk.copy, chunk.index); + const native_db::Node* kept = written; + std::optional changed; + if (original && chunk.shipped < shipped.size()) { + kept = &original->children[place[chunk.shipped]]; + // A mod's version of the chunk: its tool started from the game's list and wrote a + // record where the chunk is listed, in place of whatever the game has there. The + // first mip it gave is the one the mod's texture has; the rest of it is not the + // chunk's (the hash is another chunk's, or empty). + const auto* before = chunk.index < original->children.size() ? &original->children[chunk.index] : nullptr; + if (chunk.copy != game && written && (!before || native_db::write(*written) != native_db::write(*before))) + if (const auto* meta = written->field("meta")) + if (const auto* mip = meta->field("firstMip")) { + changed = *kept; + if (auto* own = changed->field("meta")) { + if (auto* current = own->field("firstMip")) *current = *mip; + else own->children.push_back(*mip); + kept = &*changed; + } + } + } + described[index] = kept != nullptr; + records.push_back(kept ? *kept : native_db::Node{}); + } + const native_db::Node* shape = original; + for (std::size_t copy = 0; !shape && copy < read.size(); ++copy) + if (read[copy]) shape = &*read[copy]; + if (!shape) return {}; // no copy has a list at all + // A chunk no list describes still takes up its place, with a record that says nothing. + native_db::Node blank; + blank.type = 2; + for (std::size_t index = 0; index < records.size(); ++index) + if (described[index]) { + blank = records[index]; + blank.children.clear(); + break; + } + for (std::size_t index = 0; index < records.size(); ++index) + if (!described[index]) records[index] = blank; + + // As the game writes it: by the chunks' guids. Only a bundle whose own list in the + // game is of another kind keeps the order its chunks are listed in. + std::vector order(chunks.size()); + for (std::size_t index = 0; index < order.size(); ++index) order[index] = index; + if (original || !shippedList) + std::ranges::stable_sort(order, [&](std::size_t left, std::size_t right) { return chunks[left].guid < chunks[right].guid; }); + auto merged = *shape; + merged.children.clear(); + merged.children.reserve(records.size()); + for (const auto index : order) merged.children.push_back(std::move(records[index])); + const auto bytes = native_db::write(merged); + const auto* data = reinterpret_cast(bytes.data()); + return {data, data + bytes.size()}; +} + // One superbundle, combined across every mod that ships it. `used` collects the // patch archives the result actually references, keyed by install chunk, so the // layout declares those and only those. @@ -561,6 +677,8 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, state.opaqueWhy = manifestFailure; state.files = std::move(region.files); state.assets.clear(); state.seen.clear(); + state.metadata.clear(); state.shippedChunks.clear(); + state.gameMetadata = ChunkRecord::none; } return; } @@ -580,7 +698,29 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, } if (casBacked) state.casBacked = true; if (!region.files.empty()) state.manifestChunk = region.files.front().location.installChunk; - if (fresh || state.metadata.empty()) state.metadata = manifest.chunkMetadata; + // Each chunk remembers where this copy lists it, and with that its record + // in this copy's chunk metadata, whichever version of the chunk is kept. + { + auto copy = ChunkRecord::none; + if (!manifest.chunkMetadata.empty()) { + auto list = std::find(state.metadata.begin(), state.metadata.end(), manifest.chunkMetadata); + if (list == state.metadata.end()) list = state.metadata.insert(state.metadata.end(), manifest.chunkMetadata); + copy = static_cast(list - state.metadata.begin()); + } + if (isBase) { + state.gameMetadata = copy; + state.shippedChunks.clear(); + for (const auto& chunk : manifest.chunks) state.shippedChunks.push_back(chunk.guid); + } + const auto firstChunk = manifest.ebx.size() + manifest.resources.size(); + for (std::size_t index = 0; index < manifest.chunks.size() && firstChunk + index < flat.size(); ++index) { + auto& record = flat[firstChunk + index].record; + record.guid = manifest.chunks[index].guid; + record.copy = copy; + record.index = index; + if (isBase) record.shipped = index; + } + } 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. @@ -645,6 +785,8 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, if (!count++) example = entry.asset.name; state.displaced.emplace_back(at->first, previous.asset.sha1); } + // Whichever version is kept, it is still the chunk the game lists there. + entry.record.shipped = previous.record.shipped; state.assets[at->second] = std::move(entry); } } @@ -884,9 +1026,36 @@ fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, } } + // The merged bundle holds every mod's chunks, so its chunk metadata has + // to describe them all, and in the game's own form: a record for each + // chunk, in the order of the chunks' guids. With the game's list alone, + // or with the lists the mods ship (which are not in that order, and + // have lost the records their tools wrote over), the meshes and textures + // of costumes built on one game costume never stream in. + std::vector metadata; + { + std::vector records; + std::size_t fromMods{}; + for (const auto& entry : state.assets) { + if (entry.asset.kind != fb::AssetKind::chunk) continue; + records.push_back(entry.record); + fromMods += entry.record.shipped == ChunkRecord::none || entry.record.copy != state.gameMetadata; + } + try { + metadata = merge_chunk_metadata(state.metadata, records, state.gameMetadata, state.shippedChunks); + if (fromMods && state.gameMetadata != ChunkRecord::none && + metadata != state.metadata[state.gameMetadata]) + report.notes.push_back(name + ": chunk metadata written again for " + std::to_string(records.size()) + + " chunks, " + std::to_string(fromMods) + " of them added, changed or carried by mods"); + } catch (const std::exception& failure) { + if (!state.metadata.empty()) metadata = state.metadata.front(); + report.notes.push_back(name + ": the copies' chunk metadata could not be put together (" + failure.what() + + "); kept the first copy's, so chunks mods add to this bundle may not load"); + } + } fb::BinaryBundle manifest; std::vector files; - build_manifest(state.assets, state.metadata, manifest, files); + build_manifest(state.assets, metadata, manifest, files); if (!state.casBacked) { region = fb::write_bundle_region(files, fb::write_binary_bundle(manifest)); } else { diff --git a/Engine/Vfs/mod_merge_internal.h b/Engine/Vfs/mod_merge_internal.h index c2dac61..722ae95 100644 --- a/Engine/Vfs/mod_merge_internal.h +++ b/Engine/Vfs/mod_merge_internal.h @@ -230,6 +230,40 @@ AssetOverrides collect_asset_overrides(const std::vector& mods, const CasStore& store, const fs::path& baseRoot, const fs::path& gameRoot, MergeReport& report); +// A bundle's chunk metadata is a list with one record for each of its chunks: the +// hash of the name of the resource the chunk belongs to ("h64") and what the +// engine needs before it streams the chunk (a texture's first mip). In the game's +// own bundles the records are in the order of the chunks' guids, not the order +// the chunks are listed in. A mod's copy starts from the game's list as it is and +// has, at the place each chunk it adds or replaces is listed at, a record its +// tool wrote: for an added chunk that is the chunk's own record, but for a +// replaced one it has taken the place of another chunk's (and older tools left +// its hash empty), so the game's record for that other chunk is gone from the +// mod's list. +struct ChunkRecord { + static constexpr std::size_t none = static_cast(-1); + fb::Guid guid; + std::size_t copy{none}; // the list of the copy this version of the chunk came in; none when that copy has no list + std::size_t index{}; // where that copy lists the chunk + std::size_t shipped{none}; // where the game's copy lists it; none for a chunk mods add +}; +// The list for a merged bundle, written as the game writes one: a record for each +// of `chunks` (the bundle's chunks, as listed), in the order of their guids. A +// chunk of the game's keeps the game's record for it, whichever copy carries it +// (`game` is the game's list among `lists`, `shipped` the chunks the game's copy +// lists); when the kept version is one a mod replaced it with, the first mip that +// mod's record gives goes into it. A chunk a mod adds has that mod's record, and +// one nothing describes gets a record that says nothing. In a bundle the game +// does not ship, or ships without a list, every chunk is one a mod adds. When +// every chunk is the game's own where the game lists it, the game's list comes +// back as it is; so does one mod's, for a bundle without a list in the game that +// is that mod's copy alone. +// Throws when a list that is needed cannot be read. +[[nodiscard]] std::vector merge_chunk_metadata(std::span> lists, + std::span chunks, + std::size_t game = ChunkRecord::none, + std::span shipped = {}); + fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot, std::vector& sources, MergeReport& report, const std::string& relative, ArchiveUse& used, CasStore& store, diff --git a/Engine/Vfs/native_db.cpp b/Engine/Vfs/native_db.cpp index 0e46518..8a428d9 100644 --- a/Engine/Vfs/native_db.cpp +++ b/Engine/Vfs/native_db.cpp @@ -12,7 +12,7 @@ public: : input_(input), context_(context), options_(options) {} Node read(unsigned depth = 0) { - if (depth > 32 || ++nodes_ > 32768) fail("DB nesting/entry limit exceeded"); + if (depth > 32 || ++nodes_ > options_.max_entries) fail("DB nesting/entry limit exceeded"); const auto tag = take(1)[0]; Node node; node.type = tag & 31; diff --git a/Engine/Vfs/native_db.h b/Engine/Vfs/native_db.h index 5d0c0d1..e6380ac 100644 --- a/Engine/Vfs/native_db.h +++ b/Engine/Vfs/native_db.h @@ -41,6 +41,10 @@ struct Options { // an entry. layout.toc's meta section repeats keys by design, so readers of // that file turn the rule off. bool unique_fields = true; + // How many values a file may hold: more than any the game reads whole at + // startup. A reader of something longer by nature (the chunk list of a + // level's root bundle) raises it to what its input can hold. + std::size_t max_entries = 32768; }; // Throws std::runtime_error prefixed with `context`, for example "InitFS". diff --git a/Extension/Customization/developer_hoodie_material.h b/Extension/Customization/developer_hoodie_material.h index 29e0a48..6281af3 100644 --- a/Extension/Customization/developer_hoodie_material.h +++ b/Extension/Customization/developer_hoodie_material.h @@ -121,12 +121,14 @@ inline std::pair, std::array> stand 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}}; + if (mark == IdentityList::centrix) return {{0x1f, 0x7b, 0xff}, {0xff, 0xff, 0xff}}; 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: +// homie's from deep gold through bright gold to yellow and back, and Centrix's from blue +// through light blue to white and back. No style asks for the rainbow: // it is a developer's standard, and nobody else's. inline ItemAnimation item_animation(std::optional mark, const multiplayer::MarkStyle &style) noexcept { using multiplayer::IdentityList; @@ -147,7 +149,8 @@ inline ItemAnimation item_animation(std::optional mar // 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; + constexpr std::array blue{{{.01f, .16f, .8f}, {.3f, .5f, .8f}, {.8f, .8f, .8f}}}; + out.stops = *mark == IdentityList::content_creator ? red : *mark == IdentityList::centrix ? blue : gold; } return out; } diff --git a/Extension/Multiplayer/Hud/custom_nametags.h b/Extension/Multiplayer/Hud/custom_nametags.h index c259337..9850af3 100644 --- a/Extension/Multiplayer/Hud/custom_nametags.h +++ b/Extension/Multiplayer/Hud/custom_nametags.h @@ -33,13 +33,16 @@ struct NametagPlayer { inline constexpr std::uint32_t nametag_white = 0xffffffffU, nametag_developer = 0xffff78b4U, // purple nametag_creator = 0xff6464ffU, // red nametag_homie = 0xff7ae2ffU, // gold + nametag_centrix = 0xffff7b1fU, // blue (#1F7BFF) nametag_admin = 0xffc674ffU, // pink nametag_host = 0xffffc85eU, // blue nametag_friend = 0xff8ae07bU; // green -// Developers, content creators and homies shimmer from these to their role colour. +// Developers, content creators, homies and Centrix shimmer from these to their role colour +// (Centrix between white and its blue). inline constexpr std::uint32_t nametag_developer_start = 0xffff206eU, // #6E20FF, IM_COL32 layout nametag_creator_start = 0xff2e10c8U, // #C8102E - nametag_homie_start = 0xff008ae0U; // #E08A00 + nametag_homie_start = 0xff008ae0U, // #E08A00 + nametag_centrix_start = 0xffffffffU; // white // 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/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index fe15740..00ea1b7 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -363,7 +363,7 @@ void refresh_friends(Session &s); void add_chat(Session &s, std::uint64_t sender, std::string name, std::string text, bool local = false, bool marks = true); // The colour and badge of one of the backend's categories. std::pair mark_role(IdentityList list); -// A player's role colour and badge ("Dev", "Creator", "Homie", "Admin", "Host", "Friend" or none), shown in chat +// A player's role colour and badge ("Dev", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend" or none), shown in chat // and on their nametag. `local`: the local player. std::pair player_role(Session &s, std::uint64_t id, bool local, bool marks = true); // Sends one line from this player; returns why not when it cannot. diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index ce5b422..f6579ab 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -176,7 +176,8 @@ void publish(Session &s, const NativeFrame *local) { 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"; + *mark == IdentityList::developer ? "RAINBOW" : *mark == IdentityList::content_creator ? "RED" : + *mark == IdentityList::centrix ? "BLUE" : "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()); @@ -538,6 +539,7 @@ std::pair mark_role(IdentityList list) { switch (list) { case IdentityList::developer: return {nametag_developer, "Dev"}; case IdentityList::content_creator: return {nametag_creator, "Creator"}; + case IdentityList::centrix: return {nametag_centrix, "Centrix"}; default: return {nametag_homie, "Homie"}; } } diff --git a/Extension/Multiplayer/Steam/steam_server_browser.cpp b/Extension/Multiplayer/Steam/steam_server_browser.cpp index 703bfc6..03017f0 100644 --- a/Extension/Multiplayer/Steam/steam_server_browser.cpp +++ b/Extension/Multiplayer/Steam/steam_server_browser.cpp @@ -206,10 +206,11 @@ void SteamServerBrowser::read() { } // A restarted server gets a new, higher Steam ID, while Steam lists the old // one for a while. Only one can hold an address and port: keep the newest. - // Servers named with bad words are never shown (they refuse to list themselves too). + // Servers named with bad words, or with characters a server name cannot have, are + // never shown (a server refuses such a name too, but anyone can list one). std::vector rows; for (const auto &[id, entry] : found_) { - if (text::contains_bad_words(entry.row.name)) continue; + if (!valid_server_name(entry.row.name) || text::contains_bad_words(entry.row.name)) continue; const bool replaced = std::any_of(found_.begin(), found_.end(), [&](const auto &other) { return other.first > id && std::any_of(entry.addresses.begin(), entry.addresses.end(), [&](const auto &a) { return std::find(other.second.addresses.begin(), other.second.addresses.end(), a) != other.second.addresses.end(); diff --git a/Extension/Multiplayer/Test/developer_identity_tests.cpp b/Extension/Multiplayer/Test/developer_identity_tests.cpp index 865fb67..6ef4fd3 100644 --- a/Extension/Multiplayer/Test/developer_identity_tests.cpp +++ b/Extension/Multiplayer/Test/developer_identity_tests.cpp @@ -33,12 +33,12 @@ void parsing() { 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(), + lists[3].empty() && lists[4].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"); + check(bans[0] == std::vector{dev} && bans[static_cast(L::banned)] == 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"]}})"); @@ -72,7 +72,7 @@ void lookup() { "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) && + 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"); @@ -82,6 +82,16 @@ void lookup() { 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"); + // Centrix: read from the answer as its own list, and its mark comes after a developer's and + // before a content creator's. + const auto centrix = parse_identity_lists(R"({"categories":{"centrix":["76561198000000005"],"dev":["76561198000000001"]}})"); + check(centrix[static_cast(L::centrix)] == std::vector{stranger} && centrix[static_cast(L::banned)].empty(), + "The Centrix list was not read as its own"); + check(publish_identity_lists({{{dev}, {homie, stranger}, {creator, stranger}, {dev, creator, stranger}}}) && + identity_mark(dev) == L::developer && identity_mark(stranger) == L::centrix && identity_mark(creator) == L::centrix && + identity_mark(homie) == L::homie && !reskate_banned(stranger), + "Centrix is not marked after a developer and before a content creator"); + check(publish_identity_lists({{{dev}, {dev, homie, creator}, {dev, creator}}}), "The lists did not go back"); // Their tag and their items are each their own to hide. check(own_tag_shown() && own_items_shown(), "A player's tag or items start hidden"); show_own_tag(false); diff --git a/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index 7f84ae8..7f37c78 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -677,6 +677,12 @@ void role_checks() { "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 special tag comes before every lobby role: a Centrix player who hosts is Centrix, not Host. + simulated_identities.insert({id(host), L::centrix}); + check(player_role(first, id(host), false) == Role{nametag_centrix, "Centrix"} && + player_role(host, id(host), true) == Role{nametag_centrix, "Centrix"}, + "A Centrix player who hosts is not shown as Centrix"); + simulated_identities.erase({id(host), L::centrix}); // 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. diff --git a/Extension/Multiplayer/developer_identity.cpp b/Extension/Multiplayer/developer_identity.cpp index 83a7bca..8b2d9fb 100644 --- a/Extension/Multiplayer/developer_identity.cpp +++ b/Extension/Multiplayer/developer_identity.cpp @@ -11,7 +11,7 @@ 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 std::array categories{"dev", "homie", "content_creator", "centrix"}; constexpr auto banned = static_cast(IdentityList::banned); std::atomic> current; diff --git a/Extension/Multiplayer/developer_identity.h b/Extension/Multiplayer/developer_identity.h index e03ceb8..03290ba 100644 --- a/Extension/Multiplayer/developer_identity.h +++ b/Extension/Multiplayer/developer_identity.h @@ -13,7 +13,7 @@ namespace dingosdk::multiplayer { // api.reskate.dev, so they change without a release. Steam authenticates these // identities. Each category has its tag in chat and on nametags (player_role) // and its animation on the marked hoodie and board (developer_hoodie_material.h). -enum class IdentityList : std::uint8_t { developer, homie, content_creator, banned, count }; +enum class IdentityList : std::uint8_t { developer, homie, content_creator, centrix, 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"; @@ -26,10 +26,10 @@ void refresh_identity_lists() noexcept; bool identity_listed(std::uint64_t id, IdentityList list) noexcept; // Shared by the developer tag and the rainbow hoodie and board. inline bool reskate_developer(std::uint64_t id) noexcept { return identity_listed(id, IdentityList::developer); } -// The category a player's tag and items come from: a developer's before a content creator's -// before a homie's. Most players are in none. +// The category a player's tag and items come from: a developer's before Centrix's before a +// content creator's before a homie's. Most players are in none. inline std::optional identity_mark(std::uint64_t id) noexcept { - for (const auto list : {IdentityList::developer, IdentityList::content_creator, IdentityList::homie}) + for (const auto list : {IdentityList::developer, IdentityList::centrix, IdentityList::content_creator, IdentityList::homie}) if (identity_listed(id, list)) return list; return std::nullopt; } diff --git a/Extension/UI/Overlay/nametag_gradient.h b/Extension/UI/Overlay/nametag_gradient.h index dc6f6ca..386f124 100644 --- a/Extension/UI/Overlay/nametag_gradient.h +++ b/Extension/UI/Overlay/nametag_gradient.h @@ -15,6 +15,7 @@ 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}, + NametagGradient{multiplayer::nametag_centrix_start, multiplayer::nametag_centrix}, }; inline const NametagGradient* nametag_gradient(ImU32 colour) noexcept { for (const auto& gradient : nametag_gradients) diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index 6251b84..f648452 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -312,7 +312,7 @@ struct Nametag { std::array position{}; // above the skater's head, world space std::string name; std::uint32_t color{0xffffffffU}; // R, G, B, A bytes (IM_COL32) - std::string tag; // role badge before the name ("Dev", "Creator", "Homie", "Admin", "Host", "Friend") + std::string tag; // role badge before the name ("Dev", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend") float distance{}; // metres from the local skater bool talking{}; // Recent chat lines to show as bubbles above the head, oldest first ("" = none). diff --git a/Extension/UI/Overlay/role_badge.h b/Extension/UI/Overlay/role_badge.h index f9e8985..f92c34b 100644 --- a/Extension/UI/Overlay/role_badge.h +++ b/Extension/UI/Overlay/role_badge.h @@ -4,7 +4,7 @@ #include #include -// The role badge ("Dev", "Creator", "Homie", "Admin", "Host", "Friend") before a player's name, in chat +// The role badge ("Dev", "Creator", "Centrix", "Homie", "Admin", "Host", "Friend") before a player's name, in chat // and on nametags: a pill in the role's colour (animated for the roles in nametag_gradient.h) with dark text. namespace dingosdk::overlay::detail { inline float role_badge_width(ImFont *font, float size, const std::string &tag) { diff --git a/Server/README.txt b/Server/README.txt index ab01244..610b3cc 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -38,7 +38,7 @@ straight away (players are told to rejoin). Turn this off with ReSkateServer.json ------------------ -name Shown in the browser (1-64 characters). +name Shown in the browser: 1-64 letters, numbers, spaces and - _ [ ] ( ). map The map everyone skates, named like the game's load command: "San Vansterdam", "Isle of Grom", "Super Ultra Mega Resort", "Stadium 1", or a custom map such as "bbcity" (see Custom maps). diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index 80e525f..24633db 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -23,6 +23,7 @@ std::string text(const std::filesystem::path &file) { int run() { using namespace dingosdk::server; + using dingosdk::valid_server_name; const auto folder = std::filesystem::temp_directory_path() / "reskate_server_config_tests"; std::filesystem::remove_all(folder); std::filesystem::create_directories(folder); @@ -47,6 +48,15 @@ int run() { const auto reloaded = load_config(file); check(reloaded.name == "Old Server" && !reloaded.boosts && reloaded.votes.map.percent == 60, "Values lost on rewrite"); + // Server names: letters, digits, spaces and - _ [ ] ( ) only. + check(valid_server_name("Old Server") && valid_server_name("[EU] Skate_Park-2 (24x7)") && valid_server_name("a"), + "A plain server name was refused"); + check(!valid_server_name("") && !valid_server_name(std::string(65, 'a')) && !valid_server_name("Best! Server") && + !valid_server_name("caf\xC3\xA9") && !valid_server_name("a.b") && !valid_server_name("x") && + !valid_server_name(" padded") && !valid_server_name("padded ") && !valid_server_name("[]--()") && + !valid_server_name("two\nlines"), + "A server name with other characters was accepted"); + // An up-to-date file is left alone. const auto before = std::filesystem::last_write_time(file); added.clear(); diff --git a/Server/server_config.cpp b/Server/server_config.cpp index ab2f184..6c9e4cb 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -230,7 +230,7 @@ std::optional parse_scoring(std::string_view text) { } std::string config_error(const ServerConfig &c) { using namespace multiplayer; - if (c.name.empty() || c.name.size() > 64 || !valid_member_name(c.name)) return "name must be 1 to 64 characters."; + if (!valid_server_name(c.name)) return std::string("name must be ") + server_name_rule + "."; if (c.map.empty() || !valid_map_destination(map_destination(c.map))) return "map \"" + c.map + "\" is not a known map. Use a name like \"San Vansterdam\", or put the map's mod " "folder in Mods next to the server."; diff --git a/Server/server_host.cpp b/Server/server_host.cpp index ddf7202..a0ce3d2 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -1017,7 +1017,7 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { return changed(rotation_text()); } if (name == "name") { - if (argument.empty() || argument.size() > 64 || !valid_member_name(argument)) return "Server names are 1 to 64 characters."; + if (!valid_server_name(argument)) return std::string("Server names are ") + server_name_rule + "."; config_.name = argument; if (text::contains_bad_words(config_.name)) return changed("Server renamed to " + config_.name + diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 7c77a21..a7ee95d 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -60,6 +60,10 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) target_link_libraries(dingosdk_mod_merge_unshift_tests PRIVATE dingosdk_mods) target_include_directories(dingosdk_mod_merge_unshift_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME mod_merge_unshift COMMAND dingosdk_mod_merge_unshift_tests) + add_executable(dingosdk_chunk_metadata_merge_tests Engine/Vfs/Test/chunk_metadata_merge_tests.cpp) + target_link_libraries(dingosdk_chunk_metadata_merge_tests PRIVATE dingosdk_mods) + target_include_directories(dingosdk_chunk_metadata_merge_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME chunk_metadata_merge COMMAND dingosdk_chunk_metadata_merge_tests) add_executable(dingosdk_mod_merge_added_assets_tests Engine/Vfs/Test/mod_merge_added_assets_tests.cpp) 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}")