Keep every mod's cosmetics when two presets share a short name

With several cosmetic mods installed the game crashed as soon as an
equipped custom item came into view, and so on every launch after it
was equipped.
Cosmetic presets (Engine/Resource/bundle_ref_table.*)
- The character bundle-reference table says which bundle holds each
  preset, under the preset's full path and under its leaf name, one row
  per hash. Mods number their presets alike in different folders
  (cust_tops/1_ap, cust_bottoms/1_ap), so two mods can share a leaf
  name. Adding the second preset threw "collides with an existing
  lookup", the merge fell back to the highest-priority mod's copy of
  the table, and every other cosmetic mod's presets were unregistered.
  The game then built the skater with a preset it could not find and
  called through a bad pointer.
- A preset whose leaf name is taken is now added under its full path
  only; the leaf name stays with the preset that had it. Only a clash
  of full paths still fails. MergedTable::shadowed lists each such
  preset with the one that holds its name.
Merge log (Engine/Vfs/mod_merge_combine.cpp)
- Each mod with shadowed presets gets a line naming how many and an
  example pair, so an item that shows the wrong preset can be traced to
  the mods that clash.
Checked against 39 installed mods, 15 of them cosmetic: the table
combines 274 presets where it used to keep one mod's, all reachable by
full path and 267 by leaf name, with the game's own 6264 rows unchanged.
The seven left without a leaf row are a naming clash between the mods
themselves (two Carhartt packs, a Zero tee and deck); whether the game
finds those by full path is not known.
bundle_ref_table_tests takes an optional second mod and checks that two
mods' presets are all added, in either order, and that a shared leaf
name is reported with the preset that holds it.
This commit is contained in:
AyeZeeBB committed 2026-10-03 21:43:24 -05:00
1 parent d84b32fc17
commit a30a8384f3
7 files changed
+126 -29

No files matched your search

@@ -298,6 +298,15 @@ bool recover_card_only_preset(std::string_view id, const profile::CosmeticLoadou
return true;
}
// The loadout manager's record array, or false when it cannot be read. The
// same shape the save hook demands of a record it is handed.
bool loadout_array(std::uintptr_t& manager, std::uintptr_t& begin, std::uintptr_t& end) {
auto& s = local_runtime();
return read(s.base + addr::engine::loadout_manager, manager) && manager &&
read(manager + 0x18, begin) && read(manager + 0x20, end) && begin <= end &&
(end - begin) % 0x48 == 0 && (end - begin) / 0x48 <= 10;
}
// A load, taking the preset id instead of the game's wrapper so that
// retry_pending_cosmetic_loads can run it again. The caller holds native_mutex.
bool apply_saved_loadout(const std::string& id, void* destination) {
@@ -325,8 +334,10 @@ bool apply_saved_loadout(const std::string& id, void* destination) {
if (!refresh_cosmetic_catalog()) {
// The cosmetics manager is not ready this early in a level load.
// The preset stays blocked, so nothing is written over the outfit
// on disk, and the pump applies this record once it is ready.
c.pending_loads[id] = destination;
// on disk, and the pump applies this record once it is ready --
// but only while this is still the array the record came from.
CosmeticRuntime::PendingLoad pending{destination};
if (loadout_array(pending.manager, pending.begin, pending.end)) c.pending_loads[id] = pending;
return cosmetic_diagnostic("load", "catalog_not_ready", id);
}
if (saved->recipes.size() != defaults.recipes.size()) return cosmetic_diagnostic("load", "recipe_count_mismatch", id);
@@ -411,24 +422,23 @@ bool load_cosmetic_hook(const void* wrapper, void* destination) {
void retry_pending_cosmetic_loads() {
auto& c = cosmetic_runtime();
if (c.pending_loads.empty()) return;
auto& s = local_runtime();
// The records belong to the loadout manager's array, which can be replaced
// between the refused load and now. Check each pointer against the live
// array exactly as a save does, and drop one that no longer belongs rather
// than writing through it.
std::uintptr_t manager{}, begin{}, end{};
if (!read(s.base + addr::engine::loadout_manager, manager) || !manager ||
!read(manager + 0x18, begin) || !read(manager + 0x20, end) || begin > end ||
(end - begin) % 0x48 || (end - begin) / 0x48 > 10) return;
// Unreadable now: keep the records and come back, rather than losing them.
if (!loadout_array(manager, begin, end)) return;
auto pending = std::move(c.pending_loads);
c.pending_loads.clear();
for (const auto& [id, destination] : pending) {
const auto p = reinterpret_cast<std::uintptr_t>(destination);
if (!destination || p < begin || p >= end || (p - begin) % 0x48) {
for (const auto& [id, load] : pending) {
const auto p = reinterpret_cast<std::uintptr_t>(load.destination);
// The same array, the same bounds, and the record still inside it on
// its stride. Anything else and this address may now hold another
// preset: give up on the outfit rather than write it over a different
// saved character.
if (!load.destination || manager != load.manager || begin != load.begin || end != load.end ||
p < begin || p >= end || (p - begin) % 0x48) {
cosmetic_diagnostic("load", "retry_record_moved", id);
continue;
}
if (apply_saved_loadout(id, destination))
if (apply_saved_loadout(id, load.destination))
cosmetic_diagnostic("load", "retried_after_catalog_ready", id);
}
}