mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 16:17:06 +02:00
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
This commit is contained in:
1 parent
0d5dfda243
commit
2cdc5ee8df
24 files changed
+645
-29
No files matched your search
@@ -8,10 +8,25 @@
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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 <algorithm>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <optional>
|
||||
|
||||
namespace db = dingosdk::native_db;
|
||||
namespace fb = dingosdk::frostbite;
|
||||
using namespace dingosdk::mods::detail;
|
||||
|
||||
namespace {
|
||||
using Bytes = std::vector<std::byte>;
|
||||
|
||||
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<std::byte>(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<std::uint32_t> 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<unsigned char>(value >> (8 * byte)));
|
||||
return node;
|
||||
}
|
||||
Bytes list_of(const std::vector<Record>& 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<const std::byte*>(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<Record> read_list(const Bytes& list) {
|
||||
const std::span<const unsigned char> bytes(reinterpret_cast<const unsigned char*>(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<Record> 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<fb::Guid> shipped{guid(0x50), guid(0x30), guid(0x70), guid(0x10), guid(0x60)};
|
||||
const std::vector<Record> 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<Bytes> 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<ChunkRecord> 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<Record> 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<ChunkRecord> 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<ChunkRecord> 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<Record>{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<Bytes> withNewer{list_of(game), list_of(newer)};
|
||||
const std::vector<ChunkRecord> 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<Record>{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<ChunkRecord> 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<ChunkRecord> 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<Record>{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<Bytes> modsOnly{list_of({firstTexture, firstMesh}), list_of({firstTexture, firstMesh, secondTexture})};
|
||||
const std::vector<ChunkRecord> 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<ChunkRecord> both{chunk(0x90, 0, 0), chunk(0x20, 0, 1), chunk(0x40, 1, 2)};
|
||||
check(read_list(merge_chunk_metadata(modsOnly, both)) == std::vector<Record>{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<Bytes> 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<Bytes> none{Bytes{}, Bytes{}};
|
||||
const std::vector<ChunkRecord> 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<Bytes> 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;
|
||||
}
|
||||
@@ -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 <algorithm>
|
||||
#include <array>
|
||||
#include <bit>
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
@@ -225,6 +230,12 @@ struct Audit {
|
||||
std::map<Place, EbxFacts> facts;
|
||||
std::map<Place, fb::Guid> guids;
|
||||
std::vector<Contested> 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<std::pair<std::size_t, std::size_t>> chunkBundles;
|
||||
// Those of them the patch has exactly as one mod ships them, chunks and list: the merge wrote
|
||||
// nothing there.
|
||||
std::set<std::pair<std::size_t, std::size_t>> 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<std::uint64_t> owner;
|
||||
std::optional<std::int32_t> firstMip;
|
||||
};
|
||||
// A copy's chunk metadata, record by record; nothing when the list cannot be read.
|
||||
static std::optional<std::vector<ChunkMeta>> records_of(const Bundle& bundle) {
|
||||
std::vector<ChunkMeta> records;
|
||||
const auto& list = bundle.listing->manifest.chunkMetadata;
|
||||
if (list.empty()) return records;
|
||||
try {
|
||||
const std::span<const unsigned char> bytes(reinterpret_cast<const unsigned char*>(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<unsigned char>(std::tolower(static_cast<unsigned char>(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<ChunkMeta>& records) const {
|
||||
constexpr std::uint32_t textureType = 0x6BDE20BA;
|
||||
const auto& manifest = bundle.listing->manifest;
|
||||
std::vector<std::size_t> 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<fb::Guid, std::size_t> 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<std::int32_t>(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<std::size_t>& list) const {
|
||||
std::set<std::string> 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<int>(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';
|
||||
|
||||
@@ -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<Asset> assets;
|
||||
std::vector<fb::BundleFileInfo> files;
|
||||
std::vector<std::byte> 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<std::vector<std::byte>> 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<fb::Guid> shippedChunks;
|
||||
// Keyed position of each asset, so replacing one stays constant time: these
|
||||
// bundles hold tens of thousands of entries.
|
||||
std::map<std::string, std::size_t, std::less<>> seen;
|
||||
@@ -196,8 +205,115 @@ void build_manifest(const std::vector<Asset>& assets, std::span<const std::byte>
|
||||
emit(fb::AssetKind::chunk, manifest.chunks);
|
||||
}
|
||||
|
||||
std::span<const unsigned char> unsigned_bytes(std::span<const std::byte> data) {
|
||||
return {reinterpret_cast<const unsigned char*>(data.data()), data.size()};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::byte> merge_chunk_metadata(std::span<const std::vector<std::byte>> lists,
|
||||
std::span<const ChunkRecord> chunks, std::size_t game,
|
||||
std::span<const fb::Guid> shipped) {
|
||||
// A bundle with no chunks has nothing to describe; what the first copy has stays.
|
||||
if (chunks.empty()) return lists.empty() ? std::vector<std::byte>{} : 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<std::optional<native_db::Node>> 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<std::size_t> place(shipped.size()); // where each of the game's chunks has its record
|
||||
if (original) {
|
||||
std::vector<std::size_t> 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<native_db::Node> records;
|
||||
std::vector<bool> 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<native_db::Node> 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<std::size_t> 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<const std::byte*>(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<std::size_t>(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<std::byte> metadata;
|
||||
{
|
||||
std::vector<ChunkRecord> 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<fb::BundleFileInfo> 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 {
|
||||
|
||||
@@ -230,6 +230,40 @@ AssetOverrides collect_asset_overrides(const std::vector<const Mod*>& 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<std::size_t>(-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<std::byte> merge_chunk_metadata(std::span<const std::vector<std::byte>> lists,
|
||||
std::span<const ChunkRecord> chunks,
|
||||
std::size_t game = ChunkRecord::none,
|
||||
std::span<const fb::Guid> shipped = {});
|
||||
|
||||
fb::TocDocument combine(const fs::path& baseToc, const fs::path& baseRoot,
|
||||
std::vector<Source>& sources, MergeReport& report,
|
||||
const std::string& relative, ArchiveUse& used, CasStore& store,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -121,12 +121,14 @@ inline std::pair<std::array<std::uint8_t, 3>, std::array<std::uint8_t, 3>> 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<multiplayer::IdentityList> mark, const multiplayer::MarkStyle &style) noexcept {
|
||||
using multiplayer::IdentityList;
|
||||
@@ -147,7 +149,8 @@ inline ItemAnimation item_animation(std::optional<multiplayer::IdentityList> mar
|
||||
// next to it: a deep shade, a bright one, then a reddish pink or a yellow.
|
||||
constexpr std::array<Color, 3> red{{{.2f, .004f, .01f}, {.8f, .03f, .03f}, {.8f, .05f, .13f}}};
|
||||
constexpr std::array<Color, 3> gold{{{.34f, .12f, .006f}, {.8f, .5f, .06f}, {.8f, .64f, .11f}}};
|
||||
out.stops = *mark == IdentityList::content_creator ? red : gold;
|
||||
constexpr std::array<Color, 3> blue{{{.01f, .16f, .8f}, {.3f, .5f, .8f}, {.8f, .8f, .8f}}};
|
||||
out.stops = *mark == IdentityList::content_creator ? red : *mark == IdentityList::centrix ? blue : gold;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<std::uint32_t, std::string> 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<std::uint32_t, std::string> 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.
|
||||
|
||||
@@ -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<std::uint32_t, std::string> 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"};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MultiplayerLobby> 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();
|
||||
|
||||
@@ -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<std::size_t>(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<std::size_t>(L::centrix)] == std::vector{stranger} && centrix[static_cast<std::size_t>(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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<std::string_view, 3> categories{"dev", "homie", "content_creator"};
|
||||
constexpr std::array<std::string_view, 4> categories{"dev", "homie", "content_creator", "centrix"};
|
||||
constexpr auto banned = static_cast<std::size_t>(IdentityList::banned);
|
||||
|
||||
std::atomic<std::shared_ptr<const IdentityLists>> current;
|
||||
|
||||
@@ -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<std::vector<std::uint64_t>, static_cast<std::size_t>(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<IdentityList> 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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -312,7 +312,7 @@ struct Nametag {
|
||||
std::array<float, 3> 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).
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
|
||||
// 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) {
|
||||
|
||||
+1
-1
@@ -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).
|
||||
|
||||
@@ -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("<b>x</b>") &&
|
||||
!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();
|
||||
|
||||
@@ -230,7 +230,7 @@ std::optional<std::uint64_t> 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.";
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in new issue
Block a user