diff --git a/Engine/Vfs/content_catalogs.cpp b/Engine/Vfs/content_catalogs.cpp index 6623b50e..eb57e8f5 100644 --- a/Engine/Vfs/content_catalogs.cpp +++ b/Engine/Vfs/content_catalogs.cpp @@ -5,6 +5,7 @@ #include #include #include +#include namespace dingosdk::content_cache { namespace { @@ -302,8 +303,112 @@ bool Catalogs::reserved(const std::string& key) const { return !key.starts_with("dev_") && key.find("_dev_") == std::string::npos && !key.ends_with("_dev"); } -const Catalogs& catalogs() { - static const Catalogs value = [] { +namespace { +// reskate-objects.json text: at most 255 bytes, no control characters. Anything else is absent. +std::optional mod_text(const Json& entry, std::string_view field) { + if (!entry.contains(field) || !entry.at(field).is_string()) return std::nullopt; + const auto& value = entry.at(field).string(); + if (value.empty() || value.size() > 255 || std::any_of(value.begin(), value.end(), [](char c) { + const auto byte = static_cast(c); + return byte < 0x20 || byte == 0x7f; + })) return std::nullopt; + return value; +} +// The entry's priority, or `after` (one past the highest already there) when it gives none. +std::uint32_t mod_priority(const Json& entry, std::uint32_t after) { + if (entry.contains("priority") && entry.at("priority").is_number_integer()) { + const auto value = entry.at("priority").get(); + if (value >= 0 && value <= 0xffffffffLL) return static_cast(value); + } + return after; +} +const Json& mod_list(const Json& root, std::string_view name) { + static const Json none = Json::array(); + if (!root.contains(name)) return none; + if (!root.at(name).is_array()) throw std::runtime_error(std::string(name) + " is not a list"); + return root.at(name); +} +ObjectCategory* find_category(Catalogs& out, std::string_view id) { + for (auto& category : out.object_categories) + if (category.id == id) return &category; + return nullptr; +} +bool family_exists(const Catalogs& out, std::string_view id) { + return std::ranges::any_of(out.object_categories, [&](const ObjectCategory& category) { + return std::ranges::any_of(category.groups, [&](const ObjectGroup& group) { return group.id == id; }); + }); +} +} + +std::vector add_mod_objects(Catalogs& out, const std::string& json) { + const auto root = Json::parse(json); + if (!root.is_object() || !root.contains("schema") || !root.at("schema").is_number_integer() || + root.at("schema").get() != 1) + throw std::runtime_error("expected schema 1"); + // Checked before anything is added, so a file that is not this shape adds nothing. + const auto& categories = mod_list(root, "categories"); + const auto& families = mod_list(root, "families"); + const auto& objects = mod_list(root, "objects"); + std::vector refused; + for (std::size_t i = 0; i < categories.size() && i < 64; ++i) { + const auto& entry = categories.at(i); + const auto id = mod_text(entry, "id"); + if (!id) { refused.push_back("a category without an id was left out"); continue; } + if (find_category(out, *id)) { refused.push_back("category " + *id + " was left out: the id is taken"); continue; } + std::uint32_t last{}; + for (const auto& category : out.object_categories) last = std::max(last, category.priority); + // In the Object Browser, as the game's categories tagged "qdbuildkit" are. No icon: the + // browser draws its first tile's thumbnail. + out.object_categories.push_back({*id, mod_text(entry, "title").value_or(*id), {}, + mod_priority(entry, last + 1), true, {}}); + } + for (std::size_t i = 0; i < families.size() && i < 1024; ++i) { + const auto& entry = families.at(i); + const auto id = mod_text(entry, "id"); + const auto category_id = mod_text(entry, "category"); + if (!id || !category_id) { refused.push_back("a family without an id and a category was left out"); continue; } + if (family_exists(out, *id)) { refused.push_back("family " + *id + " was left out: the id is taken"); continue; } + auto* category = find_category(out, *category_id); + if (!category) { + refused.push_back("family " + *id + " was left out: there is no category " + *category_id); + continue; + } + std::uint32_t last{}; + for (const auto& group : category->groups) last = std::max(last, group.priority); + category->groups.push_back({*id, mod_text(entry, "title").value_or(*id), mod_priority(entry, last + 1)}); + } + for (std::size_t i = 0; i < objects.size() && i < 8192; ++i) { + const auto& entry = objects.at(i); + const auto key = mod_text(entry, "key"); + if (!key || lower(key->substr(0, 4)) != "own_") { + refused.push_back("an object without an own_ key was left out"); + continue; + } + const auto id = lower(*key); + // The game's own items keep their names and families, and the first mod to name an object wins. + if (out.items.contains(id)) { refused.push_back("object " + id + " was left out: it is the game's"); continue; } + if (out.mod_objects.contains(id)) { refused.push_back("object " + id + " was left out: it is already named"); continue; } + Json object = Json::object(); + if (const auto family = mod_text(entry, "family")) { + if (!family_exists(out, *family)) { + refused.push_back("object " + id + " was left out: there is no family " + *family); + continue; + } + object["group"] = *family; + // What the game's own Quick Drop records give as their BuildKitObjectType. + object["object_type"] = std::string("QuickDrop"); + } + if (const auto title = mod_text(entry, "title")) object["title"] = *title; + if (const auto description = mod_text(entry, "description")) object["description"] = *description; + if (!object.size()) { refused.push_back("object " + id + " was left out: it has no title or family"); continue; } + out.mod_objects[id] = std::move(object); + } + return refused; +} + +namespace { +Catalogs& process_catalogs() { + static Catalogs value = [] { try { if (installed()) return read_catalogs(directory()); } catch (...) {} @@ -312,3 +417,27 @@ const Catalogs& catalogs() { return value; } } + +const Catalogs& catalogs() { return process_catalogs(); } + +ModObjectsReport install_mod_objects(const std::vector>& files) { + ModObjectsReport report; + auto& out = process_catalogs(); + for (const auto& [mod, path] : files) { + std::error_code error; + if (!std::filesystem::is_regular_file(path, error)) continue; + try { + if (std::filesystem::file_size(path) > JsonLimits{}.bytes) throw std::runtime_error("it is too large"); + const auto bytes = read_file(path); + const auto before = out.mod_objects.size(); + for (auto& note : add_mod_objects(out, std::string(bytes.begin(), bytes.end()))) + report.notes.push_back(mod + ": " + note); + report.objects += out.mod_objects.size() - before; + ++report.mods; + } catch (const std::exception& failure) { + report.notes.push_back(mod + ": " + mod_objects_file + " was skipped: " + failure.what()); + } + } + return report; +} +} diff --git a/Engine/Vfs/content_catalogs.h b/Engine/Vfs/content_catalogs.h index c40afb57..8f065628 100644 --- a/Engine/Vfs/content_catalogs.h +++ b/Engine/Vfs/content_catalogs.h @@ -55,9 +55,36 @@ struct Catalogs { // Song ("Artist - Title") -> its cdn:/ cover art: a song record carries a field-10 message // (10.10 artist, 10.11 title, 10.12 artwork). std::map> music_song_artwork; + // Objects mods add, as their reskate-objects.json describes them (add_mod_objects): lower-case + // owned asset id -> {"title", "description", "group", "object_type"}, read like `items`. Kept + // apart from `items`, which stays the game's own catalogue (store checks, the mod badge). + Json mod_objects = Json::object(); bool reserved(const std::string& key) const; }; // Read once, on first use. Empty (available == false) when no pack is installed. const Catalogs& catalogs(); Catalogs read_catalogs(const std::filesystem::path& folder); + +// A mod's names and Quick Drop families for the objects it adds, in its folder: +// {"schema": 1, +// "categories": [{"id": "category_mine", "title": "My Objects", "priority": 900}], +// "families": [{"id": "qdparent_mine_box", "title": "Box", "category": "category_mine"}], +// "objects": [{"key": "own_bkprops_mine_box_00001", "title": "Box, Red", "family": "qdparent_mine_box"}]} +// A family is one Object Browser tile: its objects are the choices X / Square offers there, as with +// the game's own families. A family may go in one of the game's categories, and an object in one of +// the game's families. Every list and field but the ids and keys is optional; an entry without a +// priority goes after the ones already there. +inline constexpr char mod_objects_file[] = "reskate-objects.json"; +// Adds one mod's reskate-objects.json to `out`. Only adds: a category or family id that is already +// taken, or an object the game's catalogue (or a mod read earlier) already has, is refused, so a +// mod can name and group its own objects but never the game's. Returns what was refused, a line +// each. Throws std::exception on a document that is not this shape, so the caller can skip it. +std::vector add_mod_objects(Catalogs& out, const std::string& json); +// The process-wide catalogues plus each mod's file (mod name, path), highest priority first. +// Called once while the runtime starts, before the game runs. Missing files are skipped. +struct ModObjectsReport { + std::size_t mods{}, objects{}; + std::vector notes; // a line each for every file or entry left out +}; +ModObjectsReport install_mod_objects(const std::vector>& files); } diff --git a/Extension/Customization/local_customization.cpp b/Extension/Customization/local_customization.cpp index 01fefb14..32440dc7 100644 --- a/Extension/Customization/local_customization.cpp +++ b/Extension/Customization/local_customization.cpp @@ -115,11 +115,12 @@ void validate_player_card(const CosmeticLoadout& value) { "Invalid RIP Card slot"); } dingosdk::Json item_display_metadata(const std::string& asset, const dingosdk::Json& overrides) { - // Titles, descriptions and rarities come from the installed content cache. - const auto& items = content_cache::catalogs().items; + // Titles, descriptions and rarities come from the installed content cache. A mod's own + // object takes the title its reskate-objects.json gives it (content_catalogs.h). + const auto& catalogs = content_cache::catalogs(); std::string key = asset; for (auto& c : key) if (c >= 'A' && c <= 'Z') c = static_cast(c + ('a' - 'A')); - Json result = items.value(key, Json::object()); + Json result = catalogs.items.contains(key) ? catalogs.items.at(key) : catalogs.mod_objects.value(key, Json::object()); // User metadata takes precedence field by field, including explicit empty // descriptions. Do not copy unrelated saved inventory data into the catalog. for (const auto* field : {"title", "description", "rarity_id"}) diff --git a/Extension/Objects/Test/object_categories_tests.cpp b/Extension/Objects/Test/object_categories_tests.cpp index aae0a887..7d6d8162 100644 --- a/Extension/Objects/Test/object_categories_tests.cpp +++ b/Extension/Objects/Test/object_categories_tests.cpp @@ -56,6 +56,86 @@ int main() { check(objects::browser_tile("own_bkprops_unknown_00001", catalogs, groups) == "own_bkprops_unknown_00001", "unplaced tile is the object itself"); + // A mod's reskate-objects.json: a category and families of its own, a family in the game's Rail + // category and an object in the game's A Frame family. It cannot rename or regroup the game's items. + { + auto modded = catalogs; + const auto refused = content_cache::add_mod_objects(modded, R"({"schema": 1, + "categories": [{"id": "category_shoebox", "title": "Shoebox Pads"}, {"id": "category_ramp"}], + "families": [ + {"id": "qdparent_shoebox_dc", "title": "DC Shoebox", "category": "category_shoebox"}, + {"id": "qdparent_shoebox_etnies", "title": "Etnies Shoebox", "category": "category_shoebox", "priority": 0}, + {"id": "qdparent_mod_rail", "title": "Mod Rail", "category": "category_rail"}, + {"id": "qdparent_aframe", "category": "category_shoebox"}, + {"id": "qdparent_orphan", "category": "category_missing"}], + "objects": [ + {"key": "own_bkrails_shoebox_dc_12m", "title": "DC Shoebox 12m", "family": "qdparent_shoebox_dc"}, + {"key": "OWN_BKRAILS_SHOEBOX_DC_9M_RED", "title": "DC Shoebox 9m, Red", "family": "qdparent_shoebox_dc"}, + {"key": "own_bkrails_shoebox_dc_6m_blue", "family": "qdparent_shoebox_dc"}, + {"key": "own_bkrails_shoebox_etnies_12m", "title": "Etnies Shoebox 12m", "family": "qdparent_shoebox_etnies"}, + {"key": "own_bkramps_mod_aframe_00001", "title": "A Frame, Mod", "family": "qdparent_aframe"}, + {"key": "own_bkprops_named_00001", "title": "Named Prop"}, + {"key": "own_bkramps_aframe_long_00001", "title": "Renamed", "family": "qdparent_shoebox_dc"}, + {"key": "own_bkrails_shoebox_dc_12m", "title": "Twice"}, + {"key": "own_bkprops_lost_00001", "family": "qdparent_missing"}, + {"key": "bkprops_no_prefix", "title": "No prefix"}]})"); + check(refused.size() == 7, "taken ids, the game's item, a repeat, a missing family and a bad key refused"); + const auto* shoebox = &modded.object_categories.back(); + check(shoebox->id == "category_shoebox" && shoebox->title == "Shoebox Pads" && shoebox->quick_drop && + shoebox->priority == 3 && shoebox->groups.size() == 2, "mod category after the game's, in the browser"); + check(shoebox->groups[0].priority == 1 && shoebox->groups[1].priority == 0, "family priorities"); + const auto& rail = modded.object_categories[1]; + check(rail.groups.size() == 2 && rail.groups[1].id == "qdparent_mod_rail" && rail.groups[1].priority == 1, + "mod family in the game's category"); + check(modded.items.at("own_bkramps_aframe_long_00001").value("title", "") == "A Frame, Long" && + !modded.mod_objects.contains("own_bkramps_aframe_long_00001"), "the game's item keeps its name and family"); + check(modded.mod_objects.at("own_bkrails_shoebox_dc_12m").value("title", "") == "DC Shoebox 12m" && + modded.mod_objects.at("own_bkrails_shoebox_dc_9m_red").value("group", "") == "qdparent_shoebox_dc" && + modded.mod_objects.at("own_bkrails_shoebox_dc_9m_red").value("object_type", "") == "QuickDrop", + "first name wins, keys are lower case, family objects are Quick Drop objects"); + check(!modded.mod_objects.at("own_bkprops_named_00001").contains("group"), "a title alone places nothing"); + check(!modded.reserved("own_bkrails_shoebox_dc_12m"), "mod objects are never store items"); + + const std::vector installed{ + {"own_bkramps_aframe_long_00001", "bkramps", "A Frame, Long"}, + {"own_bkramps_aframe_short_00001", "bkramps", "A Frame, Short"}, + {"own_bkrails_flat_00001", "bkrails", "Flat"}, + {"own_bkvertbasesmall_00001", "bkvertbasesmall", "Bowl"}, + {"own_bkrails_shoebox_dc_12m", "bkrails", "DC Shoebox 12m"}, + {"own_bkrails_shoebox_dc_6m_blue", "bkrails", "own bkrails shoebox dc 6m blue"}, + {"own_bkrails_shoebox_dc_9m_red", "bkrails", "DC Shoebox 9m, Red"}, + {"own_bkrails_shoebox_etnies_12m", "bkrails", "Etnies Shoebox 12m"}, + {"own_bkramps_mod_aframe_00001", "bkramps", "A Frame, Mod"}, + {"own_bkprops_named_00001", "bkprops", "Named Prop"}, + }; + const auto mod_layout = objects::browser_layout(installed, modded); + check(mod_layout.size() == 5 && mod_layout[2].id == "category_shoebox" && mod_layout[4].id == "bkprops", + "mod category among the browser categories, by priority"); + if (const auto* pads = find(mod_layout, "category_shoebox")) { + check(pads->tiles.size() == 2 && pads->tiles[0].id == "qdparent_shoebox_etnies" && + pads->tiles[1].id == "qdparent_shoebox_dc", "one tile per mod family"); + check(pads->tiles.size() == 2 && pads->tiles[1].items.size() == 3 && + pads->tiles[1].items[0] == "own_bkrails_shoebox_dc_12m", "a family's objects share its tile"); + } + if (const auto* ramp = find(mod_layout, "category_ramp")) + check(ramp->tiles.size() == 1 && ramp->tiles[0].items.size() == 3, "mod object in the game's family"); + const auto mod_groups = objects::object_groups(modded); + check(objects::browser_tile("OWN_BKRAILS_SHOEBOX_DC_9M_RED", modded, mod_groups) == "qdparent_shoebox_dc", + "mod object tile lookup ignores case"); + + const auto unchanged = [&](const char* json) { + auto copy = catalogs; + try { content_cache::add_mod_objects(copy, json); } catch (const std::exception&) { + return copy.mod_objects.size() == 0 && copy.object_categories.size() == catalogs.object_categories.size(); + } + return false; + }; + check(unchanged(R"({"schema": 2, "objects": []})"), "another schema is refused"); + check(unchanged(R"({"schema": 1, "categories": [{"id": "category_new"}], "objects": {}})"), + "a file that is not this shape adds nothing"); + check(unchanged("not json"), "a file that is not JSON is refused"); + } + if (content_cache::installed()) { const auto& cache = content_cache::catalogs(); std::vector browser; diff --git a/Extension/Objects/object_categories.cpp b/Extension/Objects/object_categories.cpp index 1a5fbf28..8157db37 100644 --- a/Extension/Objects/object_categories.cpp +++ b/Extension/Objects/object_categories.cpp @@ -23,8 +23,10 @@ std::map> object_groups(const content_cache: Placement object_placement(std::string_view key, const content_cache::Catalogs& catalogs, const std::map>& groups) { const auto id = lower(key); - if (!catalogs.items.is_object() || !catalogs.items.contains(id)) return {}; - const auto& item = catalogs.items.at(id); + // The game's own record, or the family a mod gives its object (reskate-objects.json). + const auto& records = catalogs.items.contains(id) ? catalogs.items : catalogs.mod_objects; + if (!records.contains(id)) return {}; + const auto& item = records.at(id); if (!item.is_object() || !item.contains("group") || !item.at("group").is_string()) return {}; const auto found = groups.find(item.at("group").string()); return found == groups.end() ? Placement{} : found->second; diff --git a/Extension/Objects/object_categories.h b/Extension/Objects/object_categories.h index c92ca529..7f8f7f62 100644 --- a/Extension/Objects/object_categories.h +++ b/Extension/Objects/object_categories.h @@ -6,7 +6,8 @@ #include // The Object Browser's category tree, taken from the game's category service -// records in the content cache. Objects the cache does not place fall back to +// records in the content cache and the families mods declare for their own +// objects (content_cache::add_mod_objects). Objects neither places fall back to // one tile each under their installed family. namespace dingosdk::objects { struct InstalledObject { diff --git a/README.md b/README.md index 6fa46047..54eeb5a8 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,27 @@ on (it is by default), the crash details have already been sent; see ReSkate serves registered image bytes through a process-local HTTP endpoint bound only to `127.0.0.1` 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. +- A mod that adds Quick Drop objects can name them and group them into families with a + `reskate-objects.json` in its folder. A family is one tile in the Object Browser, and X / Square on it + picks between its objects, the way the game's own sizes and colours work: + + ```json + { + "schema": 1, + "categories": [{"id": "category_shoebox", "title": "Shoebox Pads"}], + "families": [{"id": "qdparent_shoebox_dc", "title": "DC Shoebox", "category": "category_shoebox"}], + "objects": [ + {"key": "own_bkrails_shoebox_dc_12m", "title": "DC Shoebox 12m", "family": "qdparent_shoebox_dc"}, + {"key": "own_bkrails_shoebox_dc_6m_red", "title": "DC Shoebox 6m, Red", "family": "qdparent_shoebox_dc"} + ] + } + ``` + + `key` is the object's owned asset key. A family can also go in one of the game's categories + (`category_ramp`, `category_rail`, ...), and an object in one of the game's families. Titles, a + `description` and `priority` (lower first) are optional. The file only adds: ids that are already taken + and the game's own items are left out and logged, so a mod cannot rename or regroup the game's objects. + The file is read when the game starts. 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. diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index bfa51f62..0ca7a966 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -203,6 +203,19 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { if (r.custom_levels.present) dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::level, "Custom-level destinations after merging: {}.", r.custom_levels.levels.size()); + // Names and Quick Drop families mods give their own objects, before anything lists objects. + { + std::vector> object_files; + for (const auto& mod : mod_catalog.mods) + object_files.emplace_back(mod.name, mod.directory / dingosdk::content_cache::mod_objects_file); + const auto declared = dingosdk::content_cache::install_mod_objects(object_files); + for (const auto& note : declared.notes) + dingosdk::logging::log(dingosdk::logging::Level::warning, dingosdk::logging::Channel::objects, + "Mod objects: {}.", note); + if (declared.mods) + dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::objects, + "Mod objects: {} named by {} mod(s).", declared.objects, declared.mods); + } auto interposer = GetModuleHandleW(L"sl.interposer.dll"); if (!interposer) interposer = LoadLibraryExW(interposer_path.c_str(), nullptr, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32);