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}")