From 86fea38bcda4e85f5cd5efb2de87cdf4e97d0245 Mon Sep 17 00:00:00 2001 From: tsullivan13 <25965236+tsullivan13@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:24:16 -0700 Subject: [PATCH] Objects: unlock free build-kit objects; Object Browser category icons Many free object-dropper objects were treated as store items and hidden from the Object Browser and the park editor (pole jams, road signs, bleachers, A frames, quarter pipes, bowl pieces, ...). - Items whose record names a neighbourhood rank unlock (field 11) are open, even when no rank record lists them as a reward (20 objects, 6 cosmetics). - Build-kit objects that no record offers, sells or grants are open (169). Objects with any offer or bundle grant, and brand collabs (field 24), stay reserved: 42 of 439 on build 24855063. Cosmetics are unchanged. - Object Browser categories use their first object's thumbnail as the tab icon instead of the game's empty placeholder. Co-Authored-By: Claude Opus 5.5 --- Engine/Vfs/Test/content_catalogs_tests.cpp | 15 +++++++++++ Engine/Vfs/content_catalogs.cpp | 29 ++++++++++++++++++++++ Engine/Vfs/content_catalogs.h | 3 +++ Extension/Objects/local_object_runtime.cpp | 12 ++++++++- 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/Engine/Vfs/Test/content_catalogs_tests.cpp b/Engine/Vfs/Test/content_catalogs_tests.cpp index 798c0667..78b69d12 100644 --- a/Engine/Vfs/Test/content_catalogs_tests.cpp +++ b/Engine/Vfs/Test/content_catalogs_tests.cpp @@ -28,6 +28,16 @@ int main(int argc, char** argv) { check(catalogs.items.at("own_bkbanks_generic_volcanolarge_00001").value("title", "") == "Volcano, L Wood", "item keyed by owned asset id"); check(catalogs.items.at("own_costume_gen_dembones_00001").value("rarity_id", "") == "legendary", "item rarity"); + // Unlocked by neighbourhood rank, though no rank record lists them as rewards. + check(!catalogs.reserved("own_bkrails_generic_urbanpolejamsmall_00001") && + !catalogs.reserved("own_bkindustrial_generic_signstopsign_00001"), "rank unlocks are not store items"); + // Objects nothing offers are open; ones sold, bundled or from a collab are not. + check(!catalogs.reserved("own_bkrails_generic_urbanpolejammedium_00001") && + !catalogs.reserved("own_bkprops_generic_signnoskateboarding_00001"), "objects nothing offers are open"); + check(catalogs.reserved("own_bkbounceable_generic_dartboard_00001"), "an object sold for hard currency"); + check(catalogs.reserved("own_bkramps_generic_shoeboxanglednike_00001"), "an object from a store bundle"); + check(catalogs.reserved("own_bkpads_generic_manualpadroundxgames_00001"), "a collab object"); + check(catalogs.reserved("own_deckgraphic_gen_popsicle_00123"), "cosmetics nothing offers stay reserved"); const auto& line = catalogs.challenges.at("Base_CH02_M02_Line01"); check(line.value("type", "") == "Line" && line.value("neighborhood", "") == "neighbourhood_rank_historic", "challenge type and neighbourhood"); @@ -81,6 +91,11 @@ int main(int argc, char** argv) { for (const auto& id : catalogs.entitlements) dump["entitlements"].push_back(id); std::ofstream(argv[2], std::ios::binary) << dump.dump(1); } + // Build 24855063: 42 of 439 (28 sold for hard currency, 3 in the Nike bundle, 11 X Games). + std::size_t objects{}, reserved_objects{}; + for (const auto& [key, item] : catalogs.items.items()) + if (key.starts_with("own_bk")) { ++objects; reserved_objects += catalogs.reserved(key); } + std::cout << reserved_objects << " of " << objects << " objects reserved.\n"; std::cout << catalogs.items.size() << " items, " << catalogs.challenges.size() << " challenges, " << catalogs.entitlements.size() << " entitlements.\n"; return failures ? 1 : 0; diff --git a/Engine/Vfs/content_catalogs.cpp b/Engine/Vfs/content_catalogs.cpp index 0000e9bd..6623b50e 100644 --- a/Engine/Vfs/content_catalogs.cpp +++ b/Engine/Vfs/content_catalogs.cpp @@ -36,6 +36,9 @@ std::string lower(std::string value) { // Owned items: the presentation message (6) names the owned asset (6.10), the // title (6.2) and description (6.3); the rarity is field 23. First record wins. +// Field 10 set marks an item owned from the start; field 11 names the +// neighbourhood rank that unlocks it, which some items carry with no rank +// record listing them as a reward. void add_item(Catalogs& out, const Fields& record) { const auto presentation = message(record, 6); if (!presentation) return; @@ -45,6 +48,13 @@ void add_item(Catalogs& out, const Fields& record) { if (out.items.contains(key)) return; if (const auto* flag = protobuf::first(record, 10, Wire::varint); flag && flag->integer == 1) out.open_items.insert(key); + // E.g. Pole Jam S/L and the Stop sign: rank unlocks that add_source never + // sees, because no neighbourhood_rank record grants them. + if (text(record, 11).value_or("").starts_with("neighbourhood_rank")) + out.open_items.insert(key); + // Field 24 tags brand collabs (collab_xgames, collab_nike, ...). + if (text(record, 24).value_or("").starts_with("collab_")) + out.collab_items.insert(key); Json entry = Json::object(); if (const auto description = text(*presentation, 3)) entry["description"] = *description; if (const auto rarity = text(record, 23)) entry["rarity_id"] = *rarity; @@ -117,6 +127,18 @@ bool free_offer(const Fields& offer) { return lower(prerequisite.id).find("premium") != std::string::npos; }); } +// Every own-create grant in any of the record's grant lists. Deliberately broad: +// it does not tell free from paid, so an item named by any offer, store bundle, +// pack or reward counts as granted and keeps the store's say over it (reserved()). +// A bytes field that is not a grant list simply parses to no grants. +void add_granted(std::set& granted, const Fields& record) { + std::set numbers; + for (const auto& field : record) + if (field.wire == Wire::bytes) numbers.insert(field.number); + for (const auto number : numbers) + for (const auto& grant : grants(record, number)) + if (grant.kind == "own-create" && lower(grant.id.substr(0, 4)) == "own_") granted.insert(lower(grant.id)); +} void add_source(std::set& open, const std::string& id, const Fields& record) { const auto open_grants = [&](std::uint32_t number) { for (const auto& grant : grants(record, number)) @@ -261,6 +283,7 @@ Catalogs read_catalogs(const std::filesystem::path& folder) { add_object_category(out, *id, *fields); add_travel(out, *id, *fields); add_source(out.open_items, *id, *fields); + add_granted(out.granted_items, *fields); } } out.entitlements.assign(entitlements.begin(), entitlements.end()); @@ -270,6 +293,12 @@ Catalogs read_catalogs(const std::filesystem::path& folder) { bool Catalogs::reserved(const std::string& key) const { if (!items.contains(key) || open_items.contains(key)) return false; + // Build-kit objects nothing offers, sells or grants are taken as everyday + // park pieces; brand collabs stay with the store. Many basic objects (A + // frames, quarter pipes, bowl pieces, Pole Jam M) have no source record in + // the cache, so without this the Object Browser and park editor hid them. + // Cosmetics are not covered: unsourced ones include past store and pass items. + if (key.starts_with("own_bk") && !granted_items.contains(key) && !collab_items.contains(key)) return false; return !key.starts_with("dev_") && key.find("_dev_") == std::string::npos && !key.ends_with("_dev"); } diff --git a/Engine/Vfs/content_catalogs.h b/Engine/Vfs/content_catalogs.h index 71f8b850..c40afb57 100644 --- a/Engine/Vfs/content_catalogs.h +++ b/Engine/Vfs/content_catalogs.h @@ -36,6 +36,9 @@ struct Catalogs { Json challenges = Json::object(); std::vector entitlements; std::set open_items; + // Items some record grants (an offer, bundle, pack or reward), free or not, + // and the ones tagged as a brand collab. + std::set granted_items, collab_items; // Build-kit categories in record order. std::vector object_categories; std::vector travel_locations; diff --git a/Extension/Objects/local_object_runtime.cpp b/Extension/Objects/local_object_runtime.cpp index a8297b81..e1be85d2 100644 --- a/Extension/Objects/local_object_runtime.cpp +++ b/Extension/Objects/local_object_runtime.cpp @@ -65,16 +65,26 @@ void fill_object_categories(std::uintptr_t result, const profile::Snapshot& snap if (info.build_kit && installed_item_owned(snapshot, key, info.hash, true)) owned.push_back({key, info.category, object_title(key, catalog.value(key, dingosdk::Json::object()))}); // Titles come from the cache's category records. Their icons are cdn:/ links - // that cannot load offline, and the browser would draw the link as text. + // that cannot load offline, and the browser would draw the link as text, so + // each category uses an object thumbnail instead. const auto layout = objects::browser_layout(owned, content_cache::catalogs()); std::vector categories(layout.size()); std::vector> subcategories(layout.size()); // The DTOs borrow these strings until the native parser has copied them. std::vector> icons(layout.size()); + // Sized up front so the borrowed pointers stay valid (no reallocation). + std::vector category_icons(layout.size()); for (std::size_t index = 0; index < layout.size(); ++index) { const auto& group = layout[index]; auto& parent = categories[index]; auto& children = subcategories[index]; parent.id.set(group.id); parent.title.set(group.title); + // The category bar shows its first tile's thumbnail in place of the + // cache's offline-only icon. Left unset, every tab drew the game's + // empty-icon placeholder. Same "baseitem:" path as the tiles below. + if (!group.tiles.empty()) { + category_icons[index] = "baseitem:" + group.tiles.front().items.front(); + parent.icon.set(category_icons[index]); + } parent.hash = cosmetic_hash(group.id); parent.priority = static_cast(index); children.resize(group.tiles.size());