Merge pull request #347 from snubroot/feat/mod-object-families

Objects: mods name their Quick Drop objects and group them into families with reskate-objects.json
This commit is contained in:
AyeZeeBB committed 2026-10-23 16:00:06 -05:00
commit 1b613b0dce
8 files changed
+282 -8

No files matched your search

+131 -2
View File
@@ -5,6 +5,7 @@
#include <fstream>
#include <map>
#include <set>
#include <stdexcept>
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<std::string> 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<unsigned char>(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<std::int64_t>();
if (value >= 0 && value <= 0xffffffffLL) return static_cast<std::uint32_t>(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<std::string> 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<int>() != 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<std::string> 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<std::pair<std::string, std::filesystem::path>>& 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;
}
}
+27
View File
@@ -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<std::string, std::string, std::less<>> 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<std::string> 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<std::string> notes; // a line each for every file or entry left out
};
ModObjectsReport install_mod_objects(const std::vector<std::pair<std::string, std::filesystem::path>>& files);
}
@@ -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<char>(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"})
@@ -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<objects::InstalledObject> 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<std::string> browser;
+4 -2
View File
@@ -23,8 +23,10 @@ std::map<std::string, Placement, std::less<>> object_groups(const content_cache:
Placement object_placement(std::string_view key, const content_cache::Catalogs& catalogs,
const std::map<std::string, Placement, std::less<>>& 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;
+2 -1
View File
@@ -6,7 +6,8 @@
#include <vector>
// 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 {
+21
View File
@@ -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.
+13
View File
@@ -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<std::pair<std::string, std::filesystem::path>> 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);