diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index ca91d534..643ce0df 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -205,6 +205,9 @@ struct MultiplayerModel { // Local: the tag the ReSkate backend gives this player ("Dev", "Staff", "Content 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. + // The local player is on the ReSkate team's developer, staff or homie list: the player list + // gives each player's Steam ID. + bool steam_ids_shown{}; std::string identity_tag; std::uint32_t identity_tag_colour{}; bool identity_tag_shown{true}, identity_items_shown{true}; diff --git a/Engine/Game/World/world_layers.h b/Engine/Game/World/world_layers.h index d7622055..88886f88 100644 --- a/Engine/Game/World/world_layers.h +++ b/Engine/Game/World/world_layers.h @@ -1,5 +1,6 @@ #pragma once #include "world_layer_catalog.h" +#include #include #include #include @@ -12,6 +13,38 @@ namespace dingosdk { +// Layers ReSkate keeps switched off and does not offer: the seasons' content on San Vansterdam +// (seasons 5 and 6) and the Isle of Grom (season 5). They keep their rows in the catalog, which +// players and servers share by position, so nothing about a session's layer list changes; the +// game just treats each as "off" whatever is saved or sent for it (local_world_layers.cpp), +// and no menu or console command names it. +inline constexpr std::array world_layers_kept_off{ + "bam_dtn_season_05_content", "bam_dtn_season_05_inactive", "bam_dtn_season_05_permacontent", + "bam_dev_seasonal_content_season_06", "bam_dtn_season_06_content", "bam_dtn_season_06_inactive", + "bam_dtn_season_06_permacontent", "grom_season_05_content"}; +inline bool world_layer_kept_off(std::string_view key) noexcept { + for (const auto kept : world_layers_kept_off) + if (kept == key) return true; + return false; +} +// A layer's name as menus show it: its label without the developers' own marks, the words +// DEV and DTN ("DTN Season 04 Content" is "Season 04 Content"). +inline std::string world_layer_title(std::string_view label) { + std::string title; + for (std::size_t at = 0; at < label.size();) { + const auto end = std::min(label.find(' ', at), label.size()); + const auto word = label.substr(at, end - at); + at = end + 1; + const bool mark = word.size() == 3 && (word[0] == 'D' || word[0] == 'd') && + (((word[1] == 'E' || word[1] == 'e') && (word[2] == 'V' || word[2] == 'v')) || + ((word[1] == 'T' || word[1] == 't') && (word[2] == 'N' || word[2] == 'n'))); + if (mark || word.empty()) continue; + if (!title.empty()) title += ' '; + title += word; + } + return title.empty() ? std::string(label) : title; +} + inline constexpr std::array world_layer_modes{"default", "on", "off"}; // One choice per catalog row (world_layers()), in catalog order. using WorldLayerChoices = std::vector; diff --git a/Engine/Resource/mesh_set.cpp b/Engine/Resource/mesh_set.cpp index 2669e7d0..3e1fb9db 100644 --- a/Engine/Resource/mesh_set.cpp +++ b/Engine/Resource/mesh_set.cpp @@ -68,6 +68,69 @@ MeshLod read_mesh_lod(std::span resource, std::size_t lod) { return result; } +std::size_t mesh_lod_count(std::span resource) { + const Reader r{resource}; + for (std::size_t i = 0; i < known_header.size(); ++i) + require(r.at(i * 4) == known_header[i], "its layout is not the one this reader knows"); + return r.at(lod_count); +} + +MeshLod mesh_lod_geometry(std::span resource, std::size_t lod) { + require(lod < mesh_lod_count(resource), "it has no such level of detail"); + const Reader r{resource}; + const auto at = r.pointer(lod_table + lod * 8); + require(r.at(at + lod_kind) == known_lod_kind, "the level of detail is of another kind"); + MeshLod result; + result.chunk = r.at(at + geometry_chunk); + result.vertex_bytes = r.at(at + vertex_bytes); + result.index_bytes = r.at(at + index_bytes); + require(result.chunk != Guid{} && result.vertex_bytes > 0, "the level of detail is empty"); + return result; +} + +std::vector> read_mesh_positions(std::span resource, std::size_t lod, + std::span geometry) { + const auto layout = mesh_lod_geometry(resource, lod); + const Reader r{resource}, g{geometry}; + require(geometry.size() >= layout.vertex_bytes, "the geometry chunk is too short"); + const auto at = r.pointer(lod_table + lod * 8); + const auto sections = r.at(at + section_count); + require(sections <= 4096, "it has too many sections"); + const auto table = r.pointer(at + section_table); + std::vector> result; + for (std::uint32_t section = 0; section < sections; ++section) { + const auto s = table + section * section_size; + const std::size_t count = r.at(s + vertex_count); + if (!count) continue; + const auto d = s + declaration; + const auto elements = r.at(d + element_total), streams = r.at(d + stream_total); + require(elements <= 16 && streams > 0 && streams <= 16, "a vertex declaration is malformed"); + // Each stream's vertices follow the one before, from where the section starts. + std::array bases{}, strides{}; + std::size_t end = r.at(s + vertex_start); + for (std::size_t stream = 0; stream < streams; ++stream) { + strides[stream] = r.at(d + stream_table + stream * 2); + require(strides[stream] > 0, "a vertex stream has no size"); + bases[stream] = end; + end += strides[stream] * count; + } + require(end <= layout.vertex_bytes, "a section's vertices run past the vertex buffer"); + bool found{}; + for (std::size_t element = 0; element < elements && !found; ++element) { + if (r.at(d + element * 4) != position) continue; + const auto stream = r.at(d + element * 4 + 3); + const std::size_t offset = r.at(d + element * 4 + 2); + require(r.at(d + element * 4 + 1) == usage_format[position] && stream < streams && + offset + usage_size[position] <= strides[stream], "its positions have another format"); + for (std::size_t vertex = 0; vertex < count; ++vertex) + result.push_back(g.at>(bases[stream] + vertex * strides[stream] + offset)); + found = true; + } + require(found, "a section has no positions"); + } + return result; +} + SkinnedMesh read_skinned_mesh(std::span resource, std::size_t lod, std::span geometry) { const auto layout = read_mesh_lod(resource, lod); const Reader r{resource}, g{geometry}; diff --git a/Engine/Resource/mesh_set.h b/Engine/Resource/mesh_set.h index f3ec2d89..4cc2eac0 100644 --- a/Engine/Resource/mesh_set.h +++ b/Engine/Resource/mesh_set.h @@ -18,6 +18,16 @@ struct MeshLod { // reader does not know. [[nodiscard]] MeshLod read_mesh_lod(std::span resource, std::size_t lod); +// What a geometry comparison needs, of any MeshSet of the known layout, skinned or not and +// whatever its index size: how many levels of detail it has, a level's geometry chunk, and +// where that level's vertices are. Nothing of the skinning is read, so a mesh the skinned +// reader refuses for its bones still gives its positions. Throws for another layout, and for +// positions that are not three floats. +[[nodiscard]] std::size_t mesh_lod_count(std::span resource); +[[nodiscard]] MeshLod mesh_lod_geometry(std::span resource, std::size_t lod); +[[nodiscard]] std::vector> read_mesh_positions(std::span resource, std::size_t lod, + std::span geometry); + struct SkinnedVertex { std::array position{}; // model space std::array bones{}; // skeleton bones diff --git a/Engine/Vfs/Test/store_copies_tests.cpp b/Engine/Vfs/Test/store_copies_tests.cpp index 47b544d0..72c79a33 100644 --- a/Engine/Vfs/Test/store_copies_tests.cpp +++ b/Engine/Vfs/Test/store_copies_tests.cpp @@ -73,10 +73,70 @@ void rules() { check(copies({"asset 3 mod_ap"}, {"asset 1 coat_dmpreset"}).empty(), "a mod's own look on a store item's shape is the mod's"); check(copies({}, {"asset 1 coat_dmpreset"}).empty(), "the shape of a store item that has a look is not that item"); check(copies({}, {"asset 1 costume_dmpreset"}) == "Own_Store_Costume", "a store costume that is only a shape is told by it"); - check(copies({"asset 3 mod_ap"}, {"asset 1 costume_dmpreset"}).empty(), "unless the mod gives it a look of its own"); + check(copies({"asset 3 mod_ap"}, {"asset 1 costume_dmpreset"}) == "Own_Store_Costume", "and a look of the mod's own over it is still that costume"); check(copies({}, {"asset 1 hoodie_dmpreset"}).empty(), "a shape free items use is free"); + { + // A whole costume the store sells with looks of its own (colourways): its shape is still it. + auto suit = made_of("Own_Store_Suit", {"asset 20 store_suit_ap"}, {"asset 1 suit_dmpreset"}, "s"); + suit.costume = true; + game.add(suit, true); + check(copies({"asset 20 mod_ap"}, {"asset 1 suit_dmpreset"}) == "Own_Store_Suit", "a mod's look on a store costume's mesh is that costume"); + check(copies({"asset 20 mod_ap", "asset 21 mod_two_ap"}, {"asset 1 suit_dmpreset"}) == "Own_Store_Suit", "however many looks it brings"); + auto free_suit = made_of("Own_Free_Suit", {"asset 20 free_suit_ap"}, {"asset 1 free_suit_dmpreset"}, "t"); + free_suit.costume = true; + game.add(free_suit, false); + check(copies({"asset 20 mod_ap"}, {"asset 1 free_suit_dmpreset"}).empty(), "on a free costume's it is the mod's"); + } check(copies({}, {}, "e") == "Own_Store_Voice", "a store item's data, field for field, is that store item"); check(copies({}, {}, "f").empty(), "data a free item has as well is free"); + + // Costumes by their geometry. A mesh is rows of points here: `count` of them from `first`. + const auto points = [](float first, std::size_t count) { + std::vector> positions; + for (std::size_t i = 0; i < count; ++i) positions.push_back({first + static_cast(i) * 0.01f, 1.0f, -0.25f}); + return mesh_points(positions); + }; + // A mesh of the mod's own making: nowhere near any of the game's. + const auto own = [](std::size_t count) { + std::vector> positions; + for (std::size_t i = 0; i < count; ++i) positions.push_back({static_cast(i) * 0.01f, 7.0f, 3.5f}); + return mesh_points(positions); + }; + const auto joined = [](std::vector a, const std::vector& b) { + a.insert(a.end(), b.begin(), b.end()); + std::sort(a.begin(), a.end()); + a.erase(std::unique(a.begin(), a.end()), a.end()); + return a; + }; + check(mesh_point({1.0f, 2.0f, 3.0f}) == mesh_point({1.0001f, 2.0001f, 2.9999f}) && + mesh_point({1.0f, 2.0f, 3.0f}) != mesh_point({1.001f, 2.0f, 3.0f}), "a vertex is told to half a millimetre"); + check(mesh_points({{1, 2, 3}, {1, 2, 3}, {0, 0, 0}}).size() == 2, "a place is counted once"); + StoreMeshes meshes; + // Three store costumes and a free one; the first two share a body (2000 points) under them. + const auto body = points(0, 2000), astronaut = points(100, 10000), reaper = points(300, 8000); + meshes.add("Own_Store_Astronaut", true, joined(astronaut, body)); + meshes.add("Own_Store_Reaper", true, joined(reaper, body)); + meshes.add("Own_Store_Astronaut", true, points(100, 4000)); // a coarser level of detail: the same costume + meshes.add("Own_Free_Suit", false, points(-200, 9000)); + meshes.add("Own_Store_Robe", true, joined(points(-400, 6000), points(-200, 3000))); // half of it a free costume's + check(meshes.original(joined(astronaut, body)) == "Own_Store_Astronaut", "a store costume's mesh is that costume"); + check(meshes.original(astronaut) == "Own_Store_Astronaut", "under any name, and without the body"); + check(meshes.original(points(100, 1200)) == "Own_Store_Astronaut", "a part of it is that costume too"); + check(meshes.original(joined(points(100, 1200), own(30000))) == "Own_Store_Astronaut", + "and stays it inside a much larger mesh of the mod's own"); + check(meshes.original(joined(points(100, 700), own(1000))) == "Own_Store_Astronaut", + "a small part is, when it is much of the mod's mesh"); + check(meshes.original(points(100, 1200)) == "Own_Store_Astronaut" && meshes.original(joined(points(100, 1300), own(30000))) == "Own_Store_Astronaut", + "a tenth of the costume's finest level is enough, whatever its coarser levels add"); + check(meshes.original(joined(points(100, 700), own(30000))).empty(), "a small part of a costume in a large mesh is not"); + check(meshes.original(points(100, 200)).empty() && meshes.original(points(100, 299)).empty(), "nor are a few vertices"); + check(meshes.original(body).empty(), "the body two costumes share is nobody's"); + check(meshes.original(joined(body, own(5000))).empty(), "so a mod's own costume on it is the mod's"); + check(meshes.original(points(-200, 9000)).empty() && meshes.original(points(-200, 3000)).empty(), "a free costume's mesh is free"); + check(meshes.original(points(-400, 6000)) == "Own_Store_Robe", "what only the store costume has of it is still its own"); + check(meshes.original(own(20000)).empty(), "a mesh of the mod's own is the mod's"); + check(meshes.original(joined(points(100, 1500), points(300, 3000))) == "Own_Store_Reaper", "of two it takes from, the one it takes most of"); + check(StoreMeshes{}.original(astronaut).empty(), "nothing is taken when no costume was read"); check(copies({}, {}, "").empty(), "data with nothing in it says nothing"); check(copies({}, {}).empty(), "an item of the mod's own is no copy"); } @@ -327,7 +387,8 @@ void installed_mods(const fs::path& game, const fs::path& folder, const StoreIte const auto found = check_store_copies(catalog, sold, ¬es); const auto ms = std::chrono::duration_cast(std::chrono::steady_clock::now() - start).count(); std::cout << folder.string() << ": " << catalog.mods.size() << " mod(s) checked in " << ms << " ms against " - << found.game_items << " of the game's items, " << found.items.size() << " copy(ies) of store items in " + << found.game_items << " of the game's items, " << found.mod_meshes << " mesh level(s) against " + << found.game_costumes << " of the game's costumes, " << found.items.size() << " copy(ies) of store items in " << found.mods.size() << " of them\n"; for (const auto& source : found.mods) std::cout << " " << source.mod << ": " << source.count << ", e.g. " << source.example << " is " << source.original << '\n'; diff --git a/Engine/Vfs/mod_store_copies.cpp b/Engine/Vfs/mod_store_copies.cpp index 5c566a83..e13e4682 100644 --- a/Engine/Vfs/mod_store_copies.cpp +++ b/Engine/Vfs/mod_store_copies.cpp @@ -2,9 +2,12 @@ #include "mod_catalog.h" #include "mod_merge_internal.h" +#include "Engine/Resource/cas_codec.h" #include "Engine/Resource/ebx_document.h" +#include "Engine/Resource/mesh_set.h" #include +#include #include namespace dingosdk::mods { @@ -21,6 +24,17 @@ bool item_name(std::string_view name) { name.substr(slash == std::string_view::npos ? 0 : slash + 1).starts_with("own_"); } +// The game's whole costumes, the ones a skater wears instead of clothes: where their items +// are, and how a costume's own bundle is named after the preset its item is fitted on (an +// "asset 1" shape). The bundle holds the costume's mesh, a MeshSet resource; its coarser levels +// of detail are chunks of the bundle and the finest is one of the superbundle's own. +constexpr std::string_view costume_items = "items/cust_fullbodycostume/"; +constexpr std::string_view costume_shape = "asset 1 "; +constexpr std::string_view costume_bundle_suffix = "_cas_main_bundlereftable"; +constexpr std::uint32_t mesh_set_type = 0x49b156d4; +// The levels of detail of a mod's mesh that are read: a copy has the finest ones. +constexpr std::size_t mod_mesh_lods = 3; + // An item's AssetPaths say what kind of asset each one is: 1 a morph preset and // 2 a base mesh, which are what the item is fitted on; every other kind (3, an // appearance preset, for nearly all of them) is what it looks like. Of the @@ -175,6 +189,86 @@ public: layout_(vfs::read_layout(baseRoot_ / L"layout.toc")), store_(baseRoot_, catalog.root / generated_folder, layout_.root), notes_(notes) {} + // The meshes a mod ships, each level of detail read as a mesh of its own: every MeshSet in + // a bundle of its own files, whatever it is called and whether an item names it. + struct Mesh { + std::string name; + std::vector points; + }; + [[nodiscard]] std::vector read_mod_meshes(const Mod& mod) { + std::vector meshes; + for (const auto& relative : scan(mod.directory).tocs) { + fb::TocDocument own; + try { + own = fb::read_toc(read_file(mod.directory / fs::path(relative))); + } catch (const std::exception&) { + continue; // (read_mod notes it) + } + for (const auto& bundle : own.bundles) { + try { + const auto region = fb::read_bundle_region(bundle.region); + if (std::none_of(region.files.begin(), region.files.end(), + [](const fb::BundleFileInfo& file) { return file.location.patch; })) + continue; + const auto listing = list_bundle(store_, mod.directory, baseRoot_, bundle, gameRoot_); + if (!listing) continue; + const auto& manifest = listing->manifest; + for (std::size_t index = 0; index < manifest.resources.size(); ++index) { + const auto at = listing->first + manifest.ebx.size() + index; + if (manifest.resources[index].resourceType != mesh_set_type || at >= listing->files.size() || + !listing->files[at].location.patch) + continue; + read_mesh(mod.directory, *listing, manifest.ebx.size() + index, own, mod_mesh_lods, + [&](std::vector points) { + meshes.push_back({manifest.resources[index].name, std::move(points)}); + }); + } + } catch (const std::exception& failure) { + note(mod.name + ": " + bundle.name + " could not be read for " + std::string(store_copies_check) + " (" + failure.what() + ")"); + } + } + } + return meshes; + } + + // The meshes of the game's whole costumes (read_game found their items), and every other + // mesh its items have, which are nobody's: a costume is partly made of clothes anyone has. + // How many costumes were read. + [[nodiscard]] std::size_t read_costumes(StoreMeshes& meshes) { + const auto* toc = game_toc(items_toc); + if (!toc || costumes_.empty()) return 0; + const auto& document = tocs_.at(lower(items_toc)).document; + std::size_t read{}; + for (const auto& [name, bundle] : *toc) { + const Costume* costume{}; + if (name.ends_with(costume_bundle_suffix)) { + const auto stem = name.substr(0, name.size() - costume_bundle_suffix.size()); + const auto slash = stem.rfind('/'); + const auto found = costumes_.find(stem.substr(slash == std::string::npos ? 0 : slash + 1)); + if (found != costumes_.end()) costume = &found->second; + } + try { + const auto listing = list_bundle(store_, baseRoot_, baseRoot_, *bundle, gameRoot_); + if (!listing) continue; + const auto& manifest = listing->manifest; + bool any{}; + for (std::size_t index = 0; index < manifest.resources.size(); ++index) { + if (manifest.resources[index].resourceType != mesh_set_type) continue; + read_mesh(baseRoot_, *listing, manifest.ebx.size() + index, document, 16, [&](std::vector points) { + // (Each other mesh under its own name, so two of them sharing a point is also seen.) + if (costume) meshes.add(costume->key, costume->sold, points); + else meshes.add("mesh " + manifest.resources[index].name, false, points); + any = true; + }); + } + read += any && costume; + } catch (const std::exception& failure) { + note("The game's " + bundle->name + " could not be read for " + std::string(store_copies_check) + " (" + failure.what() + ")"); + } + } + return read; + } + // The items a mod adds to the game's bundles, and the game's own it changes. // An item only loads from a bundle the game loads, and the lists the game // reads its items from are in one of those, so a mod's own bundles (a map's @@ -242,8 +336,13 @@ public: read_asset(store_, baseRoot_, baseRoot_, *listing, index, gameRoot_)); const auto item = item_content(document); if (!item) continue; - items.add(*item, sold(lower(item->key))); + const bool store = sold(lower(item->key)); + items.add(*item, store); ++read; + if (lower(listing->manifest.ebx[index].name).starts_with(costume_items)) + for (const auto& shape : item->shapes) + if (shape.starts_with(costume_shape)) + costumes_.insert_or_assign(shape.substr(costume_shape.size()), Costume{item->key, store}); } catch (const std::exception&) {} } } catch (const std::exception& failure) { @@ -289,6 +388,39 @@ private: return &*entry; } + // One MeshSet (the listing's `file`th file) as points, a level of detail at a time, up to + // `lods` of them. A level's geometry is a chunk of the bundle or of its superbundle `toc`. A + // mesh or a level this cannot read is left out: most are not laid out as a costume's is. + template + void read_mesh(const fs::path& root, const Listing& listing, std::size_t file, const fb::TocDocument& toc, std::size_t lods, + Each&& each) { + std::vector resource; + std::size_t count{}; + try { + resource = read_asset(store_, root, baseRoot_, listing, file, gameRoot_); + count = std::min(fb::mesh_lod_count(resource), lods); + } catch (const std::exception&) { + return; + } + const auto& manifest = listing.manifest; + for (std::size_t lod = 0; lod < count; ++lod) { + try { + const auto wanted = fb::mesh_lod_geometry(resource, lod).chunk; + std::vector geometry; + for (std::size_t chunk = 0; chunk < manifest.chunks.size() && geometry.empty(); ++chunk) + if (manifest.chunks[chunk].guid == wanted) + geometry = read_asset(store_, root, baseRoot_, listing, manifest.ebx.size() + manifest.resources.size() + chunk, gameRoot_); + for (auto chunk = toc.chunks.begin(); chunk != toc.chunks.end() && geometry.empty(); ++chunk) + if (chunk->guid == wanted && !chunk->removed) + geometry = fb::decode_cas(store_.read(chunk->location.patch ? root : baseRoot_, chunk->location, + chunk->offset, chunk->size), {gameRoot_}); + if (geometry.empty()) continue; + auto points = mesh_points(fb::read_mesh_positions(resource, lod, geometry)); + if (!points.empty()) each(std::move(points)); + } catch (const std::exception&) {} + } + } + void note(std::string text) { if (notes_) notes_->push_back(std::move(text)); } @@ -303,6 +435,12 @@ private: std::vector* notes_; std::map> tocs_; std::map, std::less<>> assets_; + // The game's whole costumes: the preset each is fitted on -> its item. + struct Costume { + std::string key; + bool sold{}; + }; + std::map> costumes_; }; } // namespace @@ -314,6 +452,7 @@ std::optional item_content(const fb::ebx::Document& document) { if (!key || key->empty() || !data) return std::nullopt; ItemContent item; item.key = *key; + if (const auto* name = text_of(*root->object, "Name")) item.costume = lower(*name).starts_with(costume_items); std::string text; ItemReader reader(document, item, text); reader.value(data->value); @@ -338,8 +477,9 @@ void StoreItems::add(const ItemContent& item, bool sold) { else parts.free.insert(part); }; for (const auto& look : item.looks) file(looks_, look); - // A store item with a look is told by that; its shape is anyone's to use. - if (!sold || item.looks.empty()) + // A store item with a look is told by that; its shape is anyone's to use. A whole + // costume's is not: the shape is the costume's own mesh, with or without looks on it. + if (!sold || item.looks.empty() || item.costume) for (const auto& shape : item.shapes) file(shapes_, shape); if (!item.data.empty()) file(data_, item.data); } @@ -347,24 +487,81 @@ void StoreItems::add(const ItemContent& item, bool sold) { std::string StoreItems::original(const ItemContent& item) const { for (const auto& look : item.looks) if (const auto* key = looks_.owner(look)) return *key; - if (item.looks.empty()) - for (const auto& shape : item.shapes) - if (const auto* key = shapes_.owner(shape)) return *key; + // (Only a store item with no look files its shapes: a whole costume. A look of the + // mod's own over one does not make the costume the mod's.) + for (const auto& shape : item.shapes) + if (const auto* key = shapes_.owner(shape)) return *key; if (!item.data.empty()) if (const auto* key = data_.owner(item.data)) return *key; return {}; } +std::uint64_t mesh_point(const std::array& position) noexcept { + // Half a millimetre a step, 21 bits an axis: a kilometre either way, far past any skater. + std::uint64_t point{}; + for (const float coordinate : position) { + const auto step = std::isfinite(coordinate) ? std::llround(std::clamp(coordinate, -500.0f, 500.0f) * 2000.0f) : 0; + point = point << 21 | (static_cast(step + (1 << 20)) & 0x1fffff); + } + return point; +} + +std::vector mesh_points(const std::vector>& positions) { + std::vector points; + points.reserve(positions.size()); + for (const auto& position : positions) points.push_back(mesh_point(position)); + std::sort(points.begin(), points.end()); + points.erase(std::unique(points.begin(), points.end()), points.end()); + return points; +} + +void StoreMeshes::add(std::string_view key, bool sold, const std::vector& points) { + auto found = by_key_.find(key); + if (found == by_key_.end()) { + found = by_key_.emplace(std::string(key), static_cast(owners_.size())).first; + owners_.push_back({std::string(key), sold, 0}); + } + const auto owner = found->second; + owners_[owner].largest = std::max(owners_[owner].largest, points.size()); + for (const auto point : points) { + const auto [place, fresh] = points_.try_emplace(point, owner); + // A second mesh has it too: it is neither's. + if (!fresh && place->second != owner) place->second = nobody; + } +} + +std::string StoreMeshes::original(const std::vector& points) const { + if (points.size() < shared_least) return {}; + std::vector shared(owners_.size()); + for (const auto point : points) + if (const auto found = points_.find(point); found != points_.end() && found->second != nobody) ++shared[found->second]; + const std::string* most{}; + std::size_t count{}; + for (std::size_t owner = 0; owner < owners_.size(); ++owner) { + const auto& costume = owners_[owner]; + if (!costume.sold || shared[owner] < shared_least || shared[owner] <= count) continue; + if (shared[owner] * 10 >= costume.largest || shared[owner] * 10 >= points.size() * 3) most = &costume.key, count = shared[owner]; + } + return most ? *most : std::string(); +} + StoreCopies check_store_copies(const Catalog& catalog, const StoreItem& sold, std::vector* notes) noexcept { StoreCopies result; try { if (!sold || std::none_of(catalog.mods.begin(), catalog.mods.end(), [](const Mod& mod) { return mod.provides_layout; })) return result; Scan scan(catalog, notes); - std::vector>> added; + struct Added { + const Mod* mod{}; + std::vector items; + std::vector meshes; + }; + std::vector added; for (const auto& mod : catalog.mods) { if (!mod.provides_layout) continue; - if (auto items = scan.read_mod(mod); !items.empty()) added.emplace_back(&mod, std::move(items)); + Added entry{&mod, scan.read_mod(mod), scan.read_mod_meshes(mod)}; + result.mod_meshes += entry.meshes.size(); + if (!entry.items.empty() || !entry.meshes.empty()) added.push_back(std::move(entry)); } if (added.empty()) return result; StoreItems game; @@ -373,8 +570,20 @@ StoreCopies check_store_copies(const Catalog& catalog, const StoreItem& sold, st if (notes) notes->push_back(std::string(store_copies_check) + " could not read the game's own files and did not run"); return result; } - for (const auto& [mod, items] : added) { + // The costumes' meshes are only read when a mod ships a mesh at all. + StoreMeshes costumes; + if (result.mod_meshes) result.game_costumes = scan.read_costumes(costumes); + for (const auto& [mod, items, meshes] : added) { StoreCopies::Source source{mod->name}; + for (const auto& mesh : meshes) { + const auto original = costumes.original(mesh.points); + if (original.empty()) continue; + if (!source.count++) { + source.example = mesh.name; + source.original = original; + } + result.items.insert_or_assign(lower(mesh.name), lower(original)); + } for (const auto& item : items) { const auto key = lower(item.key); // A store item under its own key is held by that key already. diff --git a/Engine/Vfs/mod_store_copies.h b/Engine/Vfs/mod_store_copies.h index c1952ee5..59235ffa 100644 --- a/Engine/Vfs/mod_store_copies.h +++ b/Engine/Vfs/mod_store_copies.h @@ -1,6 +1,8 @@ #pragma once +#include #include +#include #include #include #include @@ -46,6 +48,9 @@ struct ItemContent { // nothing. std::vector shapes; std::string data; // a digest of every field of its item data, to compare whole + // A whole costume, worn instead of clothes (its asset is one of the game's full-body + // costumes). Its shape is the costume, whatever looks it also names. + bool costume{}; }; // Empty when the document is not an item (an asset with a Key and ItemData). [[nodiscard]] std::optional item_content(const frostbite::ebx::Document& document); @@ -56,9 +61,10 @@ public: // `sold`: the store sells it, so ReSkate never hands it out. void add(const ItemContent& item, bool sold); // The key of the store item `item` is a copy of, or empty. It is one when - // it has a look only a store item has, when it has no look of its own and - // is fitted on a shape only a store item with no look has (a whole - // costume), or when its item data is a store item's field for field. + // it has a look only a store item has, when it is fitted on a shape only + // a store item with no look has (a whole costume, with or without a look + // of the mod's own over it), or when its item data is a store item's + // field for field. // Anything an item the store does not sell has as well is nobody's. [[nodiscard]] std::string original(const ItemContent& item) const; @@ -71,6 +77,40 @@ private: Parts looks_, shapes_, data_; }; +// A costume is its mesh more than anything it names, and a mesh can be shipped again under any +// name, whole or in part, in an item or beside one. So the meshes a mod ships are also held +// against the geometry of the costumes the store sells: where their vertices are. A vertex is +// told by its place to half a millimetre. +[[nodiscard]] std::uint64_t mesh_point(const std::array& position) noexcept; +// One level of detail of a mesh, as the places of its vertices: sorted, each once. +[[nodiscard]] std::vector mesh_points(const std::vector>& positions); + +class StoreMeshes final { +public: + // One level of detail of one of the game's meshes. `key`: the costume's item; any other + // mesh of the game's (clothes, a free costume) is added as not sold, under any key. + void add(std::string_view key, bool sold, const std::vector& points); + [[nodiscard]] bool empty() const noexcept { return owners_.empty(); } + // The key of the store costume a mesh with these points takes from, or empty. It does when + // enough of them (shared_least) are points only that one costume has, and they are a tenth + // of that costume's finest level or three tenths of the mesh. A point two of the game's + // meshes have is nobody's: the body under two costumes, the socks one wears that are also + // an item anyone has. + [[nodiscard]] std::string original(const std::vector& points) const; + static constexpr std::size_t shared_least = 300; + +private: + static constexpr std::uint32_t nobody = 0xffffffffU; + struct Owner { + std::string key; + bool sold{}; + std::size_t largest{}; // its finest level of detail, in points + }; + std::vector owners_; + std::map> by_key_; + std::map points_; // -> its one owner, or nobody +}; + struct StoreCopies { // Lower-case key of each copy -> the lower-case key of the store item it copies. std::map> items; @@ -83,6 +123,8 @@ struct StoreCopies { std::vector mods; // How many of the game's own items the mods' were held against; none when no mod adds an item. std::size_t game_items{}; + // And how many of the mods' meshes against how many of the game's costumes. + std::size_t mod_meshes{}, game_costumes{}; }; // Whether the store sells the item with this lower-case key. diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index bca1db51..42a915cb 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -178,6 +178,10 @@ void publish(Session &s, const NativeFrame *local) { view.saved_host = {true, s.host_preferences.public_lobby, s.host_preferences.password_required, static_cast(s.host_preferences.capacity), s.host_preferences.tps, s.host_preferences.lobby_name}; view.nametags = s.nametags; + if (const auto social = steam_social_snapshot()) + view.steam_ids_shown = identity_listed(social->local.id, IdentityList::developer) || + identity_listed(social->local.id, IdentityList::staff) || + identity_listed(social->local.id, IdentityList::homie); if (const auto social = steam_social_snapshot()) if (const auto mark = identity_mark(social->local.id)) { std::tie(view.identity_tag_colour, view.identity_tag) = mark_role(*mark); diff --git a/Extension/Profile/local_profile_world.cpp b/Extension/Profile/local_profile_world.cpp index 4d24bea1..0d44d100 100644 --- a/Extension/Profile/local_profile_world.cpp +++ b/Extension/Profile/local_profile_world.cpp @@ -150,7 +150,7 @@ bool set_local_world_layer(std::string_view layer, std::string_view mode) { !park_runtime().active.load(std::memory_order_acquire)) return false; try { const auto it = std::find_if(world_layers().begin(), world_layers().end(), [&](const auto& row) { return row.key == layer; }); - if (it == world_layers().end() || !valid_world_layer_mode(mode)) { + if (it == world_layers().end() || !valid_world_layer_mode(mode) || world_layer_kept_off(it->key)) { r.model.feedback = "Choose a valid world layer and mode."; return false; } const auto index = static_cast(it - world_layers().begin()); diff --git a/Extension/Scripting/custom_script_loader.cpp b/Extension/Scripting/custom_script_loader.cpp index 1785ca8c..2de69da8 100644 --- a/Extension/Scripting/custom_script_loader.cpp +++ b/Extension/Scripting/custom_script_loader.cpp @@ -11,10 +11,44 @@ namespace dingosdk { namespace { +// Custom scripts are switched off for now: nothing uses them, and the folder was a way to get +// code of one's own into the game. Nothing in scripts/Custom is read or run. Everything below +// is kept as it was for the day they come back; set this to true. +constexpr bool custom_scripts_enabled = false; std::filesystem::path game_root; std::atomic ran{}; +// Custom scripts are Lua and nothing else. Lua's own ways of running native code are taken +// away before the first one runs: loading a DLL (package.loadlib, and require through the two +// C searchers and package.cpath) and starting a program (os.execute, io.popen). The game's own +// scripts use none of them. The last line says whether it held, for the check below. +constexpr std::string_view lua_only = R"lua( +local p = package +if type(p) == "table" then + p.loadlib = nil + p.cpath = "" + for _, name in ipairs({"searchers", "loaders"}) do + local list = p[name] + if type(list) == "table" then + for index = #list, 3, -1 do list[index] = nil end + end + end +end +if type(os) == "table" then os.execute = nil end +if type(io) == "table" then io.popen = nil end +if (type(p) == "table" and (p.loadlib ~= nil or p.cpath ~= "")) or (type(os) == "table" and os.execute ~= nil) + or (type(io) == "table" and io.popen ~= nil) then + error("native code loading could not be switched off") +end +)lua"; bool load_scripts(const lua_startup::Context& context) { if (ran.exchange(true)) return false; + // Without it no custom script runs at all. + if (!context.execute(lua_only)) { + logging::write(logging::Level::error, logging::Channel::assets, + "Custom scripts were not loaded: Lua's native code loading could not be switched off."); + custom_scripts::report("", "Custom scripts were not loaded"); + return false; + } std::vector scripts; try { scripts = custom_scripts::discover(game_root); @@ -60,6 +94,11 @@ bool load_scripts(const lua_startup::Context& context) { } bool start_custom_script_loader(std::uintptr_t base, std::string& error) { error.clear(); + if (!custom_scripts_enabled) { + custom_scripts::report("", "Custom scripts are switched off in this version of ReSkate"); + logging::write(logging::Level::info, logging::Channel::assets, "Custom scripts are switched off; scripts/Custom is not read."); + return true; + } try { std::array path{}; const auto length = GetModuleFileNameW(nullptr, path.data(), static_cast(path.size())); diff --git a/Extension/UI/Overlay/multiplayer_session.cpp b/Extension/UI/Overlay/multiplayer_session.cpp index 62c18bc2..6981da0b 100644 --- a/Extension/UI/Overlay/multiplayer_session.cpp +++ b/Extension/UI/Overlay/multiplayer_session.cpp @@ -544,6 +544,19 @@ void session_page(SkateMenu &menu, const Model &model) { } ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::grey_text); ImGui::TextUnformatted(detail.c_str()); + // The ReSkate team's lists see who is who: the Steam ID, copied by a click. + if (const auto id = peer ? peer->id : mp.local_id; mp.steam_ids_shown && id) { + const auto text = std::to_string(id); + ImGui::SameLine(0, 0); + ImGui::TextUnformatted(" - "); + ImGui::SameLine(0, 0); + ImGui::TextUnformatted(text.c_str()); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::SetTooltip("Steam ID. Click to copy."); + } + if (ImGui::IsItemClicked()) ImGui::SetClipboardText(text.c_str()); + } ImGui::PopStyleColor(); ImGui::EndGroup(); ImGui::PopClipRect(); diff --git a/Extension/UI/Overlay/skate_menu.cpp b/Extension/UI/Overlay/skate_menu.cpp index 0c70800e..c03b0f45 100644 --- a/Extension/UI/Overlay/skate_menu.cpp +++ b/Extension/UI/Overlay/skate_menu.cpp @@ -430,7 +430,9 @@ void draw_skate_menu(SkateMenu& menu, const Model& model, const CallbacksV3& cal case multiplayer: multiplayer_page(menu, model, callbacks); break; case progress: progression_page(menu, model, callbacks); break; case mods: - category_tabs(menu, menu.mods_tab, {"INSTALLED", "SCRIPTS"}, "mods-tabs"); + // The SCRIPTS tab (custom Lua scripts, modding_menu.cpp) is not offered for now: + // nothing uses them. The page is the installed mods. + menu.mods_tab = 0; ImGui::PushID(menu.mods_tab); ImGui::BeginChild("mods-tab", ImVec2(0, page_body_height(menu))); draw_modding_menu(menu, menu.mods_tab); diff --git a/Extension/UI/Overlay/skate_menu_world.cpp b/Extension/UI/Overlay/skate_menu_world.cpp index c238371a..c2db9504 100644 --- a/Extension/UI/Overlay/skate_menu_world.cpp +++ b/Extension/UI/Overlay/skate_menu_world.cpp @@ -405,15 +405,16 @@ void world_layers_page(SkateMenu& menu, const Model& model, const CallbacksV3& c unsigned visible_layers{}; for (unsigned i = 0; i < world_layers().size(); ++i) { const auto& layer = world_layers()[i]; - if (layer.map != model.world.map || !model.world.supported[i]) continue; + if (layer.map != model.world.map || !model.world.supported[i] || world_layer_kept_off(layer.key)) continue; if (menu.world_layer_category && layer.category != categories[menu.world_layer_category]) continue; - if (!matches_search(layer.label) && !matches_search(layer.key)) continue; + const auto title = world_layer_title(layer.label); + if (!matches_search(title) && !matches_search(layer.key)) continue; ++visible_layers; // Every row owns its checkbox ID, independent of table headers. ImGui::PushID(layer.key.data()); ImGui::TableNextRow(); ImGui::TableNextColumn(); ImGui::AlignTextToFramePadding(); - ImGui::TextWrapped("%s", layer.label.data()); + ImGui::TextWrapped("%s", title.c_str()); if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); ImGui::PushTextWrapPos(ImGui::GetFontSize() * 30); ImGui::TextUnformatted(layer.detail.data()); diff --git a/Extension/World/console_commands.cpp b/Extension/World/console_commands.cpp index 20636790..4b0722d0 100644 --- a/Extension/World/console_commands.cpp +++ b/Extension/World/console_commands.cpp @@ -157,6 +157,8 @@ void register_world_commands(Commands ®istry) { registry.add(std::move(load)); for (std::size_t i = 0; i < world_layers().size(); ++i) { const auto &layer = world_layers()[i]; + // (No command for a layer ReSkate keeps off.) + if (world_layer_kept_off(layer.key)) continue; auto mode = argument("default|on|off"); mode.choices = {"default", "on", "off"}; auto entry = variable("world " + std::string(layer.key), std::string(layer.detail), Group::world, mode); diff --git a/Extension/World/local_world_layers.cpp b/Extension/World/local_world_layers.cpp index 2ed74050..d1b9374a 100644 --- a/Extension/World/local_world_layers.cpp +++ b/Extension/World/local_world_layers.cpp @@ -283,14 +283,18 @@ void update_world_layers(std::uintptr_t manager, std::uint64_t now) { // A disabled container takes precedence over enabled children. Otherwise // a later row could silently turn it back on or leave the plan oscillating. std::vector disabled(targets.size()); + // A layer ReSkate keeps off (world_layers.h) is "off" whatever the player or a host chose. + const auto choice_of = [&](unsigned i) -> std::string_view { + return world_layer_kept_off(world_layers()[i].key) ? std::string_view("off") : std::string_view(r.model.choices[i]); + }; for (unsigned i = 0; i < world_layers().size(); ++i) - if (world_layers()[i].map == map && r.model.choices[i] == "off") { + if (world_layers()[i].map == map && choice_of(i) == "off") { const auto slot = world_switch(i); desired[slot] = false; explicit_choice[slot] = disabled[slot] = true; } for (unsigned i = 0; i < world_layers().size(); ++i) { if (world_layers()[i].map != map) { r.model.status[i] = "Available in " + std::string(world_map_label(world_layers()[i].map)); continue; } - if (r.model.choices[i] != "on") continue; + if (choice_of(i) != "on") continue; bool blocked{}; for (int at = static_cast(world_leaf(i)); at >= 0; at = world_parent(at)) blocked = blocked || disabled[at]; if (blocked) continue; @@ -337,7 +341,7 @@ void update_world_layers(std::uintptr_t manager, std::uint64_t now) { } for (unsigned i = 0; i < world_layers().size(); ++i) { if (world_layers()[i].map != map) continue; - const auto& choice = r.model.choices[i]; + const auto choice = choice_of(i); const auto slot = choice == "off" ? world_switch(i) : world_leaf(i); r.model.status[i] = done[slot] ? (choice == "default" ? "Default" : inactive[slot] ? "Disabled" : "Loaded") : status[slot]; } diff --git a/README.md b/README.md index 597dee82..c417b849 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,8 @@ accepts `park random` from its console or an admin. on an automatically selected port; no internet hosting is needed. The endpoint exposes no filesystem routes and retains at most 64 MiB of artwork. Existing content-cache covers keep their priority. -Only install mods you trust. Mods change game data, and custom scripts can run code. +Only install mods you trust. Mods change game data. Custom Lua scripts (`scripts/Custom`) are +switched off in this version: nothing in that folder is read or run. ## Dedicated servers