Просмотр исходного кода

fix(content): harden installed inventory extraction

Read and validate the installed inline bucket/equipment-slot table instead of inferring routing from item rows. Persist the resolved equipment slots, reject malformed or duplicate mappings, and use indexed bucket and socket-member lookups at runtime.

Retain class-qualified art arrangements and complete item-detail/socket metadata, add bounded extraction diagnostics, and report item-domain success independently from longer scenario and hash-name passes.

Keep package extraction live rather than suspending threads during parallel reader startup, release transient cache banks after persistence, and bump the cache format for the expanded detail and bucket records.
Thomas Shields 3 недель назад
Родитель
Сommit
d954d25955
23 измененных файлов с 467 добавлено и 181 удалено
  1. 3 2
      Sunrise/src/client/content/investment/internal.h
  2. 6 56
      Sunrise/src/client/content/investment/investment_refresh.cpp
  3. 1 1
      Sunrise/src/client/content/investment/worker/investment_refresh_worker.cpp
  4. 26 0
      Sunrise/src/client/content/items/packages/internal.h
  5. 65 0
      Sunrise/src/client/content/items/packages/package_build_report.cpp
  6. 30 33
      Sunrise/src/client/content/items/packages/package_detail_build.cpp
  7. 8 2
      Sunrise/src/client/content/items/packages/package_item_build.cpp
  8. 8 4
      Sunrise/src/client/content/items/packages/package_item_rows.cpp
  9. 127 8
      Sunrise/src/client/content/items/packages/package_root_tables.cpp
  10. 46 11
      Sunrise/src/client/content/items/packages/package_subclass_build.cpp
  11. 12 0
      Sunrise/src/middleware/content/packages/tables/definition_index_table.h
  12. 22 7
      Sunrise/src/middleware/content/packages/tables/item_appearance_reader.cpp
  13. 4 2
      Sunrise/src/middleware/content/packages/tables/items.h
  14. 3 0
      Sunrise/src/state/build_data/build_data_runtime.cpp
  15. 6 2
      Sunrise/src/state/build_data/cache/records/cache_record_codec.cpp
  16. 6 4
      Sunrise/src/state/build_data/cache/records/format.h
  17. 6 2
      Sunrise/src/state/build_data/inventory/buckets/definition.h
  18. 24 2
      Sunrise/src/state/build_data/inventory/buckets/inventory_bucket_catalog.cpp
  19. 12 2
      Sunrise/src/state/build_data/items/details/definition.h
  20. 17 4
      Sunrise/src/state/build_data/items/socket_plugs/socket_plug_catalog.cpp
  21. 25 36
      Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp
  22. 3 0
      Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h
  23. 7 3
      Sunrise/src/state/build_data/table.h

+ 3 - 2
Sunrise/src/client/content/investment/internal.h

@@ -5,7 +5,8 @@
 
 namespace sunrise::client::content::investment {
 
-/** @return True when the next refresh slice must hold the process for a package sweep. */
-[[nodiscard]] bool requires_process_freeze() noexcept;
+/** @return True when the next refresh slice needs one presented overlay before its package sweep.
+ */
+[[nodiscard]] bool requires_package_sweep() noexcept;
 
 } // namespace sunrise::client::content::investment

+ 6 - 56
Sunrise/src/client/content/investment/investment_refresh.cpp

@@ -1,15 +1,9 @@
 #include <Windows.h>
 
-#include <array>
-#include <cstdio>
-#include <string_view>
-
-#include "../../../core/logging/log.h"
 #include "../../../core/ui/busy/busy.h"
 #include "../../../middleware/content/packages/reader/reader.h"
 #include "../../../state/build_data/runtime.h"
 #include "../../../state/runtime/runtime.h"
-#include "../../process/freeze/client_process_freeze.h"
 #include "../items/packages/build.h"
 #include "internal.h"
 #include "runtime.h"
@@ -19,9 +13,6 @@ namespace {
 
 SRWLOCK g_refreshLock{SRWLOCK_INIT};
 
-/** One line reports the freeze, so a run that could not hold the game is visible. */
-constexpr std::size_t kLineLimit = 96;
-
 /**
  * @return True when every persistent mapping domain is fully published.
  * The destination layouts and spawn sets belong here even though they are not equipment mappings.
@@ -43,34 +34,10 @@ constexpr std::size_t kLineLimit = 96;
            && state::build_data::investment_constants_ready();
 }
 
-/**
- * Reports the outcome of one freeze attempt.
- * @param frozen True when the game was held.
- * @param threadCount Threads that were suspended.
- */
-void report_freeze(bool frozen, std::size_t threadCount) noexcept {
-    std::array<char, kLineLimit> line{};
-    const int written = std::snprintf(line.data(),
-                                      line.size(),
-                                      "ev=extract stage=freeze result=%s threads=%zu",
-                                      frozen ? "ok" : "fail",
-                                      threadCount);
-    if (written <= 0) {
-        return;
-    }
-    const auto length = static_cast<std::size_t>(written) < line.size()
-                            ? static_cast<std::size_t>(written)
-                            : line.size() - 1;
-    // The pass runs in slices, so a working freeze reports at debug and only a failure is loud.
-    core::log::write(core::log::Channel::client,
-                     frozen ? core::log::Level::debug : core::log::Level::warn,
-                     std::string_view(line.data(), length));
-}
-
 } // namespace
 
-/** @return True when the next refresh slice must hold the process for a package sweep. */
-bool requires_process_freeze() noexcept {
+/** @return True when the next refresh slice needs a visible overlay for a package sweep. */
+bool requires_package_sweep() noexcept {
     return !state::build_data::item_definitions_ready() && items::packages::readable();
 }
 
@@ -91,37 +58,20 @@ bool refresh() noexcept {
     }
 
     AcquireSRWLockExclusive(&g_refreshLock);
-    // Only the item sweep holds the game, and only once the block keys exist. The destination and
-    // spawn-set passes after it are tens of thousands of tag reads over many slices, so the
-    // overlay covers the whole pass: without it the longest stall of the boot has nothing on
-    // screen.
-    const bool sweeping = requires_process_freeze();
-    process::freeze::Held held{};
-    bool frozen = false;
-    std::size_t heldThreads = 0;
-    if (sweeping) {
-        // The overlay reaches the screen before the freeze stops the frame loop. A held game
-        // cannot time its connection out, which a slow disk otherwise causes here.
-        core::ui::busy::begin(core::ui::busy::Task::contentExtraction);
-        frozen = process::freeze::hold(held);
-        heldThreads = held.count;
-    } else {
-        core::ui::busy::raise(core::ui::busy::Task::contentExtraction);
-    }
+    // The package pass creates parallel readers. Suspending the client while those threads start
+    // can block their DLL thread-attach work behind a suspended owner, so the visible preflight
+    // runs one frame early and extraction proceeds with the process live.
+    core::ui::busy::raise(core::ui::busy::Task::contentExtraction);
     // The package pass owns the item table and must not wait on runtime content lookups.
     (void)items::packages::build();
     const bool domainsReady = ready();
     const bool complete =
         domainsReady && state::ensure_profile_item_identities() && state::build_data::persist();
-    process::freeze::release(held);
     // The overlay ends with the work, not with the slice, so it spans every retry the pass needs.
     if (complete) {
         core::ui::busy::end(core::ui::busy::Task::contentExtraction);
     }
     ReleaseSRWLockExclusive(&g_refreshLock);
-    if (sweeping) {
-        report_freeze(frozen, heldThreads);
-    }
     return complete;
 }
 

+ 1 - 1
Sunrise/src/client/content/investment/worker/investment_refresh_worker.cpp

@@ -46,7 +46,7 @@ void service(std::uint64_t nowMilliseconds) noexcept {
     }
     g_nextEligible = nowMilliseconds + kRefreshIntervalMilliseconds;
 
-    if (sunrise::client::content::investment::requires_process_freeze()) {
+    if (sunrise::client::content::investment::requires_package_sweep()) {
         g_overlayPending = true;
         if (sunrise::core::ui::busy::raise_early(
                 sunrise::core::ui::busy::Task::contentExtraction)) {

+ 26 - 0
Sunrise/src/client/content/items/packages/internal.h

@@ -15,10 +15,12 @@
 #include "../../../../state/build_data/abilities/definition.h"
 #include "../../../../state/build_data/collectibles/collectible_catalog.h"
 #include "../../../../state/build_data/constants/definition.h"
+#include "../../../../state/build_data/inventory/buckets/definition.h"
 #include "../../../../state/build_data/items/details/definition.h"
 #include "../../../../state/build_data/items/item_catalog.h"
 #include "../../../../state/build_data/material_requirements/material_requirement_catalog.h"
 #include "../../../../state/build_data/progressions/definition.h"
+#include "../../../../state/build_data/runtime.h"
 
 namespace sunrise::client::content::items::packages {
 
@@ -62,6 +64,13 @@ struct Storage {
     std::vector<std::byte> plugSetTable{};
     /** Compact 0..3 special plug-category code of every dense installed item row. */
     std::array<std::uint8_t, state::build_data::items::kDefinitionCapacity> specialPlugCategories{};
+    /** Inventory routing rows held until the paired bucket-definition table is resolved. */
+    std::array<state::build_data::inventory::buckets::Descriptor,
+               state::build_data::inventory::buckets::kDescriptorCapacity>
+        bucketRows{};
+    std::size_t bucketCount{};
+    std::array<std::int8_t, state::build_data::inventory::buckets::kDescriptorCapacity>
+        equipmentSlotByBucket{};
     DetailRequests detailRequests{};
     std::array<std::uint16_t, kDetailCapacity> requestedDetailIndices{};
     std::unique_ptr<state::build_data::items::details::Definition[]> details{};
@@ -103,6 +112,9 @@ struct Storage {
  */
 [[nodiscard]] bool equippable(const tables::items::Row& row) noexcept;
 
+/** Publishes parsed inventory buckets after applying the extracted item-slot relation. */
+[[nodiscard]] bool publish_buckets(Storage& storage) noexcept;
+
 /**
  * Adds one definition index to the deduplicated requested set.
  * @param definitionIndex Native
@@ -252,6 +264,12 @@ void report_detail_failure(std::size_t slot, std::uint16_t definitionIndex) noex
 /** @param count Ability bucket rows the pass built, one per subclass and ability selection. */
 void report_ability_count(std::size_t count) noexcept;
 
+/** Reports a bounded number of exact subclass/ability extraction failures per process. */
+void report_ability_failure(const char* stage,
+                            std::size_t character,
+                            std::size_t first,
+                            std::size_t second) noexcept;
+
 /** Reports requested, retained, and skipped detail closure rows. */
 void report_detail_count(std::size_t requested, std::size_t built) noexcept;
 
@@ -261,6 +279,14 @@ void report_socket_plug_count(std::size_t rules,
                               std::size_t members,
                               std::size_t skipped) noexcept;
 
+/** Reports the validated installed bucket/equipment-slot coverage. */
+void report_bucket_equipment_mapping(std::size_t mappedSlots) noexcept;
+
+/** Reports the first rejected bucket-definition invariant in one process. */
+void report_bucket_equipment_failure(const char* stage,
+                                     std::size_t first,
+                                     std::size_t second) noexcept;
+
 /** Reports the pass outcome once. @param published Rows published, or zero on failure. */
 void report(std::size_t published, const char* reason) noexcept;
 

+ 65 - 0
Sunrise/src/client/content/items/packages/package_build_report.cpp

@@ -3,12 +3,15 @@
 #include <cstdio>
 
 #include "../../../../core/logging/log.h"
+#include "../../../../middleware/content/packages/tables/definition_index_table.h"
 #include "internal.h"
 
 namespace sunrise::client::content::items::packages {
 namespace {
 
 std::atomic<bool> g_reported{};
+std::atomic<bool> g_bucketFailureReported{};
+std::atomic<std::size_t> g_abilityFailureReports{};
 
 } // namespace
 
@@ -39,6 +42,31 @@ void report_ability_count(std::size_t count) noexcept {
     }
 }
 
+/** Reports the precise ability boundary without flooding the periodic extraction retry. */
+void report_ability_failure(const char* stage,
+                            std::size_t character,
+                            std::size_t first,
+                            std::size_t second) noexcept {
+    constexpr std::size_t kReportLimit = 12;
+    if (g_abilityFailureReports.fetch_add(1, std::memory_order_relaxed) >= kReportLimit) {
+        return;
+    }
+    std::array<char, 176> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=pkg stage=ability_failure reason=%s character=%zu "
+                                      "first=%zu second=%zu",
+                                      stage,
+                                      character,
+                                      first,
+                                      second);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
 /** Reports requested, retained, and skipped rows for the equippable-item detail closure. */
 void report_detail_count(std::size_t requested, std::size_t built) noexcept {
     std::array<char, 128> line{};
@@ -77,6 +105,43 @@ void report_socket_plug_count(std::size_t rules,
     }
 }
 
+/** Reports the installed bucket definition relation used by loadout resolution. */
+void report_bucket_equipment_mapping(std::size_t mappedSlots) noexcept {
+    std::array<char, 128> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=pkg stage=bucket_equipment result=ok rows=%zu mapped=%zu",
+                                      tables::kBucketDefinitionCount,
+                                      mappedSlots);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/** Reports one fail-closed bucket-definition rejection without flooding extraction retries. */
+void report_bucket_equipment_failure(const char* stage,
+                                     std::size_t first,
+                                     std::size_t second) noexcept {
+    if (g_bucketFailureReported.exchange(true, std::memory_order_relaxed)) {
+        return;
+    }
+    std::array<char, 160> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=pkg stage=bucket_equipment result=fail reason=%s "
+                                      "first=%zu second=%zu",
+                                      stage,
+                                      first,
+                                      second);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
 /** Reports the pass outcome once. @param published Rows published, or zero on failure. */
 void report(std::size_t published, const char* reason) noexcept {
     if (g_reported.exchange(true, std::memory_order_relaxed)) {

+ 30 - 33
Sunrise/src/client/content/items/packages/package_detail_build.cpp

@@ -2,7 +2,6 @@
 #include <array>
 #include <cstring>
 #include <optional>
-#include <utility>
 
 #include "../../../../state/account/account_state.h"
 #include "../../../../state/runtime/runtime.h"
@@ -13,38 +12,13 @@ namespace {
 
 namespace domain = state::build_data::items::details;
 
-/**
- * Fallback equipment slot for old definitions whose optional equipment block is absent.
- * The
- * installed definition's own slot wins when present; unlike a bucket, it can distinguish
- *
- * multiple native equipment positions that share one inventory bucket.
- */
-constexpr std::array<std::pair<std::uint8_t, std::int8_t>, 16> kEquipmentSlotOfBucket{{
-    {16, 0},
-    {3, 1},
-    {4, 2},
-    {5, 4},
-    {6, 5},
-    {7, 6},
-    {0, 7},
-    {1, 8},
-    {2, 9},
-    {10, 10},
-    {9, 11},
-    {8, 12},
-    {27, 13},
-    {41, 14},
-    {17, 15},
-    {47, 17},
-}};
-
 /** @param bucketId Inventory bucket. @return Its equipment slot, or none when not equippable. */
 [[nodiscard]] std::optional<std::int8_t> equipment_slot(std::uint8_t bucketId) noexcept {
-    for (const auto& entry : kEquipmentSlotOfBucket) {
-        if (entry.first == bucketId) {
-            return entry.second;
-        }
+    state::build_data::inventory::buckets::Descriptor descriptor{};
+    if (state::build_data::find_inventory_bucket_descriptor(bucketId, descriptor)
+        && descriptor.equipmentSlot
+               != state::build_data::inventory::buckets::kUnavailableEquipmentSlot) {
+        return descriptor.equipmentSlot;
     }
     return std::nullopt;
 }
@@ -76,7 +50,9 @@ constexpr std::array<std::pair<std::uint8_t, std::int8_t>, 16> kEquipmentSlotOfB
     }
     detail.statCount = static_cast<std::uint8_t>(stats);
     detail.gearArtIndex = row.gearArtIndex;
-    detail.artArrangementIndex = row.artArrangementIndex;
+    for (std::size_t index = 0; index < detail.artArrangementIndices.size(); ++index) {
+        detail.artArrangementIndices[index] = row.artArrangementIndices[index];
+    }
     const std::size_t perks = row.sandboxPerkCount < detail.sandboxPerks.size()
                                   ? row.sandboxPerkCount
                                   : detail.sandboxPerks.size();
@@ -164,7 +140,28 @@ bool authored(const AuthoredHashes& hashes, std::uint32_t hash) noexcept {
 
 /** @return True when the row's bucket maps to a supported equipment slot. */
 bool equippable(const tables::items::Row& row) noexcept {
-    return equipment_slot(row.bucketId).has_value();
+    return row.equipmentSlot.has_value() || equipment_slot(row.bucketId).has_value();
+}
+
+/** Applies the bucket-definition equipment mapping and publishes the complete bucket table. */
+bool publish_buckets(Storage& storage) noexcept {
+    namespace buckets = state::build_data::inventory::buckets;
+    if (state::build_data::inventory_bucket_descriptors_ready()) {
+        return true;
+    }
+    if (storage.bucketCount == 0 || storage.bucketCount > storage.bucketRows.size()) {
+        return false;
+    }
+    bool hasEquipmentSlot = false;
+    for (std::size_t index = 0; index < storage.bucketCount; ++index) {
+        buckets::Descriptor& descriptor = storage.bucketRows[index];
+        descriptor.equipmentSlot = storage.equipmentSlotByBucket[descriptor.bucketId];
+        hasEquipmentSlot =
+            hasEquipmentSlot || descriptor.equipmentSlot != buckets::kUnavailableEquipmentSlot;
+    }
+    return hasEquipmentSlot
+           && state::build_data::publish_inventory_bucket_descriptors(
+               std::span(storage.bucketRows).first(storage.bucketCount));
 }
 
 /** Adds one definition index to the deduplicated requested set. */

+ 8 - 2
Sunrise/src/client/content/items/packages/package_item_build.cpp

@@ -135,7 +135,10 @@ bool build() noexcept {
                     continue;
                 }
             }
-            (void)build_buckets(source, storage, std::span<const std::byte>{storage.root});
+            reason = "buckets";
+            if (!build_buckets(source, storage, std::span<const std::byte>{storage.root})) {
+                continue;
+            }
             (void)build_socket_entry_lists(
                 source, storage, std::span<const std::byte>{storage.root});
             if (!state::build_data::progression_definitions_ready()) {
@@ -187,11 +190,14 @@ bool build() noexcept {
     }
     SecureZeroMemory(&keys, sizeof keys);
     const bool complete = package_domains_ready();
+    const bool itemDomainsReady = root_domains_ready();
     if (complete) {
         // Nothing reads a package again until the next boot, so this reader's files go back now.
         reader::close_files(storage.scratch);
     }
-    report(complete ? state::build_data::item_definition_count() : 0, reason);
+    // Scenario, spawn-set, and hash-name extraction advance over later refresh slices and report
+    // their own progress. Do not mislabel one of those pending domains as the last item substage.
+    report(itemDomainsReady ? state::build_data::item_definition_count() : 0, reason);
     return complete;
 }
 

+ 8 - 4
Sunrise/src/client/content/items/packages/package_item_rows.cpp

@@ -44,8 +44,11 @@ bool build_item_rows(const reader::Source& source,
     const bool needDefinitions = !state::build_data::item_definitions_ready();
     const bool needDetails = !state::build_data::configured_item_details_ready();
     const bool needSocketPlugs = !state::build_data::socket_plug_rules_ready();
+    const bool needBuckets = !state::build_data::inventory_bucket_descriptors_ready();
     const bool needDetailRows = needDetails || needSocketPlugs;
-    const bool needRows = needDefinitions || needDetailRows;
+    // Bucket equipment slots are derived from this same complete item walk, so a partial retry
+    // must still revisit the table even when definitions and detail domains already published.
+    const bool needRows = needDefinitions || needDetailRows || needBuckets;
     bool published = !needRows;
     if (needDetails && !storage.details) {
         storage.details.reset(new (std::nothrow) build_details::Definition[kDetailCapacity]);
@@ -91,9 +94,10 @@ bool build_item_rows(const reader::Source& source,
     }
     bool requestsFit = true;
     if (needRows) {
-        requestsFit = !needDetailRows
-                      || materialize_requests(
-                          storage.detailRequests, storage.requestedDetailIndices, detailCount);
+        requestsFit = publish_buckets(storage)
+                      && (!needDetailRows
+                          || materialize_requests(
+                              storage.detailRequests, storage.requestedDetailIndices, detailCount));
         published = rowCount != 0 && requestsFit && detailStorageReady;
     }
     if (published && needDefinitions) {

+ 127 - 8
Sunrise/src/client/content/items/packages/package_root_tables.cpp

@@ -1,3 +1,5 @@
+#include <algorithm>
+#include <array>
 #include <cstring>
 #include <vector>
 
@@ -7,15 +9,123 @@
 #include "internal.h"
 
 namespace sunrise::client::content::items::packages {
+namespace {
+
+namespace buckets = state::build_data::inventory::buckets;
+
+/** The installed equipment-slot catalogue has rows 0 through 18. */
+constexpr std::uint8_t kEquipmentSlotCount = 19;
+/** Slot 16 is not backed by an inventory bucket in the installed equipment ABI. */
+constexpr std::uint8_t kUnavailableBucket = 0xFF;
+/**
+ * Inventory buckets are storage ranges; equipment slots are render/loadout positions. Their
+ * installed-build ABI is independent of item definitions and routes every item through its bucket.
+ */
+constexpr std::array<std::uint8_t, kEquipmentSlotCount> kBucketByEquipmentSlot{
+    16, 3, 4, 36, 5, 6, 7, 0, 1, 2, 10, 9, 8, 27, 41, 17, kUnavailableBucket, 47, 49};
+
+/** @return The unique descriptor carrying one bucket id, or null. */
+[[nodiscard]] const buckets::Descriptor*
+find_bucket(std::span<const buckets::Descriptor> descriptors, std::uint8_t bucketId) noexcept {
+    const buckets::Descriptor* found = nullptr;
+    for (const buckets::Descriptor& descriptor : descriptors) {
+        if (descriptor.bucketId != bucketId) {
+            continue;
+        }
+        if (found != nullptr) {
+            return nullptr;
+        }
+        found = &descriptor;
+    }
+    return found;
+}
+
+/**
+ * Extracts and validates the installed bucket/equipment-slot relation.
+ * The table contains inline 72-byte records; its array elements are not index rows or tag links.
+ */
+[[nodiscard]] bool read_bucket_equipment_slots(
+    const reader::Source& source,
+    Storage& storage,
+    std::span<const buckets::Descriptor> descriptors,
+    std::array<std::int8_t, buckets::kDescriptorCapacity>& equipmentSlots) noexcept {
+    equipmentSlots.fill(buckets::kUnavailableEquipmentSlot);
+    std::uint32_t tableClass = 0;
+    tables::Array table{};
+    if (!reader::read_tag(source,
+                          storage.scratch,
+                          tables::kBucketDefinitionTableTag,
+                          storage.child,
+                          tableClass)) {
+        report_bucket_equipment_failure("table_read", 0, 0);
+        return false;
+    }
+    if (tableClass != tables::kBucketDefinitionTableClass) {
+        report_bucket_equipment_failure("table_class", tableClass, 0);
+        return false;
+    }
+    if (!tables::find_array_at(
+            std::span<const std::byte>{storage.child}, tables::kTableArrayDescriptor, table)) {
+        report_bucket_equipment_failure("table_array", storage.child.size(), 0);
+        return false;
+    }
+    if (table.count != tables::kBucketDefinitionCount) {
+        report_bucket_equipment_failure("table_count", table.count, table.elementClass);
+        return false;
+    }
+    const std::span<const std::byte> tableBlob{storage.child};
+    const std::size_t tableSize = table.count * tables::kBucketDefinitionSize;
+    if (tableSize > tableBlob.size() || table.dataOffset > tableBlob.size() - tableSize) {
+        report_bucket_equipment_failure("table_extent", table.dataOffset, tableBlob.size());
+        return false;
+    }
+    std::array<bool, kEquipmentSlotCount> seenEquipmentSlots{};
+    std::array<bool, buckets::kDescriptorCapacity> seenBuckets{};
+    std::size_t mappedSlots = 0;
+    for (std::size_t index = 0; index < table.count; ++index) {
+        const std::size_t base = table.dataOffset + index * tables::kBucketDefinitionSize;
+        const std::uint8_t equipmentSlot = std::to_integer<std::uint8_t>(
+            tableBlob[base + tables::kBucketDefinitionEquipmentSlotOffset]);
+        if (equipmentSlot != tables::kBucketDefinitionUnavailableEquipmentSlot
+            && (equipmentSlot >= kEquipmentSlotCount || seenEquipmentSlots[equipmentSlot])) {
+            report_bucket_equipment_failure("equipment_slot", index, equipmentSlot);
+            return false;
+        }
+        if (equipmentSlot == tables::kBucketDefinitionUnavailableEquipmentSlot) {
+            continue;
+        }
+        const std::uint8_t bucketId = kBucketByEquipmentSlot[equipmentSlot];
+        const buckets::Descriptor* descriptor =
+            bucketId < buckets::kDescriptorCapacity ? find_bucket(descriptors, bucketId) : nullptr;
+        if (descriptor == nullptr || seenBuckets[bucketId]
+            || descriptor->arraySelector != buckets::ArraySelector::character) {
+            report_bucket_equipment_failure("bucket_relation", equipmentSlot, bucketId);
+            return false;
+        }
+        seenEquipmentSlots[equipmentSlot] = true;
+        seenBuckets[bucketId] = true;
+        equipmentSlots[bucketId] = static_cast<std::int8_t>(equipmentSlot);
+        ++mappedSlots;
+    }
+    if (mappedSlots != 18 || seenEquipmentSlots[16]) {
+        report_bucket_equipment_failure("mapped_slots", mappedSlots, seenEquipmentSlots[16]);
+        return false;
+    }
+    report_bucket_equipment_mapping(mappedSlots);
+    return true;
+}
+
+} // namespace
 
 /** Publishes the inventory bucket descriptors from the root's bucket table. */
 bool build_buckets(const reader::Source& source,
                    Storage& storage,
                    std::span<const std::byte> root) noexcept {
-    namespace buckets = state::build_data::inventory::buckets;
     if (state::build_data::inventory_bucket_descriptors_ready()) {
         return true;
     }
+    storage.bucketCount = 0;
+    storage.equipmentSlotByBucket.fill(buckets::kUnavailableEquipmentSlot);
     std::uint32_t tableTag = 0;
     if (!tables::slot_tag(root, tables::kBucketTableSlot, tableTag) || tableTag == 0
         || !reader::read_tag(source, storage.scratch, tableTag, storage.child)) {
@@ -30,8 +140,9 @@ bool build_buckets(const reader::Source& source,
     if (count <= 0 || static_cast<std::size_t>(count) > buckets::kDescriptorCapacity) {
         return false;
     }
-    std::vector<buckets::Descriptor> rows(static_cast<std::size_t>(count));
-    for (std::size_t index = 0; index < rows.size(); ++index) {
+    const std::size_t bucketCount = static_cast<std::size_t>(count);
+    std::array<buckets::Descriptor, buckets::kDescriptorCapacity> descriptors{};
+    for (std::size_t index = 0; index < bucketCount; ++index) {
         const std::size_t base =
             tables::kBucketFirstDescriptor + index * tables::kBucketDescriptorSize;
         if (base + tables::kBucketDescriptorSize > blob.size()) {
@@ -43,13 +154,21 @@ bool build_buckets(const reader::Source& source,
             &firstSlot, blob.data() + base + tables::kBucketFirstSlotOffset, sizeof firstSlot);
         std::memcpy(
             &slotCount, blob.data() + base + tables::kBucketSlotCountOffset, sizeof slotCount);
-        rows[index].bucketId = std::to_integer<std::uint8_t>(blob[base]);
-        rows[index].firstSlot = static_cast<std::uint16_t>(firstSlot);
-        rows[index].slotCount = static_cast<std::uint16_t>(slotCount);
-        rows[index].arraySelector = static_cast<buckets::ArraySelector>(
+        descriptors[index].bucketId = std::to_integer<std::uint8_t>(blob[base]);
+        descriptors[index].firstSlot = static_cast<std::uint16_t>(firstSlot);
+        descriptors[index].slotCount = static_cast<std::uint16_t>(slotCount);
+        descriptors[index].arraySelector = static_cast<buckets::ArraySelector>(
             std::to_integer<std::uint8_t>(blob[base + tables::kBucketArraySelectorOffset]));
     }
-    return state::build_data::publish_inventory_bucket_descriptors(rows);
+    std::array<std::int8_t, buckets::kDescriptorCapacity> equipmentSlots{};
+    if (!read_bucket_equipment_slots(
+            source, storage, std::span(descriptors).first(bucketCount), equipmentSlots)) {
+        return false;
+    }
+    storage.bucketRows = descriptors;
+    storage.bucketCount = bucketCount;
+    storage.equipmentSlotByBucket = equipmentSlots;
+    return true;
 }
 
 /** Publishes the socket entry list table from the root. */

+ 46 - 11
Sunrise/src/client/content/items/packages/package_subclass_build.cpp

@@ -21,13 +21,21 @@ constexpr std::size_t kSubclassSlot =
  * @return True when the character equips a subclass whose detail is published.
  */
 [[nodiscard]] bool subclass_list(const state::CharacterState& character,
-                                 std::uint16_t& socketEntryListIndex) noexcept {
+                                 std::uint16_t& socketEntryListIndex,
+                                 const char*& reason) noexcept {
     const auto& slot = character.equipment.slots[kSubclassSlot];
     state::build_data::items::Definition item{};
     state::build_data::items::details::Definition detail{};
-    if (!slot.has_value()
-        || !state::build_data::find_item_definition_hash(slot->definitionHash, item)
-        || !state::build_data::find_configured_item_detail(item.definitionIndex, detail)) {
+    reason = "subclass_slot";
+    if (!slot.has_value()) {
+        return false;
+    }
+    reason = "subclass_item";
+    if (!state::build_data::find_item_definition_hash(slot->definitionHash, item)) {
+        return false;
+    }
+    reason = "subclass_detail";
+    if (!state::build_data::find_configured_item_detail(item.definitionIndex, detail)) {
         return false;
     }
     socketEntryListIndex = detail.socketEntryListIndex;
@@ -69,17 +77,29 @@ bool build_character_abilities(const reader::Source& source,
     count = 0;
     std::uint32_t tableTag = 0;
     tables::Array rows{};
-    if (!tables::slot_tag(root, tables::kSocketEntryListTableSlot, tableTag) || tableTag == 0
-        || !reader::read_tag(source, scratch, tableTag, table)
-        || !tables::find_array_at(
+    if (!tables::slot_tag(root, tables::kSocketEntryListTableSlot, tableTag) || tableTag == 0) {
+        report_ability_failure("table_slot", 0, root.size(), tableTag);
+        return false;
+    }
+    if (!reader::read_tag(source, scratch, tableTag, table)) {
+        report_ability_failure("table_read", 0, tableTag, 0);
+        return false;
+    }
+    if (!tables::find_array_at(
             std::span<const std::byte>{table}, tables::kTableArrayDescriptor, rows)) {
+        report_ability_failure("table_array", 0, table.size(), 0);
         return false;
     }
     const state::AccountState account = state::account_snapshot();
     for (std::size_t character = 0; character < account.characterCount && count < output.size();
          ++character) {
         domain::Definition row{};
-        if (!subclass_list(account.characters[character], row.socketEntryListIndex)) {
+        const char* subclassReason = "subclass";
+        if (!subclass_list(
+                account.characters[character], row.socketEntryListIndex, subclassReason)) {
+            const auto& subclass = account.characters[character].equipment.slots[kSubclassSlot];
+            report_ability_failure(
+                subclassReason, character, subclass.has_value() ? subclass->definitionHash : 0, 0);
             continue;
         }
         // The selection is held in a local because the row it also keys is the build's output.
@@ -91,14 +111,29 @@ bool build_character_abilities(const reader::Source& source,
         tables::IndexRow indexRow{};
         if (!tables::index_row(
                 std::span<const std::byte>{table}, rows, row.socketEntryListIndex, indexRow)
-            || indexRow.targetTag == 0
-            || !reader::read_tag(source, scratch, indexRow.targetTag, definition)
-            || !build_ability_buckets(
+            || indexRow.targetTag == 0) {
+            report_ability_failure("index_row", character, row.socketEntryListIndex, rows.count);
+            continue;
+        }
+        if (!reader::read_tag(source, scratch, indexRow.targetTag, definition)) {
+            report_ability_failure(
+                "definition_read", character, row.socketEntryListIndex, indexRow.targetTag);
+            continue;
+        }
+        if (!build_ability_buckets(
                 source, scratch, std::span<const std::byte>{definition}, blob, selection, row)) {
+            const std::size_t packedSelection =
+                selection.movementEntry | (selection.grenadeEntry << 8U)
+                | (selection.superEntry << 16U) | (selection.meleeEntry << 24U);
+            report_ability_failure(
+                "bucket_build", character, row.socketEntryListIndex, packedSelection);
             continue;
         }
         output[count++] = row;
     }
+    if (count == 0) {
+        report_ability_failure("empty", account.characterCount, rows.count, output.size());
+    }
     return true;
 }
 

+ 12 - 0
Sunrise/src/middleware/content/packages/tables/definition_index_table.h

@@ -176,6 +176,18 @@ inline constexpr std::size_t kSocketEntryListTableSlot = 97;
 inline constexpr std::size_t kInvestmentRootChild = 0;
 /** The investment root holds the inventory bucket table at this slot. */
 inline constexpr std::size_t kBucketTableSlot = 17;
+/** Installed bucket-definition table pairing inventory buckets with native equipment slots. */
+inline constexpr std::uint32_t kBucketDefinitionTableTag = 0x81327D66U;
+/** Package class of the bucket-definition index table. */
+inline constexpr std::uint32_t kBucketDefinitionTableClass = 0x80805936U;
+/** The installed table contains one row for each item-bearing bucket definition. */
+inline constexpr std::size_t kBucketDefinitionCount = 34;
+/** One resolved bucket definition occupies 72 bytes. */
+inline constexpr std::size_t kBucketDefinitionSize = 72;
+/** The native equipment-slot byte sits here in each resolved bucket definition. */
+inline constexpr std::size_t kBucketDefinitionEquipmentSlotOffset = 64;
+/** Non-equippable bucket definitions carry the all-one slot sentinel. */
+inline constexpr std::uint8_t kBucketDefinitionUnavailableEquipmentSlot = 0xFF;
 /** Element class of the socket entry list table. */
 inline constexpr std::uint32_t kSocketEntryListTableClass = 0x80807A7EU;
 /** A socket entry list definition holds its entry array descriptor here. */

+ 22 - 7
Sunrise/src/middleware/content/packages/tables/item_appearance_reader.cpp

@@ -8,7 +8,8 @@ namespace {
 
 /** The art block declares its gear art definition index here. */
 constexpr std::size_t kGearArtIndexOffset = 88;
-/** One art row is 4 bytes and carries its art index after a class byte and one pad byte. */
+/** One art row is 4 bytes: signed class, one pad byte, then its art index. */
+constexpr std::size_t kArtRowStride = 4;
 constexpr std::size_t kArtRowValueOffset = 2;
 /** Material override arrays sit at these art-block offsets, one per stage. */
 constexpr std::size_t kMaterialStageOffsets[]{40, 56, 72};
@@ -57,10 +58,9 @@ read(std::span<const std::byte> blob, std::size_t offset, Value& value) noexcept
 }
 
 /**
- * Reads the two art indices the art block declares.
- * The arrangement index comes from the block's first art row, so the row count must be checked.
- * An art block with no rows would read arrangement zero, which is a real arrangement, not none.
- * @param definition Whole item definition bytes.
+ * Reads the gear-art index and every class-qualified arrangement the art block declares.
+ * @param
+ * definition Whole item definition bytes.
  * @param art Art block offset.
  * @param row Receives both art indices.
  */
@@ -70,7 +70,20 @@ void read_art(std::span<const std::byte> definition, std::size_t art, Row& row)
     if (!find_array_at(definition, art, rows) || rows.elementClass != kArtRowClass) {
         return;
     }
-    (void)read(definition, rows.dataOffset + kArtRowValueOffset, row.artArrangementIndex);
+    for (std::uint64_t index = 0; index < rows.count; ++index) {
+        const std::size_t at = rows.dataOffset + static_cast<std::size_t>(index) * kArtRowStride;
+        std::int8_t characterClass = -1;
+        std::uint16_t arrangement = kUnavailableArtIndex;
+        if (!read(definition, at, characterClass)
+            || !read(definition, at + kArtRowValueOffset, arrangement)) {
+            return;
+        }
+        const std::size_t slot =
+            characterClass == -1 ? 0 : static_cast<std::size_t>(characterClass) + 1U;
+        if (slot < kArtClassCapacity && row.artArrangementIndices[slot] == kUnavailableArtIndex) {
+            row.artArrangementIndices[slot] = arrangement;
+        }
+    }
 }
 
 /**
@@ -147,7 +160,9 @@ void read_sandbox_perks(std::span<const std::byte> definition, Row& row) noexcep
 /** Reads the art indices, material override rows and sandbox perks one definition declares. */
 void read_appearance(std::span<const std::byte> definition, Row& row) noexcept {
     row.gearArtIndex = kUnavailableArtIndex;
-    row.artArrangementIndex = kUnavailableArtIndex;
+    std::fill(std::begin(row.artArrangementIndices),
+              std::end(row.artArrangementIndices),
+              kUnavailableArtIndex);
     row.sandboxPerkCount = 0;
     row.renderOverrideCount = 0;
     std::size_t art = 0;

+ 4 - 2
Sunrise/src/middleware/content/packages/tables/items.h

@@ -22,6 +22,8 @@ inline constexpr std::size_t kSandboxPerkCapacity = 4;
 inline constexpr std::size_t kRenderOverrideCapacity = 32;
 /** All bits set marks an art index the definition does not declare. */
 inline constexpr std::uint16_t kUnavailableArtIndex = 0xFFFF;
+/** Generic art plus one row for each of Titan, Hunter, and Warlock. */
+inline constexpr std::size_t kArtClassCapacity = 4;
 /** All bits set marks a socket lane whose type the definition does not declare. */
 inline constexpr std::uint16_t kUnavailableSocketType = 0xFFFF;
 /** A signed material override key is empty at minus one. */
@@ -59,8 +61,8 @@ struct Row {
     std::int32_t statValues[kStatCapacity]{};
     /** Gear art definition index, read straight from the art block. */
     std::uint16_t gearArtIndex{kUnavailableArtIndex};
-    /** Art arrangement index, read from the art block's first art row. */
-    std::uint16_t artArrangementIndex{kUnavailableArtIndex};
+    /** Generic, Titan, Hunter, and Warlock art arrangements declared by the art block. */
+    std::uint16_t artArrangementIndices[kArtClassCapacity]{};
     std::uint8_t sandboxPerkCount{};
     std::uint16_t sandboxPerks[kSandboxPerkCapacity]{};
     std::uint8_t renderOverrideCount{};

+ 3 - 0
Sunrise/src/state/build_data/build_data_runtime.cpp

@@ -69,12 +69,14 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept {
                     runtime::persistence::scratch_domains(persistenceState),
                     counts);
     if (status == cache::LoadStatus::missing) {
+        runtime::persistence::release_scratch_locked(persistenceState);
         ReleaseSRWLockExclusive(&persistenceState.lock);
         return true;
     }
     if (status == cache::LoadStatus::stale) {
         // A stale cache is replaced only after every domain is complete.
         persistenceState.replaceStaleCache = true;
+        runtime::persistence::release_scratch_locked(persistenceState);
         ReleaseSRWLockExclusive(&persistenceState.lock);
         return true;
     }
@@ -124,6 +126,7 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept {
     runtime::spawn_catalog::publish();
     runtime::name_catalog::publish();
     persistenceState.persisted = true;
+    runtime::persistence::release_scratch_locked(persistenceState);
     ReleaseSRWLockExclusive(&persistenceState.lock);
     return true;
 }

+ 6 - 2
Sunrise/src/state/build_data/cache/records/cache_record_codec.cpp

@@ -208,7 +208,7 @@ bool encode(const items::details::Definition& value, ItemDetailRecord& record) n
     }
     record.definitionHash = value.definitionHash;
     record.gearArtIndex = value.gearArtIndex;
-    record.artArrangementIndex = value.artArrangementIndex;
+    record.artArrangementIndices = value.artArrangementIndices;
     record.sandboxPerkCount = value.sandboxPerkCount;
     record.sandboxPerks = value.sandboxPerks;
     record.renderOverrideCount = value.renderOverrideCount;
@@ -247,7 +247,7 @@ bool decode(const ItemDetailRecord& record, items::details::Definition& value) n
     }
     value.definitionHash = record.definitionHash;
     value.gearArtIndex = record.gearArtIndex;
-    value.artArrangementIndex = record.artArrangementIndex;
+    value.artArrangementIndices = record.artArrangementIndices;
     value.sandboxPerkCount = record.sandboxPerkCount;
     value.sandboxPerks = record.sandboxPerks;
     value.renderOverrideCount = record.renderOverrideCount;
@@ -265,6 +265,8 @@ bool encode(const inventory::buckets::Descriptor& value, InventoryBucketRecord&
         static_cast<std::uint8_t>(value.arraySelector),
         value.firstSlot,
         value.slotCount,
+        value.equipmentSlot,
+        value.reserved,
     };
     return true;
 }
@@ -276,6 +278,8 @@ bool decode(const InventoryBucketRecord& record, inventory::buckets::Descriptor&
         static_cast<inventory::buckets::ArraySelector>(record.arraySelector),
         record.firstSlot,
         record.slotCount,
+        record.equipmentSlot,
+        record.reserved,
     };
     return true;
 }

+ 6 - 4
Sunrise/src/state/build_data/cache/records/format.h

@@ -27,7 +27,7 @@ inline constexpr std::array<char, 8> kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', '
  * Current build-data cache format. An older cache is rebuilt rather than read, so a bump needs
  * no other edit. Bump it whenever a domain's stored shape changes.
  */
-inline constexpr std::uint32_t kCacheFormatVersion = 31;
+inline constexpr std::uint32_t kCacheFormatVersion = 33;
 /** Signed -1 on disk means there is no equipment slot. */
 inline constexpr std::int8_t kAbsentEquipmentSlot = -1;
 /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */
@@ -162,7 +162,7 @@ struct ItemDetailRecord {
     std::array<std::int32_t, items::details::kStatCapacity> statValues{};
     std::uint32_t definitionHash{};
     std::uint16_t gearArtIndex{};
-    std::uint16_t artArrangementIndex{};
+    std::array<std::uint16_t, items::details::kArtClassCapacity> artArrangementIndices{};
     std::uint8_t sandboxPerkCount{};
     std::array<std::uint16_t, items::details::kSandboxPerkCapacity> sandboxPerks{};
     /** Material override rows in the stage order the character record folds them in. */
@@ -198,6 +198,8 @@ struct InventoryBucketRecord {
     std::uint8_t arraySelector{};
     std::uint16_t firstSlot{};
     std::uint16_t slotCount{};
+    std::int8_t equipmentSlot{inventory::buckets::kUnavailableEquipmentSlot};
+    std::uint8_t reserved{};
 };
 
 /** Disk form of the buckets one subclass publishes under one ability selection. */
@@ -376,7 +378,7 @@ static_assert(sizeof(MaterialRequirementSetRecord)
                      + material_requirements::kRequirementCapacity
                            * sizeof(MaterialRequirementRecord));
 static_assert(sizeof(ItemDetailRecord)
-              == 4 * sizeof(std::uint16_t) + 8 * sizeof(std::uint8_t) + sizeof(std::int32_t)
+              == 7 * sizeof(std::uint16_t) + 8 * sizeof(std::uint8_t) + sizeof(std::int32_t)
                      + sizeof(std::uint32_t)
                      + 2 * items::details::kInitialPlugCapacity * sizeof(std::uint16_t)
                      + items::details::kStatCapacity * (sizeof(std::uint8_t) + sizeof(std::int32_t))
@@ -388,7 +390,7 @@ static_assert(sizeof(SocketPlugRuleRecord)
 static_assert(sizeof(SocketPlugPoolRecord) == 2 * sizeof(std::uint32_t));
 static_assert(sizeof(SocketPlugMemberRecord) == sizeof(std::uint16_t));
 static_assert(sizeof(InventoryBucketRecord)
-              == 2 * sizeof(std::uint8_t) + 2 * sizeof(std::uint16_t));
+              == 4 * sizeof(std::uint8_t) + 2 * sizeof(std::uint16_t));
 static_assert(sizeof(SocketEntryListRecord)
               == sizeof(std::uint32_t) + sizeof(std::uint16_t) + 2 * sizeof(std::uint8_t)
                      + sizeof(std::uint64_t));

+ 6 - 2
Sunrise/src/state/build_data/inventory/buckets/definition.h

@@ -27,8 +27,10 @@ inline constexpr std::uint32_t kSmallProfileSlotCapacity = 6;
 inline constexpr std::uint8_t kUnavailableBucketId = (std::numeric_limits<std::uint8_t>::max)();
 /** Leaving out the unavailable id leaves at most 255 unique bucket records. */
 inline constexpr std::size_t kDescriptorCapacity = kUnavailableBucketId;
-/** The packed cache record holds 2 bytes and 2 16-bit slot fields. */
-inline constexpr std::size_t kDescriptorByteSize = 6;
+/** Signed -1 marks a bucket that has no equipment slot. */
+inline constexpr std::int8_t kUnavailableEquipmentSlot = -1;
+/** The packed descriptor holds routing, an optional equipment slot, and one reserved byte. */
+inline constexpr std::size_t kDescriptorByteSize = 8;
 
 /** Packed routing record for one checked runtime inventory bucket. */
 struct Descriptor {
@@ -36,6 +38,8 @@ struct Descriptor {
     ArraySelector arraySelector{};
     std::uint16_t firstSlot{};
     std::uint16_t slotCount{};
+    std::int8_t equipmentSlot{kUnavailableEquipmentSlot};
+    std::uint8_t reserved{};
 };
 
 static_assert(sizeof(Descriptor) == kDescriptorByteSize);

+ 24 - 2
Sunrise/src/state/build_data/inventory/buckets/inventory_bucket_catalog.cpp

@@ -2,6 +2,7 @@
 
 #include <algorithm>
 #include <array>
+#include <bitset>
 #include <limits>
 
 #include "../../table.h"
@@ -49,6 +50,16 @@ std::array<std::uint16_t, kDescriptorCapacity> g_lookup{};
     return firstSlot <= capacity && descriptor.slotCount <= capacity - firstSlot;
 }
 
+/** The installed equipment-slot catalogue exposes rows 0 through 18. */
+constexpr std::size_t kEquipmentSlotCount = 19;
+
+/** @return True when the optional native equipment slot is absent or inside its catalogue. */
+[[nodiscard]] bool equipment_slot_fits(const Descriptor& descriptor) noexcept {
+    return descriptor.equipmentSlot == kUnavailableEquipmentSlot
+           || (descriptor.equipmentSlot >= 0
+               && static_cast<std::size_t>(descriptor.equipmentSlot) < kEquipmentSlotCount);
+}
+
 } // namespace
 
 /** Clears every generated inventory-bucket descriptor under the catalog lock. */
@@ -65,14 +76,25 @@ bool valid(std::span<const Descriptor> descriptors) noexcept {
     }
 
     std::array<bool, kDescriptorCapacity> occupied{};
+    std::bitset<kEquipmentSlotCount> occupiedEquipmentSlots;
+    bool hasEquipmentSlot = false;
     for (const Descriptor& descriptor : descriptors) {
         if (descriptor.bucketId == kUnavailableBucketId || occupied[descriptor.bucketId]
-            || !range_fits(descriptor)) {
+            || descriptor.reserved != 0 || !range_fits(descriptor)
+            || !equipment_slot_fits(descriptor)) {
             return false;
         }
+        if (descriptor.equipmentSlot != kUnavailableEquipmentSlot) {
+            const std::size_t equipmentSlot = static_cast<std::size_t>(descriptor.equipmentSlot);
+            if (occupiedEquipmentSlots.test(equipmentSlot)) {
+                return false;
+            }
+            occupiedEquipmentSlots.set(equipmentSlot);
+            hasEquipmentSlot = true;
+        }
         occupied[descriptor.bucketId] = true;
     }
-    return true;
+    return hasEquipmentSlot;
 }
 
 /** Rebuilds the descriptor table and its bucket-id lookup after the checks pass. */

+ 12 - 2
Sunrise/src/state/build_data/items/details/definition.h

@@ -34,6 +34,8 @@ inline constexpr std::size_t kSandboxPerkCapacity = 4;
 inline constexpr std::size_t kRenderOverrideCapacity = 32;
 /** All bits set marks an art index the definition does not declare. */
 inline constexpr std::uint16_t kUnavailableArtIndex = 0xFFFF;
+/** Generic art plus one row for each playable character class. */
+inline constexpr std::size_t kArtClassCapacity = 4;
 /** All bits set marks a socket lane whose type the definition does not declare. */
 inline constexpr std::uint16_t kUnavailableSocketType = 0xFFFF;
 /** A signed material override key is empty at -1. */
@@ -83,6 +85,14 @@ unavailable_plug_indices() noexcept {
     return result;
 }
 
+/** @return Generic and class-qualified art slots initialized to the empty sentinel. */
+[[nodiscard]] constexpr std::array<std::uint16_t, kArtClassCapacity>
+unavailable_art_indices() noexcept {
+    std::array<std::uint16_t, kArtClassCapacity> result{};
+    result.fill(kUnavailableArtIndex);
+    return result;
+}
+
 /** Installed-build fields required to generate one supported item instance. */
 struct Definition {
     std::uint16_t definitionIndex{};
@@ -103,8 +113,8 @@ struct Definition {
     std::array<Stat, kStatCapacity> stats{};
     /** Gear art definition index the render row publishes. */
     std::uint16_t gearArtIndex{kUnavailableArtIndex};
-    /** Art arrangement index the render row publishes beside it. */
-    std::uint16_t artArrangementIndex{kUnavailableArtIndex};
+    /** Generic, Titan, Hunter, and Warlock arrangement alternatives. */
+    std::array<std::uint16_t, kArtClassCapacity> artArrangementIndices{unavailable_art_indices()};
     /** Number of filled leading entries in `sandboxPerks`. */
     std::uint8_t sandboxPerkCount{};
     std::array<std::uint16_t, kSandboxPerkCapacity> sandboxPerks{};

+ 17 - 4
Sunrise/src/state/build_data/items/socket_plugs/socket_plug_catalog.cpp

@@ -1,6 +1,7 @@
 #include "socket_plug_catalog.h"
 
 #include <algorithm>
+#include <bitset>
 
 #include "../../table.h"
 
@@ -11,6 +12,7 @@ Lock g_lock;
 Table<Rule, kRuleCapacity> g_rules;
 Table<Pool, kPoolCapacity> g_pools;
 Table<Member, kMemberCapacity> g_members;
+std::bitset<details::kDefinitionCapacity> g_membership;
 
 /** @return True when the first rule's item/lane key is strictly before the second. */
 [[nodiscard]] bool rule_less(const Rule& left, const Rule& right) noexcept {
@@ -26,6 +28,7 @@ void clear() noexcept {
     g_rules.clear();
     g_pools.clear();
     g_members.clear();
+    g_membership.reset();
 }
 
 /** Checks counts, strict rule order, contiguous pools, and sorted unique pool members. */
@@ -53,7 +56,10 @@ bool valid(std::span<const Rule> rules,
         }
         const auto range = members.subspan(expectedOffset, pool.memberCount);
         if (!std::is_sorted(range.begin(), range.end())
-            || std::adjacent_find(range.begin(), range.end()) != range.end()) {
+            || std::adjacent_find(range.begin(), range.end()) != range.end()
+            || std::any_of(range.begin(), range.end(), [](Member member) {
+                   return member >= details::kDefinitionCapacity;
+               })) {
             return false;
         }
         expectedOffset += pool.memberCount;
@@ -68,8 +74,16 @@ bool replace(std::span<const Rule> rules,
     if (!valid(rules, pools, members)) {
         return false;
     }
+    std::bitset<details::kDefinitionCapacity> membership;
+    for (const Member member : members) {
+        membership.set(member);
+    }
     const Lock::Exclusive guard(g_lock);
-    return g_rules.replace(rules) && g_pools.replace(pools) && g_members.replace(members);
+    if (!g_rules.replace(rules) || !g_pools.replace(pools) || !g_members.replace(members)) {
+        return false;
+    }
+    g_membership = membership;
+    return true;
 }
 
 /** Performs an exact item/lane rule lookup followed by a binary search in its plug pool. */
@@ -101,8 +115,7 @@ bool allowed(std::uint16_t itemDefinitionIndex,
 /** Answers whether one definition occurs in any installed ordinary-socket plug pool. */
 bool contains(Member plugDefinitionIndex) noexcept {
     const Lock::Shared guard(g_lock);
-    const auto members = g_members.rows();
-    return std::find(members.begin(), members.end(), plugDefinitionIndex) != members.end();
+    return plugDefinitionIndex < g_membership.size() && g_membership.test(plugDefinitionIndex);
 }
 
 /** Copies the three related arrays under the same shared hold. */

+ 25 - 36
Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp

@@ -177,44 +177,32 @@ cache::records::MutableDomains scratch_domains(Context& state) noexcept {
     };
 }
 
+/** Releases transient cache snapshot banks without allocating or walking their capacities. */
+void release_scratch_locked(Context& state) noexcept {
+    state.namedScratch.reset();
+    state.itemScratch.reset();
+    state.collectibleScratch.reset();
+    state.materialRequirementSetScratch.reset();
+    state.itemDetailScratch.reset();
+    state.socketPlugRuleScratch.reset();
+    state.socketPlugPoolScratch.reset();
+    state.socketPlugMemberScratch.reset();
+    state.inventoryBucketScratch.reset();
+    state.socketEntryListScratch.reset();
+    state.socketEntryTableScratch.reset();
+    state.abilityBucketScratch.reset();
+    state.progressionScratch.reset();
+    state.scenarioScratch.reset();
+    state.rosterGroupScratch.reset();
+    state.spawnStemScratch.reset();
+    state.spawnNameHashScratch.reset();
+    state.hashNameScratch.reset();
+    state.constantsScratch = {};
+}
+
 /** Clears fixed cache paths, identity, flags, and snapshot storage. */
 void clear_locked(Context& state) noexcept {
-    const cache::records::MutableDomains scratch = scratch_domains(state);
-    std::fill(scratch.named.begin(), scratch.named.end(), content::Definition{});
-    std::fill(scratch.items.begin(), scratch.items.end(), items::Definition{});
-    std::fill(scratch.collectibles.begin(), scratch.collectibles.end(), collectibles::Definition{});
-    std::fill(scratch.materialRequirementSets.begin(),
-              scratch.materialRequirementSets.end(),
-              material_requirements::Definition{});
-    std::fill(scratch.itemDetails.begin(), scratch.itemDetails.end(), items::details::Definition{});
-    std::fill(scratch.socketPlugRules.begin(),
-              scratch.socketPlugRules.end(),
-              items::socket_plugs::Rule{});
-    std::fill(scratch.socketPlugPools.begin(),
-              scratch.socketPlugPools.end(),
-              items::socket_plugs::Pool{});
-    std::fill(scratch.socketPlugMembers.begin(),
-              scratch.socketPlugMembers.end(),
-              items::socket_plugs::Member{});
-    std::fill(scratch.inventoryBuckets.begin(),
-              scratch.inventoryBuckets.end(),
-              inventory::buckets::Descriptor{});
-    std::fill(scratch.socketEntryLists.begin(),
-              scratch.socketEntryLists.end(),
-              socket_entry_lists::Definition{});
-    std::fill(scratch.socketEntryTables.begin(),
-              scratch.socketEntryTables.end(),
-              socket_entry_lists::EntryTable{});
-    std::fill(
-        scratch.abilityBuckets.begin(), scratch.abilityBuckets.end(), abilities::Definition{});
-    std::fill(scratch.progressions.begin(), scratch.progressions.end(), progressions::Definition{});
-    std::fill(scratch.scenarios.begin(), scratch.scenarios.end(), scenarios::Definition{});
-    std::fill(scratch.rosterGroups.begin(), scratch.rosterGroups.end(), scenarios::RosterGroup{});
-    std::fill(scratch.spawnStems.begin(), scratch.spawnStems.end(), spawn_sets::Stem{});
-    std::fill(
-        scratch.spawnNameHashes.begin(), scratch.spawnNameHashes.end(), spawn_sets::NameHash{});
-    std::fill(scratch.hashNames.begin(), scratch.hashNames.end(), hash_names::Name{});
-    state.constantsScratch = {};
+    release_scratch_locked(state);
     state.cacheDirectory = {};
     state.cachePath = {};
     state.buildIdentity = {};
@@ -292,6 +280,7 @@ bool persist_if_complete_locked(Context& state) noexcept {
     }
     state.persisted = true;
     state.replaceStaleCache = false;
+    release_scratch_locked(state);
     return true;
 }
 

+ 3 - 0
Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h

@@ -67,6 +67,9 @@ struct Context {
  */
 void clear_locked(Context& state) noexcept;
 
+/** Releases transient cache snapshot banks while preserving paths, identity, and flags. */
+void release_scratch_locked(Context& state) noexcept;
+
 /**
  * Gives mutable views over every generated domain.
  * @param state Its lock must already be held exclusively.

+ 7 - 3
Sunrise/src/state/build_data/table.h

@@ -71,7 +71,7 @@ public:
 
     /** Drops every row. Call under an exclusive hold. */
     void clear() noexcept {
-        rows_.fill(Row{});
+        std::fill_n(rows_.begin(), count_, Row{});
         count_ = 0;
     }
 
@@ -84,8 +84,12 @@ public:
         if (rows.size() > Capacity) {
             return false;
         }
-        rows_.fill(Row{});
         std::copy(rows.begin(), rows.end(), rows_.begin());
+        if (count_ > rows.size()) {
+            std::fill(rows_.begin() + static_cast<std::ptrdiff_t>(rows.size()),
+                      rows_.begin() + static_cast<std::ptrdiff_t>(count_),
+                      Row{});
+        }
         // The count publishes the new rows, so it moves last.
         count_ = rows.size();
         return true;
@@ -102,7 +106,7 @@ public:
         if (count > Capacity) {
             return {};
         }
-        rows_.fill(Row{});
+        std::fill_n(rows_.begin(), (std::max)(count_, count), Row{});
         count_ = count;
         return {rows_.data(), count_};
     }