From 96d44fc8a816955fc4998df7c5f94652602747c7 Mon Sep 17 00:00:00 2001 From: wishluna <173279213+wishluna@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:01:20 -0500 Subject: [PATCH] Load a saved outfit whose slots differ from the template's A mod that adds slots to a template made every outfit saved before it fail to load (recipe_template_mismatch / slot_mismatch), and switching the mod off did the same to every outfit saved with it. The preset was blocked, so the player saw the standard skater until the profile database was edited by hand. Match the saved outfit to the template slot by slot instead of by position. A template slot the outfit has no item for starts on the game's default. A saved slot the template does not have is held, like an item from a costume mod that is not installed, and a save writes it back beside the template's slots. Template key, template version, scalar count and recipe count still have to match. Co-Authored-By: Claude Fable 5.1 --- .../local_customization_runtime.cpp | 45 ++++++++++++++----- .../local_customization_runtime.h | 4 +- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/Extension/Customization/local_customization_runtime.cpp b/Extension/Customization/local_customization_runtime.cpp index 26fb10d..3ff43b4 100644 --- a/Extension/Customization/local_customization_runtime.cpp +++ b/Extension/Customization/local_customization_runtime.cpp @@ -342,7 +342,7 @@ bool load_cosmetic_hook(const void* wrapper, void* destination) { 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()) + recipe.scalar_bits.size() != expected.scalar_bits.size()) return cosmetic_diagnostic("load", "recipe_template_mismatch", id); std::map slot_categories; if (!cosmetic_slot_categories(native[i].resource, slot_categories)) @@ -350,11 +350,23 @@ bool load_cosmetic_hook(const void* wrapper, void* destination) { 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()) { + // The template says which slots there are and in what order; the saved + // outfit is matched to it slot by slot. A mod that adds slots to a + // template (a wheel slot per wheel on the board) changes that list + // under every outfit saved before it, and again when it is switched + // off: a slot the outfit has no item for starts on its default, and a + // saved slot the template no longer has is held, so the next save + // keeps it for when the slot is back. + std::map saved_slots; + for (const auto& item : recipe.items) saved_slots.emplace(item.slot, &item); + std::size_t added{}; + item_arrays.emplace_back(expected.items.size()); + for (std::size_t j = 0; j < expected.items.size(); ++j) { + const auto match = saved_slots.find(expected.items[j].slot); + const auto* slot = match == saved_slots.end() ? &expected.items[j] : match->second; + if (match == saved_slots.end()) ++added; + else saved_slots.erase(match); + if (slot != &expected.items[j] && !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 @@ -379,8 +391,15 @@ bool load_cosmetic_hook(const void* wrapper, void* destination) { } 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}; + item_arrays.back().data()[j] = {slot->asset.c_str(), words.back().data(), cosmetic_hash(slot->asset), + expected.items[j].slot}; } + for (const auto& [hash, item] : saved_slots) held.push_back({i, *item, {}}); + if (added || !saved_slots.empty()) + dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::customization, + "Saved outfit \"{}\": its slots differ from the game's (a mod that adds or removes slots): {} " + "new slot(s) start on their default, {} saved slot(s) the game does not have now are kept.", + id, added, saved_slots.size()); native[i].items = item_arrays.back().data(); } s.copy_cosmetic(destination, native.data(), native.data() + native.size()); @@ -423,9 +442,10 @@ void save_cosmetic_hook(std::uintptr_t manager, void* record, const char* raw_id 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. + // Keep items from costume mods that are not installed right now, and + // whole slots a mod added that is switched off: 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; @@ -435,7 +455,10 @@ void save_cosmetic_hook(std::uintptr_t manager, void* record, const char* raw_id item = h.saved; return false; } - return true; + // The game has no such slot now (the mod that added it is off): + // the saved item rides along until the slot is back. + value.recipes[h.recipe].items.push_back(h.saved); + return false; }); if (held->second.empty()) cosmetic_runtime().held_slots.erase(held); } diff --git a/Extension/Customization/local_customization_runtime.h b/Extension/Customization/local_customization_runtime.h index 609794b..5e4c144 100644 --- a/Extension/Customization/local_customization_runtime.h +++ b/Extension/Customization/local_customization_runtime.h @@ -21,7 +21,9 @@ struct CosmeticRuntime { std::set blocked_loadouts; // Saved outfit items that were not installed when their preset loaded (a // disabled or removed costume mod). The slot shows its default this session; - // saves keep the original item unless the player changes that slot. + // saves keep the original item unless the player changes that slot. Also + // saved slots the template did not have then (a mod that adds slots, + // switched off): those have no slot to show in, and saves carry them along. struct HeldSlot { std::size_t recipe{}; profile::CosmeticSlot saved; std::string fallback; }; std::map, std::less<>> held_slots; std::set diagnostics;