Files
ReSkate/Extension/Customization/local_cosmetic_catalog.cpp
T
2026-10-02 14:40:41 -05:00

309 lines
18 KiB
C++

#include "Engine/Core/Log/logging.h"
#include "local_cosmetic_catalog.h"
#include "local_customization_runtime.h"
#include "Extension/Profile/runtime_internal.h"
#include "Extension/Objects/object_categories.h"
#include "Engine/Game/Build/addresses.h"
#include "Engine/Game/Build/20260929/engine.h"
#include "Engine/Game/Build/20260929/local_cosmetic_catalog.h"
namespace dingosdk::profile_runtime {
// Retail OwnableData catalog bridge; included after the installed-item reader.
// Only used by the authored-offline profile on the game-update thread.
void cosmetic_release(CosmeticShared& value) noexcept {
const auto control = value.control;
value = {};
if (!control) return;
const auto address = reinterpret_cast<std::uintptr_t>(control);
const auto table = *reinterpret_cast<std::uintptr_t**>(control);
if (InterlockedDecrement(reinterpret_cast<volatile LONG*>(address + 8)) == 0)
reinterpret_cast<void (*)(void*)>(table[1])(control);
if (InterlockedDecrement(reinterpret_cast<volatile LONG*>(address + 12)) == 0)
reinterpret_cast<void (*)(void*)>(table[2])(control);
}
CosmeticCatalogFunctions& cosmetic_catalog_functions() {
static auto* f = new CosmeticCatalogFunctions;
return *f;
}
void initialize_cosmetic_catalog_functions(std::uintptr_t base) {
auto& f = cosmetic_catalog_functions();
f.allocate = reinterpret_cast<decltype(f.allocate)>(base + cosmetic_allocate_contract.rva);
f.construct = reinterpret_cast<decltype(f.construct)>(base + cosmetic_construct_contract.rva);
f.decode = reinterpret_cast<decltype(f.decode)>(base + cosmetic_decode_contract.rva);
f.create = reinterpret_cast<decltype(f.create)>(base + cosmetic_create_catalog_contract.rva);
f.complete = reinterpret_cast<decltype(f.complete)>(base + cosmetic_complete_catalog_contract.rva);
f.populate_inventory = reinterpret_cast<decltype(f.populate_inventory)>(base + cosmetic_populate_inventory_contract.rva);
f.ui_ready = reinterpret_cast<decltype(f.ui_ready)>(base + cosmetic_ui_ready_contract.rva);
f.message_control_vtable = base + addr::local_cosmetic_catalog::message_control_vtable;
f.allocator_adapter = {base + addr::engine::allocator_adapter_vtable, 0, 8};
}
void cosmetic_varint(std::string& out, std::uint64_t value) {
while (value >= 128) { out += static_cast<char>((value & 127) | 128); value >>= 7; }
out += static_cast<char>(value);
}
void cosmetic_wire_number(std::string& out, unsigned field, std::uint64_t value) {
cosmetic_varint(out, static_cast<std::uint64_t>(field) << 3); cosmetic_varint(out, value);
}
void cosmetic_wire_string(std::string& out, unsigned field, std::string_view value) {
cosmetic_varint(out, (static_cast<std::uint64_t>(field) << 3) | 2);
cosmetic_varint(out, value.size()); out.append(value);
}
std::string cosmetic_ownable_message(const std::string& key, const CosmeticItemInfo& info, bool owned,
const dingosdk::Json& metadata) {
// Local identity is deliberately the installed asset key. It is never sent
// to an online service or represented as an original server catalog ID.
std::string title = (key.starts_with("Own_") || key.starts_with("own_")) ? key.substr(4) : key;
std::replace(title.begin(), title.end(), '_', ' ');
std::string presentation;
cosmetic_wire_string(presentation, 2, metadata.value("title", title));
if (metadata.contains("description")) cosmetic_wire_string(presentation, 3, metadata.at("description").get<std::string>());
// The Object Browser makes one tile per subcategory. Objects name their
// tile (objects::browser_tile) in "group": the game's own variant family,
// or the object itself when the content cache does not place it.
cosmetic_wire_string(presentation, 5, info.build_kit ? metadata.value("group", key) : info.category);
// BuildKitObjectType name ("QuickDrop") as the live records send it.
if (info.build_kit && metadata.contains("object_type"))
cosmetic_wire_string(presentation, 6, metadata.at("object_type").get<std::string>());
cosmetic_wire_number(presentation, 7, static_cast<std::uint64_t>(static_cast<std::int64_t>(info.sort_priority)));
// categoryIdHash. For objects the Object Browser matches it against its
// category's hash (live records send djb2 of e.g. "category_ramp"); a
// mismatch leaves the category's tiles without a default item, drawn as Ø.
if (info.build_kit && metadata.contains("browser_category"))
cosmetic_wire_number(presentation, 9, cosmetic_hash(metadata.at("browser_category").get<std::string>()));
else
cosmetic_wire_number(presentation, 9, info.categories.empty() ? 0 : info.categories.front());
cosmetic_wire_string(presentation, 10, key);
cosmetic_wire_number(presentation, 11, info.hash);
// Empty icon/background invoke the native baseitem:/baseitembg: thumbnails.
std::string body;
cosmetic_wire_string(body, 1, key);
cosmetic_wire_string(body, 6, presentation);
cosmetic_wire_number(body, 10, owned ? 1 : 0);
// The OwnableData decoder maps tag 0xba (field 23) to rarityId +160.
// The ID references a separate server rarity definition; never infer one from an asset key.
if (metadata.contains("rarity_id")) cosmetic_wire_string(body, 23, metadata.at("rarity_id").get<std::string>());
std::string framed;
cosmetic_varint(framed, body.size()); framed += body;
return framed;
}
CosmeticShared make_cosmetic_message(const std::string& wire) {
auto& f = cosmetic_catalog_functions();
std::uintptr_t allocator{};
auto* block = static_cast<std::byte*>(f.allocate(&allocator, 0x1e8, 0));
if (!block) throw std::bad_alloc();
std::memset(block, 0, 0x1e8);
std::memcpy(block, &f.message_control_vtable, 8);
const std::uint32_t one = 1;
std::memcpy(block + 8, &one, 4); std::memcpy(block + 12, &one, 4);
std::memcpy(block + 0x1e0, &allocator, 8);
const auto adapter = reinterpret_cast<std::uintptr_t>(f.allocator_adapter.data());
const std::array<std::uintptr_t, 3> context{adapter, adapter, 0};
f.construct(block + 0x10, context.data());
CosmeticSharedGuard result{{block + 0x10, block}};
// The native reader constructor builds precisely this: bytes, length, offset, ended.
std::array<std::uintptr_t, 4> reader{reinterpret_cast<std::uintptr_t>(wire.data()), wire.size(), 0, 0};
if (!f.decode(reader.data(), result.value.body) || reader[2] != wire.size())
throw std::runtime_error("Native cosmetic record decode failed");
const auto value = result.value; result.value = {};
return value;
}
// The completion callback retains a shared map and shared messages separately.
// Its recovered refcount ABI increments both counters for each strong alias.
// Only the map storage is ReSkate-owned; messages use native control blocks.
void* cosmetic_map_destructor(CosmeticCatalogOwner* owner, unsigned flags) noexcept {
owner->clear();
if (flags & 1) delete owner;
return owner;
}
void cosmetic_map_destroy(CosmeticCatalogOwner* owner) noexcept { owner->clear(); }
void cosmetic_map_free(CosmeticCatalogOwner* owner) noexcept { delete owner; }
void* cosmetic_map_deleter(void*, void*) noexcept { return nullptr; }
CosmeticShared make_cosmetic_catalog(const profile::Snapshot& snapshot) {
static const std::array<std::uintptr_t, 4> table{
reinterpret_cast<std::uintptr_t>(&cosmetic_map_destructor),
reinterpret_cast<std::uintptr_t>(&cosmetic_map_destroy),
reinterpret_cast<std::uintptr_t>(&cosmetic_map_free),
reinterpret_cast<std::uintptr_t>(&cosmetic_map_deleter)};
const auto& items = cosmetic_runtime().items;
if (items.empty() || items.size() > 8192) throw std::runtime_error("Invalid local catalog size");
auto owner = std::make_unique<CosmeticCatalogOwner>();
owner->vtable = table.data();
owner->nodes.resize(items.size());
const auto bucket_count = static_cast<std::uint32_t>(items.size() * 2 + 1);
owner->buckets.resize(bucket_count + 1);
// EASTL's iteration scans past empty buckets until this non-null sentinel.
owner->buckets.back() = &owner->sentinel;
owner->map = {owner->buckets.data(), bucket_count, static_cast<std::uint32_t>(items.size())};
const auto inventory = snapshot.customization.value("inventory", dingosdk::Json::object());
const auto catalog = snapshot.customization.value("catalog", dingosdk::Json::object());
const auto object_dropper = snapshot.extensions.value("object_dropper", dingosdk::Json::object());
const auto object_inventory = object_dropper.value("inventory", dingosdk::Json::object());
const auto object_catalog = object_dropper.value("catalog", dingosdk::Json::object());
const auto groups = objects::object_groups(content_cache::catalogs());
std::set<std::uint32_t> hashes;
std::size_t index{};
for (const auto& [key, info] : items) {
if (!hashes.insert(info.hash).second) throw std::runtime_error("Ambiguous cosmetic identity");
auto& node = owner->nodes[index++];
node.hash = info.hash;
const auto& item_inventory = info.build_kit ? object_inventory : inventory;
const auto& item_catalog = info.build_kit ? object_catalog : catalog;
const auto saved = item_inventory.find(key);
const bool owned = !cosmetic_runtime().ownership_unavailable && saved != item_inventory.end() && saved->get<bool>();
auto metadata = profile::item_display_metadata(key, item_catalog.value(key, dingosdk::Json::object()));
if (info.build_kit) {
// The same tile and category the browser layout gives this object.
const auto placement = objects::object_placement(key, content_cache::catalogs(), groups);
metadata["group"] = placement.group ? placement.group->id : key;
metadata["browser_category"] = placement.category ? placement.category->id : info.category;
}
node.item = make_cosmetic_message(cosmetic_ownable_message(key, info, owned, metadata));
const auto bucket = info.hash % bucket_count;
node.next = owner->buckets[bucket]; owner->buckets[bucket] = &node;
}
CosmeticShared result{&owner->map, owner.get()}; owner.release();
return result;
}
void publish_cosmetic_catalog() {
auto& c = cosmetic_runtime(); auto& f = cosmetic_catalog_functions();
const auto base = local_runtime().base;
std::uintptr_t manager{}, arena{}, allocator_vtable{}, ui_allocator{};
if (!f.ui_ready || !f.ui_ready() || !read(base + addr::local_cosmetic_catalog::ownable_manager, manager)) return;
if (manager && manager == c.published_manager) {
// Items installed or removed since (a live mod apply, picked up at the
// level load that reloads the cosmetics bundle): hand the manager a new
// catalog. The native completion replaces the installed one, rebuilds
// its indexes and tells the CAS pages the catalog changed.
if (c.published_generation == c.items_generation) return;
std::uintptr_t vtable{};
if (!read(manager, vtable) || vtable != base + addr::local_cosmetic_catalog::ownable_manager_vtable) return;
CosmeticSharedGuard catalog{make_cosmetic_catalog(*local_runtime().store->shared_snapshot())};
f.complete(&catalog.value, &manager);
std::uintptr_t installed{}; std::uint32_t count{};
if (!read(manager + 0x28, installed) || !installed || !read(installed + 12, count) || count != c.items.size())
throw std::runtime_error("Cosmetic catalog republication did not complete");
c.published_generation = c.items_generation;
dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::customization,
"Cosmetic catalog republished after a mod change: {} items.", count);
return;
}
// A real service-backed manager retains ownership of its own catalog.
if (manager && manager != c.published_manager) return;
if (!read(base + addr::engine::default_arena, arena) || !arena || !read(arena, allocator_vtable) ||
allocator_vtable < base || allocator_vtable >= base + supported_build::game_image_size ||
!read(base + addr::engine::ui_allocator, ui_allocator) || !ui_allocator) return;
f.allocator_adapter[1] = arena;
CosmeticSharedGuard catalog{make_cosmetic_catalog(*local_runtime().store->shared_snapshot())};
dingosdk::logging::event(dingosdk::logging::Channel::customization, "{\"event\":\"local_cosmetic_catalog_decoded\"}");
f.create(ui_allocator, 0); // No online provider or network request.
if (!read(base + addr::local_cosmetic_catalog::ownable_manager, manager) || !manager) throw std::runtime_error("Cosmetic UI manager unavailable");
std::uintptr_t vtable{};
if (!read(manager, vtable) || vtable != base + addr::local_cosmetic_catalog::ownable_manager_vtable)
throw std::runtime_error("Unexpected cosmetic UI manager");
dingosdk::logging::event(dingosdk::logging::Channel::customization, "{\"event\":\"local_cosmetic_catalog_manager_created\"}");
f.complete(&catalog.value, &manager); // Consumes map; publishes both native indexes and UI readiness.
std::uintptr_t installed{}; std::uint32_t count{};
if (!read(manager + 0x28, installed) || !installed || !read(installed + 12, count) || count != c.items.size())
throw std::runtime_error("Cosmetic catalog publication did not complete");
c.published_manager = manager;
c.published_generation = c.items_generation;
std::ostringstream event;
event << "{\"event\":\"local_cosmetic_catalog_published\",\"items\":" << count << '}';
dingosdk::logging::event(dingosdk::logging::Channel::customization, event.str().c_str());
}
std::vector<std::uint32_t> cosmetic_owned_ids(const profile::Snapshot& snapshot) {
std::vector<std::uint32_t> ids;
if (cosmetic_runtime().ownership_unavailable) return ids;
const auto inventory = snapshot.customization.value("inventory", dingosdk::Json::object());
const auto object_dropper = snapshot.extensions.value("object_dropper", dingosdk::Json::object());
const auto object_inventory = object_dropper.value("inventory", dingosdk::Json::object());
for (const auto& [key, info] : cosmetic_runtime().items) {
const auto& item_inventory = info.build_kit ? object_inventory : inventory;
const auto saved = item_inventory.find(key);
if (saved != item_inventory.end() && saved->is_boolean() && saved->get<bool>())
ids.push_back(info.hash);
}
return ids;
}
void publish_cosmetic_inventory() {
auto& s = local_runtime(); auto& c = cosmetic_runtime(); auto& f = cosmetic_catalog_functions();
if (!s.active.load(std::memory_order_acquire) || c.update_thread != GetCurrentThreadId() ||
!c.published_manager || !f.populate_inventory || !f.ui_ready || !f.ui_ready()) return;
std::uintptr_t ownables{}, inventory{}, vtable{}, bound_catalog{}, data_model{}, service{};
std::uint32_t count{};
if (!read(s.base + addr::local_cosmetic_catalog::ownable_manager, ownables) || ownables != c.published_manager ||
!read(s.base + addr::local_cosmetic_catalog::inventory_manager, inventory)) return;
if (!inventory) {
c.published_inventory = c.inventory_data_model = 0;
c.published_inventory_count = 0;
return;
}
// The native constructor builds this manager with the current OwnableData manager.
// Its normal subscriptions are skipped without an inventory
// service, even though the default-owned catalog is already available.
if (!read(inventory, vtable) || vtable != s.base + addr::local_cosmetic_catalog::inventory_manager_vtable ||
!read(inventory + 0x1b0, service) || service ||
!read(inventory + 0x1d0, bound_catalog) || (bound_catalog && bound_catalog != ownables) ||
!read(inventory + 0x1c8, data_model) || !data_model ||
!read(inventory + 0x10c, count) || count > 8192) return;
if (!bound_catalog) {
// Offline UI setup can construct inventory before our catalog exists.
// The native binder stores a borrowed OwnableData pointer at +0x1d0; without a
// service there is no later subscription to repair that null binding.
// Bind only an empty offline inventory to our current published catalog,
// on the game-update thread, before the native consumer dereferences it.
if (count) return;
const auto previous = InterlockedCompareExchangePointer(
reinterpret_cast<void* volatile*>(inventory + 0x1d0),
reinterpret_cast<void*>(ownables), nullptr);
if (previous && reinterpret_cast<std::uintptr_t>(previous) != ownables) return;
}
if (inventory == c.published_inventory && data_model == c.inventory_data_model &&
count == c.published_inventory_count) return;
const auto ids = cosmetic_owned_ids(*s.store->shared_snapshot());
if (ids.size() > 8192) throw std::runtime_error("Invalid local cosmetic inventory size");
// Same synchronous consumer as the native default-owned subscription
// callback. It copies IDs into native models,
// sets ownership and updates the category collections used by CAS pages.
// Only begin/end are read; no borrowed storage survives this call.
const auto begin = reinterpret_cast<std::uintptr_t>(ids.data());
const auto end = begin + ids.size() * sizeof(std::uint32_t);
const std::array<std::uintptr_t, 4> borrowed{begin, end, end, 0};
f.populate_inventory(inventory, borrowed.data());
if (!read(inventory + 0x10c, count) || count != ids.size()) {
std::ostringstream event;
event << "{\"event\":\"local_cosmetic_inventory_incomplete\",\"expected\":" << ids.size()
<< ",\"actual\":" << count << '}';
dingosdk::logging::event(dingosdk::logging::Channel::customization, event.str().c_str());
throw std::runtime_error("Cosmetic inventory publication did not complete");
}
c.published_inventory = inventory; c.inventory_data_model = data_model;
c.published_inventory_count = count;
std::ostringstream event;
event << "{\"event\":\"local_cosmetic_inventory_published\",\"items\":" << count << '}';
dingosdk::logging::event(dingosdk::logging::Channel::customization, event.str().c_str(),
dingosdk::logging::Level::info);
}
}