mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 18:37:07 +02:00
Skater slot, menu and throwdown fixes; MY MODS rework; lobby parties Saved skaters (Extension/Customization, Extension/Skater) - The selected slot, and slots before it, came up as the standard skater after a start or map change. The game asks for a slot's saved outfit once per level, and that happened before the cosmetics catalog could be read. The first selection now waits for the catalog (60 s at most), and later map changes check against the catalog already read. - The retry fromabdda77is removed: the record it wrote to is a temporary on the game's stack, so it could never work. Pause menu (Extension/UI/NativeMenu) - ReSkate's entries stopped working after enough map changes: each level change took new slots in a fixed table of 1024 for the same buttons. A button now keeps its slot, and a full table hands out the slot that has gone longest unused. Throwdowns (Extension/Throwdowns, Runtime/runtime_init.cpp) - Multiplayer crashed when someone placed a throwdown after a map change. The event types are freed with the level but were cached for the process. They are now cleared when a level is left, checked before use, and looked for again in each level. MY MODS (Launcher/gui_mods.cpp, gui.cpp) - Search, a filter and an order; a switch per mod; a menu per row. - Multi-select with tick boxes, Shift-click and Ctrl+A, and a bar to enable, disable, move, update or uninstall everything ticked. Lobby parties (Extension/Multiplayer/Session) - A lobby is no longer one party. Its host keeps parties the way a dedicated server does: invite, accept, join, leave, kick, promote, open and close, with "/p" party chat. The "Lobby party" switch is gone. Protocol version unchanged (38). Mod merge (Engine/Vfs), follow-ups toe715b9b- Files rewritten into the merged patch are tracked by position, so another mod's added assets no longer shift the lookup. - Two mods adding different assets under one name is now noted, with whose copy the maps get. - mod_merge_unshift_tests compiles with the launcher tests on.
455 lines
27 KiB
C++
455 lines
27 KiB
C++
#include "Engine/Core/Log/logging.h"
|
|
#include "local_customization_runtime.h"
|
|
#include "Extension/Profile/runtime_internal.h"
|
|
#include "Engine/Vfs/content_catalogs.h"
|
|
#include "Engine/Game/Build/addresses.h"
|
|
#include "Engine/Game/Build/20260929/engine.h"
|
|
#include "Engine/Game/Build/20260929/local_customization.h"
|
|
#include <mutex>
|
|
#include <set>
|
|
|
|
namespace dingosdk::profile_runtime {
|
|
CosmeticRuntime& cosmetic_runtime() { static auto* r = new CosmeticRuntime; return *r; }
|
|
|
|
bool cosmetic_diagnostic(const char* operation, const char* reason, std::string_view id ) noexcept {
|
|
try {
|
|
auto& seen = cosmetic_runtime().diagnostics;
|
|
const auto key = std::string(operation) + ':' + reason + ':' + std::string(id);
|
|
if (seen.size() < 64 && seen.insert(key).second) {
|
|
// An outfit that was not loaded or not saved is logged as a warning (events named
|
|
// ..._rejected are), so a player's ordinary log says why; the rest are routine.
|
|
const std::string_view why(reason);
|
|
const bool routine = why == "callback_entered" || why == "unchanged" || why == "no_saved_loadout" ||
|
|
why == "card_only_preset_recovered" || why == "card_is_not_skater_preset";
|
|
const dingosdk::Json event{{"event", routine ? "local_cosmetic_loadout_diagnostic" : "local_cosmetic_loadout_rejected"},
|
|
{"operation", operation}, {"reason", reason}, {"preset", id}};
|
|
dingosdk::logging::event(dingosdk::logging::Channel::customization, event.dump().c_str());
|
|
}
|
|
} catch (...) {}
|
|
return false;
|
|
}
|
|
|
|
std::uint32_t cosmetic_hash(std::string_view value) {
|
|
if (value.empty()) return 0;
|
|
std::uint32_t hash = 5381;
|
|
for (unsigned char c : value) hash = hash * 33 ^ c;
|
|
return hash;
|
|
}
|
|
|
|
bool cosmetic_text(std::uintptr_t p, std::string& value) {
|
|
value.clear();
|
|
for (std::size_t i = 0; i <= 255; ++i) {
|
|
unsigned char c{};
|
|
if (!read(p + i, c)) return false;
|
|
if (!c) return true;
|
|
if (c < 32 || c == 127) return false;
|
|
value += static_cast<char>(c);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool cosmetic_array(std::uintptr_t p, std::size_t stride, std::size_t limit, std::uint32_t& count) {
|
|
if (!p || !read(p - 4, count)) return false;
|
|
count &= 0x7fffffff;
|
|
return count <= limit && p <= memory::highest_user_address - count * stride;
|
|
}
|
|
|
|
bool cosmetic_words(std::uintptr_t p, std::size_t limit, std::vector<std::uint32_t>& result) {
|
|
std::uint32_t count{};
|
|
if (!cosmetic_array(p, 4, limit, count)) return false;
|
|
result.resize(count);
|
|
return !count || read_bytes(p, result.data(), count * 4);
|
|
}
|
|
|
|
namespace {
|
|
std::mutex reserved_hash_mutex;
|
|
std::set<std::uint32_t> reserved_hashes;
|
|
}
|
|
bool reserved_cosmetic(std::string_view asset) {
|
|
const auto& catalogs = content_cache::catalogs();
|
|
if (!catalogs.available || asset.empty()) return false;
|
|
std::string folded(asset);
|
|
for (auto& letter : folded) if (letter >= 'A' && letter <= 'Z') letter = static_cast<char>(letter + ('a' - 'A'));
|
|
return catalogs.reserved(folded);
|
|
}
|
|
bool reserved_cosmetic_hash(std::uint32_t hash) {
|
|
std::lock_guard lock(reserved_hash_mutex);
|
|
return hash && reserved_hashes.contains(hash);
|
|
}
|
|
bool refresh_cosmetic_catalog() {
|
|
auto& s = local_runtime(); auto& c = cosmetic_runtime();
|
|
std::uintptr_t manager{}, vtable{}, buckets{};
|
|
std::uint32_t bucket_count{}, count{};
|
|
std::uint8_t ready{};
|
|
if (!read(s.base + addr::engine::cosmetics_manager, manager) || !manager || !read(manager, vtable) ||
|
|
vtable != s.base + addr::engine::cosmetics_manager_vtable || !read(manager + 0xa8, ready) || ready != 1 ||
|
|
!read(manager + 0x30, buckets) || !read(manager + 0x38, bucket_count) ||
|
|
!read(manager + 0x3c, count) || !count || count > 8192 || !bucket_count || bucket_count > 16384)
|
|
return false;
|
|
if (c.item_manager == manager && c.item_count == count && !c.items.empty()) return true;
|
|
std::map<std::string, CosmeticItemInfo, std::less<>> items;
|
|
std::set<std::uintptr_t> seen;
|
|
for (std::uint32_t bucket = 0; bucket < bucket_count; ++bucket) {
|
|
std::uintptr_t node{};
|
|
if (!read(buckets + bucket * 8ULL, node)) return false;
|
|
while (node) {
|
|
if (seen.size() >= count || !seen.insert(node).second) return false;
|
|
std::uintptr_t asset{}, key_ptr{}, categories{};
|
|
std::uint32_t hash{}, asset_hash{}, category_count{};
|
|
if (!read(node, hash) || !read(node + 8, asset)) return false;
|
|
asset &= ~std::uintptr_t{4};
|
|
std::string key;
|
|
if (!read(asset + 0x38, key_ptr) || !cosmetic_text(key_ptr, key) || key.empty() ||
|
|
!read(asset + 0x48, asset_hash) || hash != asset_hash || hash != cosmetic_hash(key) ||
|
|
!read(asset + 0x20, categories) || !cosmetic_array(categories, 8, 32, category_count)) return false;
|
|
CosmeticItemInfo info{hash, {}, {}, 0};
|
|
if (!read(asset + 0x4c, info.sort_priority)) return false;
|
|
for (std::uint32_t i = 0; i < category_count; ++i) {
|
|
std::uintptr_t category{}, name_ptr{}; std::uint32_t category_hash{}; std::string name;
|
|
if (!read(categories + i * 8ULL, category)) return false;
|
|
category &= ~std::uintptr_t{4};
|
|
if (!read(category + 0x28, name_ptr) || !cosmetic_text(name_ptr, name) ||
|
|
!read(category + 0x38, category_hash) || category_hash != cosmetic_hash(name)) return false;
|
|
info.build_kit |= name == "bkobjects";
|
|
if (info.category.empty()) info.category = name;
|
|
info.categories.push_back(category_hash);
|
|
}
|
|
if (info.build_kit) {
|
|
const auto start = key.find('_');
|
|
const auto end = start == std::string::npos ? start : key.find('_', start + 1);
|
|
if (start != std::string::npos && end != std::string::npos && end > start + 1)
|
|
info.category = key.substr(start + 1, end - start - 1);
|
|
}
|
|
if (!items.emplace(key, std::move(info)).second) return false;
|
|
if (!read(node + 0x10, node)) return false;
|
|
}
|
|
}
|
|
std::uintptr_t current{}, current_buckets{}; std::uint32_t current_count{};
|
|
if (seen.size() != count || !read(s.base + addr::engine::cosmetics_manager, current) || current != manager ||
|
|
!read(manager + 0x30, current_buckets) || current_buckets != buckets ||
|
|
!read(manager + 0x3c, current_count) || current_count != count) return false;
|
|
const auto& catalogs = content_cache::catalogs();
|
|
c.ownership_unavailable = !catalogs.available;
|
|
std::vector<std::string> keys, objects, reserved_keys, reserved_objects;
|
|
for (const auto& [key, info] : items) {
|
|
std::string folded = key;
|
|
for (auto& letter : folded) if (letter >= 'A' && letter <= 'Z') letter = static_cast<char>(letter + ('a' - 'A'));
|
|
const bool held = catalogs.reserved(folded);
|
|
(info.build_kit ? (held ? reserved_objects : objects) : (held ? reserved_keys : keys)).push_back(key);
|
|
}
|
|
if (!c.ownership_unavailable) {
|
|
s.store->seed_cosmetic_inventory(keys);
|
|
s.store->seed_object_inventory(objects);
|
|
s.store->reconcile_inventory(reserved_keys, reserved_objects);
|
|
}
|
|
{
|
|
std::set<std::uint32_t> hashes;
|
|
for (const auto& [key, info] : items)
|
|
if (reserved_cosmetic(key)) hashes.insert(info.hash);
|
|
std::lock_guard lock(reserved_hash_mutex);
|
|
reserved_hashes = std::move(hashes);
|
|
}
|
|
c.items = std::move(items); c.item_count = count; c.item_manager = manager;
|
|
++c.items_generation;
|
|
std::ostringstream event;
|
|
event << "{\"event\":\"local_cosmetic_catalog\",\"installed\":" << keys.size()
|
|
<< ",\"objects\":" << objects.size() << '}';
|
|
dingosdk::logging::event(dingosdk::logging::Channel::customization, event.str().c_str());
|
|
return true;
|
|
}
|
|
|
|
bool read_cosmetic_recipes(const void* vector, profile::CosmeticLoadout& result,
|
|
std::vector<CosmeticNativeRecipe>& native) {
|
|
std::uintptr_t begin{}, end{}, capacity{};
|
|
const auto address = reinterpret_cast<std::uintptr_t>(vector);
|
|
if (!read(address, begin) || !read(address + 8, end) || !read(address + 16, capacity) ||
|
|
begin < 0x10000 || begin > end || end > capacity || (end - begin) % 0x28 ||
|
|
!(end - begin) || (end - begin) / 0x28 > 16 || (capacity - begin) / 0x28 > 16) return false;
|
|
for (auto p = begin; p < end; p += 0x28) {
|
|
CosmeticNativeRecipe row{}; profile::CosmeticRecipe recipe;
|
|
if (!read(p, row) || !row.resource ||
|
|
!read(row.resource + 0x38, recipe.template_key) || !read(row.resource + 0x3c, recipe.template_version) ||
|
|
!cosmetic_words(reinterpret_cast<std::uintptr_t>(row.scalars), 512, recipe.scalar_bits)) return false;
|
|
std::uint32_t count{}, masks{};
|
|
if (!cosmetic_array(reinterpret_cast<std::uintptr_t>(row.items), 0x18, 256, count) ||
|
|
!cosmetic_array(reinterpret_cast<std::uintptr_t>(row.transient_mask), 4, 512, masks)) return false;
|
|
for (std::uint32_t i = 0; i < count; ++i) {
|
|
CosmeticNativeItem item{}; profile::CosmeticSlot slot;
|
|
if (!read(reinterpret_cast<std::uintptr_t>(row.items) + i * 0x18ULL, item) ||
|
|
!cosmetic_text(reinterpret_cast<std::uintptr_t>(item.asset), slot.asset) ||
|
|
!cosmetic_words(reinterpret_cast<std::uintptr_t>(item.parameters), 128, slot.parameter_bits)) return false;
|
|
if (slot.asset.empty() && item.hash) {
|
|
for (const auto& [key, info] : cosmetic_runtime().items)
|
|
if (info.hash == item.hash) { slot.asset = key; break; }
|
|
}
|
|
if (item.hash != cosmetic_hash(slot.asset)) return false;
|
|
slot.slot = item.slot; recipe.items.push_back(std::move(slot));
|
|
}
|
|
result.recipes.push_back(std::move(recipe)); native.push_back(row);
|
|
}
|
|
(void)profile::encode_loadout(result);
|
|
return true;
|
|
}
|
|
|
|
bool cosmetic_slot_categories(std::uintptr_t resource, std::map<std::uint32_t, std::uint32_t>& result) {
|
|
// RecipeTemplate.Slots is a 24-byte array (port name, slot asset, save flag).
|
|
// RecipeSlotAsset identifies its slot separately from its accepted Category.
|
|
// Tattoos, gestures, stickers and several color/right-side slots share categories.
|
|
std::uintptr_t slots{}; std::uint32_t count{};
|
|
if (!read(resource + 0x28, slots) || !cosmetic_array(slots, 24, 256, count)) return false;
|
|
for (std::uint32_t i = 0; i < count; ++i) {
|
|
std::uintptr_t slot{}, type{}, name{}, category{}, category_name{};
|
|
std::uint32_t hash{}, category_hash{}; std::string text, category_text;
|
|
if (!read(slots + i * 24ULL + 8, slot)) return false;
|
|
slot &= ~std::uintptr_t{4};
|
|
if (!read(slot + 8, type) || type != local_runtime().base + addr::engine::cosmetic_slot_type ||
|
|
!read(slot + 0x20, name) || !cosmetic_text(name, text) || text.empty() ||
|
|
!read(slot + 0x4c, hash) || hash != cosmetic_hash(text) ||
|
|
!read(slot + 0x30, category)) return false;
|
|
category &= ~std::uintptr_t{4};
|
|
if (!read(category + 0x28, category_name) || !cosmetic_text(category_name, category_text) || category_text.empty() ||
|
|
!read(category + 0x38, category_hash) || category_hash != cosmetic_hash(category_text) ||
|
|
!result.emplace(hash, category_hash).second) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool complete_starter_recipes(const profile::CosmeticLoadout& value) {
|
|
if (value.recipes.size() != starter_templates.size()) return false;
|
|
for (std::size_t i = 0; i < starter_templates.size(); ++i)
|
|
if (value.recipes[i].template_key != starter_templates[i].key ||
|
|
value.recipes[i].template_version != starter_templates[i].version || value.recipes[i].items.empty()) return false;
|
|
return true;
|
|
}
|
|
|
|
bool initialize_missing_starter(const std::vector<CosmeticNativeRecipe>& recipes) {
|
|
auto& s = local_runtime(); auto& c = cosmetic_runtime();
|
|
if (!c.initialize_starter || recipes.size() != starter_templates.size()) return false;
|
|
std::uintptr_t manager{}, categories{}, binding{}, model{};
|
|
std::uint32_t state{}, count{};
|
|
if (!read(s.base + addr::engine::loadout_manager, manager) || !manager || !read(manager, state) || state != 0 ||
|
|
!read(manager + 8, categories) || !cosmetic_array(categories, 16, 64, count) || count ||
|
|
!read(manager + 0x198, model) || !model || !read(manager + 0x1b0, binding) || !binding) return false;
|
|
// On a new offline profile the pre-spawn configuration may never arrive.
|
|
// Borrow the complete authored character setup from InitRecipeTemplates,
|
|
// never the account-wide RIP Card callback. Configure the native manager, which
|
|
// copies the definitions and continues its normal selection state machine.
|
|
struct Definition { std::uintptr_t resource; const std::uintptr_t* presets; };
|
|
static_assert(sizeof(Definition) == 16);
|
|
CosmeticBorrowedArray<std::uintptr_t> no_presets(0);
|
|
CosmeticBorrowedArray<Definition> definitions(recipes.size());
|
|
std::set<std::uintptr_t> unique;
|
|
for (std::size_t i = 0; i < recipes.size(); ++i) {
|
|
const auto resource = recipes[i].resource;
|
|
std::uintptr_t type{}, name{}; std::uint32_t key{}, version{}; std::string text;
|
|
if (!read(resource + 8, type) || type != s.base + addr::local_customization::recipe_template_type || !unique.insert(resource).second ||
|
|
!read(resource + 0x20, name) || !cosmetic_text(name, text) || text != starter_templates[i].name ||
|
|
!read(resource + 0x38, key) || key != starter_templates[i].key ||
|
|
!read(resource + 0x3c, version) || version != starter_templates[i].version) return false;
|
|
definitions.data()[i] = {resource, no_presets.data()};
|
|
}
|
|
const auto entries = definitions.data(); const std::uintptr_t no_creation_preset{};
|
|
c.initialize_starter(false, &entries, &no_creation_preset);
|
|
if (!read(manager + 8, categories) || !cosmetic_array(categories, 16, 64, count) || count != recipes.size()) return false;
|
|
dingosdk::logging::event(dingosdk::logging::Channel::customization, dingosdk::Json{{"event", "local_starter_profile_initialized"}, {"templates", count}}.dump().c_str());
|
|
return true;
|
|
}
|
|
|
|
void initialize_starter_from_expression(std::uintptr_t vm, std::uint32_t pc) noexcept {
|
|
auto& s = local_runtime();
|
|
if (pc || !s.active.load(std::memory_order_acquire)) return;
|
|
PreserveError preserve;
|
|
try {
|
|
std::uintptr_t instance{}, resource{}, current{}, constants{};
|
|
std::uint32_t key{}; std::array<std::uint32_t, 10> layout{};
|
|
// Exact installed InitRecipeTemplates expression. All three assets are
|
|
// live in its constant page; the native setup copies them synchronously.
|
|
// Every expression the game starts passes here: the live VM's fields are
|
|
// peeked, not read with a system call each (profiled 2026-10-01).
|
|
if (!memory::peek(vm + 0x38, current) || !memory::peek(current + 0x10, key) || key != 0x6ba0e33e ||
|
|
!read(vm + 0x30, instance) || !read(instance, resource) || current != resource ||
|
|
!read(resource + 0x20, layout) ||
|
|
layout != std::array<std::uint32_t, 10>{48,232,10,123,3,0,1,10,0,0} ||
|
|
!read(instance + 48, constants) || !constants) return;
|
|
std::lock_guard lock(s.native_mutex);
|
|
std::vector<CosmeticNativeRecipe> recipes(3);
|
|
for (std::size_t i = 0; i < recipes.size(); ++i) {
|
|
if (!read(constants + 8 + i * 8, recipes[i].resource)) return;
|
|
recipes[i].resource &= ~std::uintptr_t{4};
|
|
}
|
|
const bool initialized = initialize_missing_starter(recipes);
|
|
if (cosmetic_runtime().diagnostics.insert("starter_template_expression").second)
|
|
dingosdk::logging::event(dingosdk::logging::Channel::customization, dingosdk::Json{{"event", "local_starter_template_expression"}, {"initialized", initialized}}.dump().c_str());
|
|
} catch (...) { cosmetic_diagnostic("starter", "expression_setup_failed"); }
|
|
}
|
|
|
|
bool recover_card_only_preset(std::string_view id, const profile::CosmeticLoadout& saved,
|
|
const profile::CosmeticLoadout& defaults) {
|
|
// The b227/4c2b candidates could save a RIP Card as a skater preset. Only
|
|
// that exact shape is recoverable; ordinary incompatible outfits stay protected.
|
|
if (id == "profile" || !complete_starter_recipes(defaults)) return false;
|
|
try { profile::validate_player_card(saved); } catch (...) { return false; }
|
|
auto& s = local_runtime();
|
|
if (!s.store->player_card()) s.store->save_player_card(saved);
|
|
cosmetic_runtime().blocked_loadouts.erase(std::string(id));
|
|
cosmetic_diagnostic("load", "card_only_preset_recovered", id);
|
|
// Leave the invalid preset on disk until a complete native outfit is saved.
|
|
return true;
|
|
}
|
|
|
|
bool load_cosmetic_hook(const void* wrapper, void* destination) {
|
|
auto& s = local_runtime();
|
|
if (!s.active.load(std::memory_order_acquire)) return s.load_cosmetic(wrapper, destination);
|
|
PreserveError preserve;
|
|
std::lock_guard lock(s.native_mutex);
|
|
std::string id;
|
|
try {
|
|
if (!identifier(wrapper, id)) return cosmetic_diagnostic("load", "invalid_preset_id");
|
|
const auto saved = s.store->cosmetic_loadout(id);
|
|
if (!saved) {
|
|
profile::CosmeticLoadout starter; std::vector<CosmeticNativeRecipe> native;
|
|
const bool valid = read_cosmetic_recipes(destination, starter, native);
|
|
if (cosmetic_runtime().diagnostics.insert("starter_recipe:" + id).second) {
|
|
dingosdk::Json counts = dingosdk::Json::array();
|
|
if (valid) for (const auto& recipe : starter.recipes)
|
|
counts.push_back({{"template", recipe.template_key}, {"version", recipe.template_version},
|
|
{"items", recipe.items.size()}, {"scalars", recipe.scalar_bits.size()}});
|
|
dingosdk::logging::event(dingosdk::logging::Channel::customization, dingosdk::Json{{"event", "local_starter_recipe_observed"}, {"preset", id},
|
|
{"valid", valid}, {"recipes", counts}}.dump().c_str());
|
|
}
|
|
return cosmetic_diagnostic("load", "no_saved_loadout", id);
|
|
}
|
|
auto& c = cosmetic_runtime();
|
|
c.blocked_loadouts.insert(id);
|
|
profile::CosmeticLoadout defaults; std::vector<CosmeticNativeRecipe> native;
|
|
if (!read_cosmetic_recipes(destination, defaults, native)) return cosmetic_diagnostic("load", "invalid_native_recipe", id);
|
|
if (recover_card_only_preset(id, *saved, defaults)) return false;
|
|
// The cosmetics manager cannot be read for the first seconds of a level
|
|
// load, which is when the game asks for every slot up to the selected
|
|
// one. The catalog read before the load says the same thing (what is
|
|
// installed does not change with the level), so the outfit is checked
|
|
// against that one. Only with no catalog read yet this session is the
|
|
// load refused; the selection that asks is held back until one is
|
|
// (local_customization_outfits_loadable).
|
|
if (!refresh_cosmetic_catalog() && c.items.empty()) return cosmetic_diagnostic("load", "catalog_not_ready", id);
|
|
if (saved->recipes.size() != defaults.recipes.size()) return cosmetic_diagnostic("load", "recipe_count_mismatch", id);
|
|
const auto snapshot_shared = s.store->shared_snapshot();
|
|
const auto& snapshot = *snapshot_shared;
|
|
std::vector<CosmeticBorrowedArray<std::uint32_t>> words;
|
|
std::vector<CosmeticBorrowedArray<CosmeticNativeItem>> item_arrays;
|
|
words.reserve(16 * 257); item_arrays.reserve(16);
|
|
std::vector<CosmeticRuntime::HeldSlot> held;
|
|
for (std::size_t i = 0; i < saved->recipes.size(); ++i) {
|
|
const auto& recipe = saved->recipes[i]; const auto& expected = defaults.recipes[i];
|
|
if (recipe.template_key != expected.template_key || recipe.template_version != expected.template_version ||
|
|
recipe.scalar_bits.size() != expected.scalar_bits.size() || recipe.items.size() != expected.items.size())
|
|
return cosmetic_diagnostic("load", "recipe_template_mismatch", id);
|
|
std::map<std::uint32_t, std::uint32_t> slot_categories;
|
|
if (!cosmetic_slot_categories(native[i].resource, slot_categories))
|
|
return cosmetic_diagnostic("load", "invalid_slot_definitions", id);
|
|
words.emplace_back(recipe.scalar_bits.size());
|
|
std::copy(recipe.scalar_bits.begin(), recipe.scalar_bits.end(), words.back().data());
|
|
native[i].scalars = words.back().data();
|
|
item_arrays.emplace_back(recipe.items.size());
|
|
for (std::size_t j = 0; j < recipe.items.size(); ++j) {
|
|
const auto* slot = &recipe.items[j];
|
|
if (slot->slot != expected.items[j].slot) return cosmetic_diagnostic("load", "slot_mismatch", id);
|
|
if (!slot->asset.empty()) {
|
|
// An item that is no longer installed (a removed mod, a catalog
|
|
// change) or no longer fits its slot falls back to the slot's
|
|
// default. Rejecting the whole outfit would also block every
|
|
// later save to this preset, so outfits could never be fixed.
|
|
const auto found = c.items.find(slot->asset);
|
|
const auto category = slot_categories.find(slot->slot);
|
|
const char* problem = found == c.items.end() ? "is no longer installed" :
|
|
category == slot_categories.end() ||
|
|
std::find(found->second.categories.begin(), found->second.categories.end(), category->second) ==
|
|
found->second.categories.end() ? "no longer fits its slot" : nullptr;
|
|
if (problem) {
|
|
dingosdk::logging::log(dingosdk::logging::Level::warning, dingosdk::logging::Channel::customization,
|
|
"Saved outfit \"{}\": item {} {}; showing the default for that slot and keeping the item "
|
|
"saved for when it is installed again.", id, slot->asset, problem);
|
|
held.push_back({i, *slot, expected.items[j].asset});
|
|
slot = &expected.items[j];
|
|
} else {
|
|
const auto& inventory = snapshot.customization.value("inventory", dingosdk::Json::object());
|
|
if (inventory.contains(slot->asset) && !inventory.at(slot->asset).get<bool>())
|
|
slot = &expected.items[j];
|
|
}
|
|
}
|
|
words.emplace_back(slot->parameter_bits.size());
|
|
std::copy(slot->parameter_bits.begin(), slot->parameter_bits.end(), words.back().data());
|
|
item_arrays.back().data()[j] = {slot->asset.c_str(), words.back().data(), cosmetic_hash(slot->asset), slot->slot};
|
|
}
|
|
native[i].items = item_arrays.back().data();
|
|
}
|
|
s.copy_cosmetic(destination, native.data(), native.data() + native.size());
|
|
c.blocked_loadouts.erase(id);
|
|
if (held.empty()) c.held_slots.erase(id);
|
|
else c.held_slots[id] = std::move(held);
|
|
dingosdk::logging::event(dingosdk::logging::Channel::customization, "{\"event\":\"local_cosmetic_loadout_loaded\"}");
|
|
return true;
|
|
} catch (...) {
|
|
if (!id.empty()) cosmetic_runtime().blocked_loadouts.insert(id);
|
|
dingosdk::logging::event(dingosdk::logging::Channel::customization, "{\"event\":\"local_cosmetic_loadout_load_failed\"}");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
void save_cosmetic_hook(std::uintptr_t manager, void* record, const char* raw_id, const char* reason, bool changed_only) {
|
|
auto& s = local_runtime();
|
|
if (!s.active.load(std::memory_order_acquire)) { s.save_cosmetic(manager, record, raw_id, reason, changed_only); return; }
|
|
PreserveError preserve;
|
|
std::lock_guard lock(s.native_mutex);
|
|
try {
|
|
cosmetic_diagnostic("save", "callback_entered");
|
|
std::uintptr_t current{}, begin{}, end{}; std::string id;
|
|
const auto p = reinterpret_cast<std::uintptr_t>(record);
|
|
if (!read(s.base + addr::engine::loadout_manager, current) || current != manager || !manager ||
|
|
!read(manager + 0x18, begin) || !read(manager + 0x20, end) || begin > end ||
|
|
(end - begin) % 0x48 || (end - begin) / 0x48 > 10 || p < begin || p >= end || (p - begin) % 0x48) {
|
|
cosmetic_diagnostic("save", "invalid_manager_or_record"); return;
|
|
}
|
|
if (!cosmetic_text(reinterpret_cast<std::uintptr_t>(raw_id), id) || id.empty()) {
|
|
cosmetic_diagnostic("save", "invalid_preset_id"); return;
|
|
}
|
|
if (cosmetic_runtime().blocked_loadouts.contains(id)) {
|
|
cosmetic_diagnostic("save", "previous_load_rejected", id); return;
|
|
}
|
|
profile::CosmeticLoadout value; std::vector<CosmeticNativeRecipe> native;
|
|
if (!read_cosmetic_recipes(record, value, native)) {
|
|
cosmetic_diagnostic("save", "invalid_native_recipe", id); return;
|
|
}
|
|
if (value.recipes.size() == 1 && value.recipes.front().template_key == 2169419386U) {
|
|
cosmetic_diagnostic("save", "card_is_not_skater_preset", id); return;
|
|
}
|
|
// Keep items from costume mods that are not installed right now: a slot
|
|
// still showing the stand-in default saves the original item, while a
|
|
// slot the player changed saves the new choice and releases the hold.
|
|
if (const auto held = cosmetic_runtime().held_slots.find(id); held != cosmetic_runtime().held_slots.end()) {
|
|
std::erase_if(held->second, [&](const CosmeticRuntime::HeldSlot& h) {
|
|
if (h.recipe >= value.recipes.size()) return true;
|
|
for (auto& item : value.recipes[h.recipe].items) {
|
|
if (item.slot != h.saved.slot) continue;
|
|
if (item.asset != h.fallback) return true;
|
|
item = h.saved;
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
if (held->second.empty()) cosmetic_runtime().held_slots.erase(held);
|
|
}
|
|
const auto previous = s.store->cosmetic_loadout(id);
|
|
s.store->save_cosmetic_loadout(id, value);
|
|
// The native success callback copies this same vector to the baseline.
|
|
// Only acknowledge after the JSON transaction has completed.
|
|
std::uintptr_t first{}, last{};
|
|
if (!read(p, first) || !read(p + 8, last)) {
|
|
cosmetic_diagnostic("save", "saved_but_baseline_unavailable", id); return;
|
|
}
|
|
s.copy_cosmetic(reinterpret_cast<void*>(p + 0x20), reinterpret_cast<const void*>(first), reinterpret_cast<const void*>(last));
|
|
if (!previous || *previous != value) dingosdk::logging::event(dingosdk::logging::Channel::customization, "{\"event\":\"local_cosmetic_loadout_saved\"}");
|
|
else cosmetic_diagnostic("save", "unchanged", id);
|
|
} catch (...) { dingosdk::logging::event(dingosdk::logging::Channel::customization, "{\"event\":\"local_cosmetic_loadout_save_failed\"}"); }
|
|
}
|
|
} |