Ver Fonte

fix(investment): restore exotic catalyst completion state

Sunrise rejected the native Masterwork item-state bit 0x4 and had no build-scoped catalyst lifecycle catalog. Accept the full 0x7 state contract, derive released catalyst relations from the target investment data, and apply each completed plug with its flag as one change.
y9522 há 3 semanas atrás
pai
commit
ab75cb7295
52 ficheiros alterados com 2493 adições e 47 exclusões
  1. 6 0
      Sunrise.sln
  2. 8 0
      Sunrise/Sunrise.vcxproj
  3. 1 0
      Sunrise/resources/default_settings.json
  4. 11 3
      Sunrise/src/client/content/items/packages/internal.h
  5. 26 0
      Sunrise/src/client/content/items/packages/package_build_report.cpp
  6. 35 7
      Sunrise/src/client/content/items/packages/package_item_rows.cpp
  7. 12 0
      Sunrise/src/client/content/items/packages/package_socket_plug_build.cpp
  8. 9 1
      Sunrise/src/client/content/items/packages/package_socket_plug_build.h
  9. 4 0
      Sunrise/src/core/settings/parser.h
  10. 2 0
      Sunrise/src/core/settings/settings.h
  11. 7 0
      Sunrise/src/core/settings/settings_parser.cpp
  12. 2 4
      Sunrise/src/core/settings/state/inventory_parser.cpp
  13. 4 3
      Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_banks.cpp
  14. 9 0
      Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_render.cpp
  15. 3 2
      Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_stats.cpp
  16. 9 0
      Sunrise/src/middleware/datagen/character_record/appearance/internal.h
  17. 7 0
      Sunrise/src/middleware/datagen/family4/loadout/loadout_item_resolver.cpp
  18. 1 1
      Sunrise/src/middleware/web_service/messages/opcode406.h
  19. 3 4
      Sunrise/src/middleware/web_service/messages/opcode406_codec.cpp
  20. 2 5
      Sunrise/src/state/account/inventory/inventory_state.h
  21. 25 0
      Sunrise/src/state/account/inventory/item_state.h
  22. 6 2
      Sunrise/src/state/build_data/build_data_runtime.cpp
  23. 4 1
      Sunrise/src/state/build_data/cache/read/cache_file_reader.cpp
  24. 12 4
      Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp
  25. 25 0
      Sunrise/src/state/build_data/cache/records/cache_detail_links.cpp
  26. 21 4
      Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp
  27. 43 0
      Sunrise/src/state/build_data/cache/records/cache_exotic_catalyst_record_codec.cpp
  28. 16 0
      Sunrise/src/state/build_data/cache/records/codec.h
  29. 4 0
      Sunrise/src/state/build_data/cache/records/domains.h
  30. 17 1
      Sunrise/src/state/build_data/cache/records/format.h
  31. 19 1
      Sunrise/src/state/build_data/cache/records/validation.h
  32. 1 0
      Sunrise/src/state/build_data/cache/write/cache_file_writer.cpp
  33. 2 0
      Sunrise/src/state/build_data/cache/write/cache_payload_writer.cpp
  34. 1 0
      Sunrise/src/state/build_data/cache/write/temporary/temporary_cache_file.cpp
  35. 108 0
      Sunrise/src/state/build_data/items/catalysts/definition.h
  36. 155 0
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_build_data_runtime.cpp
  37. 426 0
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_builder.cpp
  38. 70 0
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_builder.h
  39. 198 0
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_catalog.cpp
  40. 88 0
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_catalog.h
  41. 51 0
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_generated.cpp
  42. 72 0
      Sunrise/src/state/build_data/runtime.h
  43. 2 0
      Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp
  44. 14 0
      Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp
  45. 2 0
      Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h
  46. 4 4
      Sunrise/src/state/runtime/state_account_socket_runtime.cpp
  47. 2 0
      Sunrise/src/state/runtime/state_runtime.cpp
  48. 119 0
      tests/Sunrise.Tests.vcxproj
  49. 385 0
      tests/exotic_catalyst_regression_tests.cpp
  50. 156 0
      tests/item_state_regression_tests.cpp
  51. 216 0
      tests/loadout_catalyst_regression_tests.cpp
  52. 68 0
      tests/opcode406_item_state_regression_tests.cpp

+ 6 - 0
Sunrise.sln

@@ -5,6 +5,8 @@ VisualStudioVersion = 18.0.30412.183
 MinimumVisualStudioVersion = 10.0.40219.1
 Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Sunrise", "Sunrise\Sunrise.vcxproj", "{A665726B-97A2-4E35-B518-0FC207D3DA1B}"
 EndProject
+Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Sunrise.Tests", "tests\Sunrise.Tests.vcxproj", "{9D2A5866-9D2F-4A07-A88C-6E713A355C94}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|x64 = Debug|x64
@@ -15,6 +17,10 @@ Global
 		{A665726B-97A2-4E35-B518-0FC207D3DA1B}.Debug|x64.Build.0 = Debug|x64
 		{A665726B-97A2-4E35-B518-0FC207D3DA1B}.Release|x64.ActiveCfg = Release|x64
 		{A665726B-97A2-4E35-B518-0FC207D3DA1B}.Release|x64.Build.0 = Release|x64
+		{9D2A5866-9D2F-4A07-A88C-6E713A355C94}.Debug|x64.ActiveCfg = Debug|x64
+		{9D2A5866-9D2F-4A07-A88C-6E713A355C94}.Debug|x64.Build.0 = Debug|x64
+		{9D2A5866-9D2F-4A07-A88C-6E713A355C94}.Release|x64.ActiveCfg = Release|x64
+		{9D2A5866-9D2F-4A07-A88C-6E713A355C94}.Release|x64.Build.0 = Release|x64
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE

+ 8 - 0
Sunrise/Sunrise.vcxproj

@@ -208,6 +208,7 @@
     <ClCompile Include="src\middleware\datagen\family4\loadout\subclass_socket_selection.cpp" />
     <ClCompile Include="src\state\build_data\cache\records\cache_socket_record_codec.cpp" />
     <ClCompile Include="src\state\build_data\cache\records\cache_socket_plug_record_codec.cpp" />
+    <ClCompile Include="src\state\build_data\cache\records\cache_exotic_catalyst_record_codec.cpp" />
     <ClCompile Include="src\client\hooks\graphics\renderer\selection\graphics_swap_chain_selection.cpp" />
     <ClCompile Include="src\client\hooks\graphics\renderer\graphics_renderer_lifecycle.cpp" />
     <ClCompile Include="src\client\hooks\graphics\renderer\graphics_renderer_frame.cpp" />
@@ -682,6 +683,10 @@
     <ClCompile Include="src\state\build_data\items\details\item_detail_catalog.cpp" />
     <ClCompile Include="src\state\build_data\items\socket_plugs\socket_plug_catalog.cpp" />
     <ClCompile Include="src\state\build_data\items\socket_plugs\socket_plug_build_data_runtime.cpp" />
+    <ClCompile Include="src\state\build_data\items\catalysts\exotic_catalyst_builder.cpp" />
+    <ClCompile Include="src\state\build_data\items\catalysts\exotic_catalyst_generated.cpp" />
+    <ClCompile Include="src\state\build_data\items\catalysts\exotic_catalyst_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\items\catalysts\exotic_catalyst_build_data_runtime.cpp" />
     <ClCompile Include="src\state\build_data\constants\investment_constant_catalog.cpp" />
     <ClCompile Include="src\state\build_data\abilities\ability_bucket_catalog.cpp" />
     <ClCompile Include="src\state\build_data\progressions\progression_catalog.cpp" />
@@ -1456,6 +1461,9 @@
     <ClInclude Include="src\state\build_data\items\details\item_detail_catalog.h" />
     <ClInclude Include="src\state\build_data\items\socket_plugs\definition.h" />
     <ClInclude Include="src\state\build_data\items\socket_plugs\socket_plug_catalog.h" />
+    <ClInclude Include="src\state\build_data\items\catalysts\definition.h" />
+    <ClInclude Include="src\state\build_data\items\catalysts\exotic_catalyst_builder.h" />
+    <ClInclude Include="src\state\build_data\items\catalysts\exotic_catalyst_catalog.h" />
     <ClInclude Include="src\client\content\items\packages\package_socket_plug_build.h" />
     <ClInclude Include="src\state\build_data\inventory\buckets\definition.h" />
     <ClInclude Include="src\state\build_data\inventory\buckets\inventory_bucket_catalog.h" />

+ 1 - 0
Sunrise/resources/default_settings.json

@@ -1,5 +1,6 @@
 {
   "version": 13,
+  "complete_exotic_catalysts": true,
   "core": {
     "logging": {
       "debugger_sink": true,

+ 11 - 3
Sunrise/src/client/content/items/packages/internal.h

@@ -304,9 +304,17 @@ void report_row_count(std::size_t walked,
 
 /** Reports the exact socket-rule, deduplicated-pool, member, and skipped-lane counts. */
 void report_socket_plug_count(std::size_t rules,
-                              std::size_t pools,
-                              std::size_t members,
-                              std::size_t skipped) noexcept;
+                               std::size_t pools,
+                               std::size_t members,
+                               std::size_t skipped) noexcept;
+
+/**
+ * Reports released, placeholder, and unsupported catalyst catalog counts.
+ * @param report Complete catalog report from the build pass.
+ * @param built True when all released catalyst relations were safe.
+ */
+void report_catalyst_catalog(const state::build_data::items::catalysts::Report& report,
+                             bool built) noexcept;
 
 /** Reports the validated installed bucket/equipment-slot coverage. */
 void report_bucket_equipment_mapping(std::size_t mappedSlots) noexcept;

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

@@ -128,6 +128,32 @@ void report_socket_plug_count(std::size_t rules,
     }
 }
 
+/** Reports the build-scoped catalyst catalog and its first safe failure. */
+void report_catalyst_catalog(const state::build_data::items::catalysts::Report& report,
+                             bool built) noexcept {
+    std::array<char, 192> line{};
+    const int written = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=pkg stage=exotic_catalysts result=%s released=%zu placeholder=%zu unsupported=%zu "
+        "error=%s item=0x%08X lane=%u",
+        built ? "ok" : "fail",
+        report.released,
+        report.placeholder,
+        report.unsupported,
+        state::build_data::items::catalysts::error_name(report.error).data(),
+        report.itemDefinitionHash,
+        static_cast<unsigned>(report.socketLane));
+    if (written > 0) {
+        const core::log::Level level = !built                  ? core::log::Level::error
+                                       : report.unsupported != 0 ? core::log::Level::warn
+                                                                 : core::log::Level::info;
+        core::log::write(core::log::Channel::client,
+                         level,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
 /** 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{};

+ 35 - 7
Sunrise/src/client/content/items/packages/package_item_rows.cpp

@@ -43,16 +43,19 @@ 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 needCatalysts = !state::build_data::exotic_catalysts_ready();
     const bool needBuckets = !state::build_data::inventory_bucket_descriptors_ready();
-    const bool needDetailRows = needDetails || needSocketPlugs;
+    const bool retainDetails = needDetails || needCatalysts;
+    const bool needSocketRows = needSocketPlugs || needCatalysts;
+    const bool needDetailRows = needDetails || needSocketRows;
     // 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.size() != kDetailCapacity) {
+    if (retainDetails && storage.details.size() != kDetailCapacity) {
         storage.details.assign(kDetailCapacity, build_details::Definition{});
     }
-    const bool detailStorageReady = !needDetails || storage.details.size() == kDetailCapacity;
+    const bool detailStorageReady = !retainDetails || storage.details.size() == kDetailCapacity;
     const std::span<const std::byte> container{storage.child};
     reason = "rows";
     // The detail closure is gathered during this one walk. Collections can name any installed
@@ -88,7 +91,7 @@ bool build_item_rows(const reader::Source& source,
                                                  item.plugCategoryHash,
                                                  item.rollSetIndex,
                                                  item.linkedPlugIndex};
-        if (needSocketPlugs) {
+        if (needSocketRows) {
             storage.specialPlugCategories[item.definitionIndex] =
                 special_plug_category(item.plugCategoryHash);
         }
@@ -119,7 +122,7 @@ bool build_item_rows(const reader::Source& source,
     }
     SocketPlugBuild socketPlugBuild;
     const bool socketStorageReady =
-        !needSocketPlugs
+        !needSocketRows
         || socketPlugBuild.prepare(storage.specialPlugCategories,
                                    std::span(storage.rows).first(rowCount));
     if (published && !socketStorageReady) {
@@ -141,10 +144,10 @@ bool build_item_rows(const reader::Source& source,
                 report_detail_failure(slot, storage.requestedDetailIndices[slot]);
                 continue;
             }
-            if (needDetails) {
+            if (retainDetails) {
                 storage.details[builtDetailCount++] = detail;
             }
-            if (needSocketPlugs) {
+            if (needSocketRows) {
                 (void)socketPlugBuild.append(item,
                                              std::span<const std::byte>{storage.definition},
                                              std::span<const std::byte>{storage.plugSetTable},
@@ -156,6 +159,25 @@ bool build_item_rows(const reader::Source& source,
                 std::span<build_details::Definition>{storage.details}.first(builtDetailCount));
             report_detail_count(detailCount, builtDetailCount);
         }
+        std::array<state::build_data::items::catalysts::Definition,
+                   state::build_data::items::catalysts::kDefinitionCapacity>
+            catalystRows{};
+        std::size_t catalystCount = 0;
+        state::build_data::items::catalysts::Report catalystReport{};
+        if (published && needCatalysts) {
+            reason = "exotic_catalysts";
+            const bool built = state::build_data::derive_exotic_catalysts(
+                std::span(storage.rows).first(rowCount),
+                std::span(storage.details).first(builtDetailCount),
+                socketPlugBuild.rules(),
+                socketPlugBuild.pools(),
+                socketPlugBuild.members(),
+                catalystRows,
+                catalystCount,
+                catalystReport);
+            report_catalyst_catalog(catalystReport, built);
+            published = built;
+        }
         if (published && needSocketPlugs) {
             const std::size_t rules = socketPlugBuild.rule_count();
             const std::size_t pools = socketPlugBuild.pool_count();
@@ -167,6 +189,11 @@ bool build_item_rows(const reader::Source& source,
                 report_socket_plug_count(rules, pools, members, skipped);
             }
         }
+        if (published && needCatalysts) {
+            reason = "exotic_catalysts";
+            published = state::build_data::publish_exotic_catalysts(
+                std::span(catalystRows).first(catalystCount));
+        }
     }
     // Ability buckets read the socket entry list table again and depend on the detail domain, so
     // they run last. The entry-bucket table never joins the on-disk cache, so a warm boot still
@@ -199,6 +226,7 @@ bool build_item_rows(const reader::Source& source,
     return published && state::build_data::item_definitions_ready()
            && state::build_data::configured_item_details_ready()
            && state::build_data::socket_plug_rules_ready()
+           && state::build_data::exotic_catalysts_ready()
            && state::build_data::ability_buckets_ready();
 }
 

+ 12 - 0
Sunrise/src/client/content/items/packages/package_socket_plug_build.cpp

@@ -246,6 +246,18 @@ std::size_t SocketPlugBuild::member_count() const noexcept {
     return memberCount_;
 }
 
+std::span<const socket_plugs::Rule> SocketPlugBuild::rules() const noexcept {
+    return std::span(rules_).first(ruleCount_);
+}
+
+std::span<const socket_plugs::Pool> SocketPlugBuild::pools() const noexcept {
+    return std::span(pools_).first(poolCount_);
+}
+
+std::span<const socket_plugs::Member> SocketPlugBuild::members() const noexcept {
+    return std::span(members_).first(memberCount_);
+}
+
 /** Drops all heap-backed extraction scratch and resets every count. */
 void SocketPlugBuild::release() noexcept {
     rules_.clear();

+ 9 - 1
Sunrise/src/client/content/items/packages/package_socket_plug_build.h

@@ -23,7 +23,12 @@ public:
     SocketPlugBuild(const SocketPlugBuild&) = delete;
     SocketPlugBuild& operator=(const SocketPlugBuild&) = delete;
 
-    /** Allocates bounded scratch and indexes the three native expandable plug categories. */
+    /**
+     * Allocates bounded scratch and indexes the three native expandable plug categories.
+     * @param specialCategories Category code for each native item index.
+     * @param itemDefinitions Complete installed item table.
+     * @return True when every bounded scratch bank is ready.
+     */
     [[nodiscard]] bool
     prepare(std::span<const std::uint8_t> specialCategories,
             std::span<const state::build_data::items::Definition> itemDefinitions) noexcept;
@@ -42,6 +47,9 @@ public:
     [[nodiscard]] std::size_t rule_count() const noexcept;
     [[nodiscard]] std::size_t pool_count() const noexcept;
     [[nodiscard]] std::size_t member_count() const noexcept;
+    [[nodiscard]] std::span<const socket_plugs::Rule> rules() const noexcept;
+    [[nodiscard]] std::span<const socket_plugs::Pool> pools() const noexcept;
+    [[nodiscard]] std::span<const socket_plugs::Member> members() const noexcept;
 
     /** Package-list visitor entry point; accepts only an in-range bounded native index. */
     [[nodiscard]] bool add(std::uint32_t itemDefinitionIndex,

+ 4 - 0
Sunrise/src/core/settings/parser.h

@@ -11,6 +11,8 @@
 
 namespace sunrise::core::settings::parser {
 
+struct ParserTestAccess;
+
 /**
  * Fixed-storage JSON reader for the supported Core settings.
  * Every parse below writes `output` only once its whole object is valid, and skips unknown keys.
@@ -25,6 +27,8 @@ public:
     [[nodiscard]] bool parse_root(Settings& output) noexcept;
 
 private:
+    friend struct ParserTestAccess;
+
     /** Parses the Core settings object. */
     [[nodiscard]] bool core(Settings& output) noexcept;
     /** Parses the activity SDK generation boot gate. `enabled` must be unique and boolean. */

+ 2 - 0
Sunrise/src/core/settings/settings.h

@@ -35,6 +35,8 @@ struct Settings {
      * every file written before versioning. Checked against kSettingsVersion at load.
      */
     std::uint32_t version{};
+    /** Completes released exotic weapon catalysts while resolving client item state. */
+    bool completeExoticCatalysts{true};
     /** Core-owned sink and channel policy. */
     log::Settings logging;
     /** Core-owned boot gate for activity SDK generation. */

+ 7 - 0
Sunrise/src/core/settings/settings_parser.cpp

@@ -18,6 +18,7 @@ bool Parser::parse_root(Settings& output) noexcept {
         return at_end();
     }
     bool hasVersion = false;
+    bool hasCompleteExoticCatalysts = false;
     bool hasCore = false;
     bool hasClient = false;
     bool hasServer = false;
@@ -36,6 +37,11 @@ bool Parser::parse_root(Settings& output) noexcept {
             }
             output.version = static_cast<std::uint32_t>(value);
             hasVersion = true;
+        } else if (key == "complete_exotic_catalysts") {
+            if (hasCompleteExoticCatalysts || !boolean(output.completeExoticCatalysts)) {
+                return false;
+            }
+            hasCompleteExoticCatalysts = true;
         } else if (key == "core") {
             if (hasCore || !core(output)) {
                 return false;
@@ -230,6 +236,7 @@ Settings defaults() noexcept {
     // Named members, so adding one to Settings cannot silently shift the rest.
     return Settings{
         .version = kSettingsVersion,
+        .completeExoticCatalysts = true,
         .logging = log::defaults(),
         .activitySdkGeneration = {},
         .server = server::Settings{state::entitlements::authored()},

+ 2 - 4
Sunrise/src/core/settings/state/inventory_parser.cpp

@@ -20,9 +20,6 @@ enum class ItemField : std::size_t {
     count,
 };
 
-/** The supported client exposes Locked and Tracked/Favorite as accumulated item-state bits. */
-constexpr std::uint32_t kSupportedItemStateMask = 0x3U;
-
 } // namespace
 
 /** Parses the optional equipment object with its fixed named slots. */
@@ -153,7 +150,8 @@ bool Parser::equipment_item(authored_inventory::Item& output) noexcept {
         } else if (key == "flags") {
             std::uint64_t flags = 0;
             if (!mark(ItemField::flags) || !unsigned_value(flags)
-                || flags > kSupportedItemStateMask) {
+                || flags > (std::numeric_limits<std::uint32_t>::max)()
+                || !authored_inventory::valid_item_state(static_cast<std::uint32_t>(flags))) {
                 return false;
             }
             parsed.flags = static_cast<std::uint32_t>(flags);

+ 4 - 3
Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_banks.cpp

@@ -93,7 +93,7 @@ void append_item_perks(const Equipped& equipped,
                        std::size_t& count) noexcept {
     append_perks(equipped.definitionIndex, bank, count);
     for (std::size_t lane = 0; lane < equipped.laneCount; ++lane) {
-        append_perks(equipped.plugs[lane], bank, count);
+        append_perks(resolve_effective_plug(equipped, lane), bank, count);
     }
 }
 
@@ -118,9 +118,10 @@ void apply_overflow_hashes(const family4::loadout::ResolvedInstances& instances,
         }
         for (std::size_t lane = 0; lane < equipped.laneCount && rankedCount < ranked.size();
              ++lane) {
+            const std::uint16_t effectivePlug = resolve_effective_plug(equipped, lane);
             details::Definition plug{};
-            if (equipped.plugs[lane] == details::kUnavailableItemIndex
-                || !state::build_data::find_configured_item_detail(equipped.plugs[lane], plug)) {
+            if (effectivePlug == details::kUnavailableItemIndex
+                || !state::build_data::find_configured_item_detail(effectivePlug, plug)) {
                 continue;
             }
             ranked[rankedCount++] = {priority_of(detail.socketTypes[lane]),

+ 9 - 0
Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_render.cpp

@@ -152,6 +152,15 @@ bool resolve_equipped(const family4::loadout::SlottedInstance& slotted,
     return true;
 }
 
+/** Resolves one socketed plug to the item row that supplies its perks and stats. */
+std::uint16_t resolve_effective_plug(const Equipped& equipped, std::size_t lane) noexcept {
+    if (lane >= equipped.laneCount || lane >= equipped.plugs.size()) {
+        return details::kUnavailableItemIndex;
+    }
+    return state::build_data::resolve_exotic_catalyst_effect(
+        equipped.definitionIndex, static_cast<std::uint8_t>(lane), equipped.plugs[lane]);
+}
+
 /** Fills each equipped render row with its instance, definition, art and material pairs. */
 bool apply_render(const family4::loadout::ResolvedInstances& instances,
                   state::CharacterClass characterClass,

+ 3 - 2
Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_stats.cpp

@@ -46,7 +46,7 @@ namespace constants = state::build_data::constants;
 [[nodiscard]] std::int32_t item_total(const Equipped& equipped, std::uint8_t row) noexcept {
     std::int32_t total = definition_total(equipped.definitionIndex, row);
     for (std::size_t lane = 0; lane < equipped.laneCount; ++lane) {
-        total += definition_total(equipped.plugs[lane], row);
+        total += definition_total(resolve_effective_plug(equipped, lane), row);
     }
     return total;
 }
@@ -63,7 +63,8 @@ collect_rows(const Equipped& equipped, std::span<std::uint8_t> rows, std::size_t
     const std::size_t lanes = equipped.laneCount + 1;
     for (std::size_t source = 0; source < lanes; ++source) {
         const std::uint16_t definitionIndex =
-            source == 0 ? equipped.definitionIndex : equipped.plugs[source - 1];
+            source == 0 ? equipped.definitionIndex
+                        : resolve_effective_plug(equipped, source - 1);
         details::Definition detail{};
         if (definitionIndex == details::kUnavailableItemIndex
             || !state::build_data::find_configured_item_detail(definitionIndex, detail)) {

+ 9 - 0
Sunrise/src/middleware/datagen/character_record/appearance/internal.h

@@ -53,6 +53,15 @@ select_art_arrangement(const details::Definition& detail,
                                     details::Definition& detail,
                                     Equipped& equipped) noexcept;
 
+/**
+ * Resolves one socket lane to the definition that supplies its native perks and stat changes.
+ * @param equipped Equipped base item and its visible socket plugs.
+ * @param lane Zero-based ordinary socket lane.
+ * @return Effective plug definition, or the unavailable sentinel for an invalid lane.
+ */
+[[nodiscard]] std::uint16_t resolve_effective_plug(const Equipped& equipped,
+                                                   std::size_t lane) noexcept;
+
 /** Fills every empty-valued field with the sentinel its reader tests for. */
 void apply_sentinels(layout::Appearance& appearance) noexcept;
 

+ 7 - 0
Sunrise/src/middleware/datagen/family4/loadout/loadout_item_resolver.cpp

@@ -162,6 +162,13 @@ bool resolve_item(const authored_inventory::Item& authored,
                                      candidate.item.instance.ordinarySockets)) {
         return false;
     }
+    if (state::build_data::complete_exotic_catalyst(
+            itemDefinition.definitionIndex,
+            candidate.item.flags,
+            candidate.item.instance.ordinarySockets.plugs)
+        == state::build_data::items::catalysts::ApplyResult::failed) {
+        return false;
+    }
 
     candidate.item.instance.instanceSoid = authored.instanceSoid;
     candidate.item.instance.bounds.itemDefinitionCount =

+ 1 - 1
Sunrise/src/middleware/web_service/messages/opcode406.h

@@ -19,7 +19,7 @@ struct Request {
 
 /**
  * Parses the exact reflected opcode-406 item-state descriptor.
- * The state value is biased from INT32_MIN, and only the two supported state bits are accepted.
+ * The state value is biased from INT32_MIN, and only the three supported state bits are accepted.
  * Fields are filled as far as the parse reaches, so a refused request still describes itself.
  * @param message Parsed Web Service envelope.
  * @param request Receives the instance, definition row, and unbiased state bits.

+ 3 - 4
Sunrise/src/middleware/web_service/messages/opcode406_codec.cpp

@@ -1,6 +1,7 @@
 #include <cstddef>
 
 #include "../../encoding/bit_reader.h"
+#include "../../../state/account/inventory/item_state.h"
 #include "opcode406.h"
 
 namespace sunrise::middleware::web_service::messages::opcode406 {
@@ -16,9 +17,6 @@ constexpr std::uint8_t kValueWidth = 32;
 constexpr std::uint8_t kPaddingWidth = 7;
 /** Nonnegative signed 32-bit values have this bit set after native descriptor biasing. */
 constexpr std::uint64_t kValueBias = 0x80000000ULL;
-/** Only the two lowest state bits are supported by this build. */
-constexpr std::uint64_t kSupportedStateBits = 0x3U;
-
 } // namespace
 
 /** Parses the exact native item-state descriptor. */
@@ -49,7 +47,8 @@ bool parse_request(const Message& message, Request& request) noexcept {
 
     return read && instancePresent != 0 && instanceSoid != 0 && definitionPresent != 0
            && encodedFlags >= kValueBias && padding == 0
-           && encodedFlags - kValueBias <= kSupportedStateBits;
+           && state::account::inventory::valid_item_state(
+               static_cast<std::uint32_t>(encodedFlags - kValueBias));
 }
 
 } // namespace sunrise::middleware::web_service::messages::opcode406

+ 2 - 5
Sunrise/src/state/account/inventory/inventory_state.h

@@ -6,6 +6,8 @@
 #include <optional>
 #include <string_view>
 
+#include "item_state.h"
+
 namespace sunrise::state::account::inventory {
 
 /** Authored equipment exposes the 16 named slots the first State supports. */
@@ -55,11 +57,6 @@ inline constexpr std::size_t kProfileItemCapacity = 701;
 inline constexpr std::size_t kProfileActionSourceCapacity = 100;
 /** Runtime-owned SOIDs for profile stacks use a namespace separate from created item instances. */
 inline constexpr std::uint64_t kFirstProfileItemInstanceSoid = 0x5000000000000001ULL;
-/**
- * Native item-state bit the Client sets to lock one item against destruction.
- * Confirmed against the installed build by three observed lock and unlock transitions.
- */
-inline constexpr std::uint32_t kLockedItemFlag = 0x1;
 /**
  * The 16 supported character equipment buckets reserve 151 native rows in this build. One row
 

+ 25 - 0
Sunrise/src/state/account/inventory/item_state.h

@@ -0,0 +1,25 @@
+#pragma once
+
+#include <cstdint>
+
+namespace sunrise::state::account::inventory {
+
+/** Native accumulated state bit that prevents item destruction. */
+inline constexpr std::uint32_t kLockedItemFlag = 0x1U;
+/** Native accumulated state bit that marks an item as tracked or favorite. */
+inline constexpr std::uint32_t kTrackedItemFlag = 0x2U;
+/** Native accumulated state bit that marks a completed masterwork. */
+inline constexpr std::uint32_t kMasterworkItemFlag = 0x4U;
+/** All accumulated item-state bits supported by the target build. */
+inline constexpr std::uint32_t kSupportedItemStateMask =
+    kLockedItemFlag | kTrackedItemFlag | kMasterworkItemFlag;
+
+/**
+ * @param flags Accumulated item-state value to check.
+ * @return True when flags contains only item-state bits supported by the target build.
+ */
+[[nodiscard]] constexpr bool valid_item_state(std::uint32_t flags) noexcept {
+    return (flags & ~kSupportedItemStateMask) == 0;
+}
+
+} // namespace sunrise::state::account::inventory

+ 6 - 2
Sunrise/src/state/build_data/build_data_runtime.cpp

@@ -12,6 +12,8 @@
 #include "constants/investment_constant_catalog.h"
 #include "hash_names/hash_name_catalog.h"
 #include "inventory/buckets/inventory_bucket_catalog.h"
+#include "items/catalysts/exotic_catalyst_builder.h"
+#include "items/catalysts/exotic_catalyst_catalog.h"
 #include "items/details/item_detail_catalog.h"
 #include "items/socket_plugs/socket_plug_catalog.h"
 #include "material_requirements/material_requirement_catalog.h"
@@ -56,7 +58,8 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept {
     if (!core::path::append(persistenceState.cacheDirectory, kCacheDirectorySuffix)
         || !core::path::append(persistenceState.cachePath, kCacheFileSuffix)
         || !cache::current_build_identity(configuredEquipmentHash,
-                                          persistenceState.buildIdentity)) {
+                                           persistenceState.buildIdentity)
+        || !items::catalysts::matches_target_build(persistenceState.buildIdentity)) {
         runtime::persistence::clear_locked(persistenceState);
         ReleaseSRWLockExclusive(&persistenceState.lock);
         return false;
@@ -106,7 +109,8 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept {
         // makes the lists ready, the package build skips itself, and no ability is picked.
         || !socket_entry_lists::replace_entry_tables(domains.socketEntryTables) || !detailsReplaced
         || !items::socket_plugs::replace(
-            domains.socketPlugRules, domains.socketPlugPools, domains.socketPlugMembers)
+             domains.socketPlugRules, domains.socketPlugPools, domains.socketPlugMembers)
+        || !items::catalysts::replace(domains.exoticCatalysts)
         || !abilities::replace(domains.abilityBuckets)
         || !progressions::replace(domains.progressions)
         // The layouts are what activity message 1 reads. Without them a cache hit makes the

+ 4 - 1
Sunrise/src/state/build_data/cache/read/cache_file_reader.cpp

@@ -13,7 +13,8 @@ namespace {
 [[nodiscard]] bool required_domains_present(const records::DomainCounts& counts) noexcept {
     return counts.named != 0 && counts.items != 0 && counts.collectibles != 0
            && counts.materialRequirementSets != 0 && counts.socketPlugRules != 0
-           && counts.socketPlugPools != 0 && counts.inventoryBuckets != 0
+           && counts.socketPlugPools != 0 && counts.exoticCatalysts != 0
+           && counts.inventoryBuckets != 0
            && counts.socketEntryLists != 0 && counts.progressions != 0 && counts.scenarios != 0
            && counts.rosterGroups != 0;
 }
@@ -28,6 +29,7 @@ namespace {
            && counts.socketPlugRules <= output.socketPlugRules.size()
            && counts.socketPlugPools <= output.socketPlugPools.size()
            && counts.socketPlugMembers <= output.socketPlugMembers.size()
+           && counts.exoticCatalysts <= output.exoticCatalysts.size()
            && counts.inventoryBuckets <= output.inventoryBuckets.size()
            && counts.socketEntryLists <= output.socketEntryLists.size()
            && counts.socketEntryTables <= output.socketEntryTables.size()
@@ -56,6 +58,7 @@ namespace {
         header.socketPlugRuleCount,
         header.socketPlugPoolCount,
         header.socketPlugMemberCount,
+        header.exoticCatalystCount,
         header.inventoryBucketCount,
         header.socketEntryListCount,
         header.socketEntryTableCount,

+ 12 - 4
Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp

@@ -72,8 +72,11 @@ void clear(records::MutableDomains output) noexcept {
     std::fill(
         output.socketPlugPools.begin(), output.socketPlugPools.end(), items::socket_plugs::Pool{});
     std::fill(output.socketPlugMembers.begin(),
-              output.socketPlugMembers.end(),
-              items::socket_plugs::Member{});
+               output.socketPlugMembers.end(),
+               items::socket_plugs::Member{});
+    std::fill(output.exoticCatalysts.begin(),
+              output.exoticCatalysts.end(),
+              items::catalysts::Definition{});
     std::fill(output.inventoryBuckets.begin(),
               output.inventoryBuckets.end(),
               inventory::buckets::Descriptor{});
@@ -112,6 +115,7 @@ bool expected_size(const records::DomainCounts& counts, std::uint64_t& size) noe
            && add_records(counts.socketPlugRules, sizeof(records::SocketPlugRuleRecord), size)
            && add_records(counts.socketPlugPools, sizeof(records::SocketPlugPoolRecord), size)
            && add_records(counts.socketPlugMembers, sizeof(records::SocketPlugMemberRecord), size)
+           && add_records(counts.exoticCatalysts, sizeof(records::ExoticCatalystRecord), size)
            && add_records(counts.inventoryBuckets, sizeof(records::InventoryBucketRecord), size)
            && add_records(counts.socketEntryLists, sizeof(records::SocketEntryListRecord), size)
            && add_records(counts.socketEntryTables, sizeof(records::SocketEntryTableRecord), size)
@@ -158,8 +162,11 @@ bool read_payload(HANDLE file,
             && read_domain<records::SocketPlugPoolRecord>(
                 file, output.socketPlugPools.first(counts.socketPlugPools), checksum);
     valid = valid
-            && read_domain<records::SocketPlugMemberRecord>(
-                file, output.socketPlugMembers.first(counts.socketPlugMembers), checksum);
+             && read_domain<records::SocketPlugMemberRecord>(
+                 file, output.socketPlugMembers.first(counts.socketPlugMembers), checksum);
+    valid = valid
+            && read_domain<records::ExoticCatalystRecord>(
+                file, output.exoticCatalysts.first(counts.exoticCatalysts), checksum);
     valid = valid
             && read_domain<records::InventoryBucketRecord>(
                 file, output.inventoryBuckets.first(counts.inventoryBuckets), checksum);
@@ -218,6 +225,7 @@ bool read_payload(HANDLE file,
         output.socketPlugRules.first(counts.socketPlugRules),
         output.socketPlugPools.first(counts.socketPlugPools),
         output.socketPlugMembers.first(counts.socketPlugMembers),
+        output.exoticCatalysts.first(counts.exoticCatalysts),
         output.inventoryBuckets.first(counts.inventoryBuckets),
         output.socketEntryLists.first(counts.socketEntryLists),
         output.socketEntryTables.first(counts.socketEntryTables),

+ 25 - 0
Sunrise/src/state/build_data/cache/records/cache_detail_links.cpp

@@ -1,6 +1,7 @@
 #include <algorithm>
 
 #include "../../items/details/item_detail_catalog.h"
+#include "../../items/catalysts/exotic_catalyst_builder.h"
 #include "../../items/socket_plugs/socket_plug_catalog.h"
 #include "validation.h"
 
@@ -109,4 +110,28 @@ bool valid_socket_plug_links(std::span<const items::socket_plugs::Rule> rules,
     });
 }
 
+/** Checks each catalyst against its item, detail, pool, plug, and pinned release state. */
+bool valid_exotic_catalyst_links(
+    std::span<const items::catalysts::Definition> catalysts,
+    std::span<const items::Definition> itemDefinitions,
+    std::span<const items::details::Definition> details,
+    std::span<const items::socket_plugs::Rule> rules,
+    std::span<const items::socket_plugs::Pool> pools,
+    std::span<const items::socket_plugs::Member> members) noexcept {
+    if (itemDefinitions.empty() || details.empty()
+        || !items::socket_plugs::valid(rules, pools, members)) {
+        return false;
+    }
+    const items::catalysts::Facts facts = items::catalysts::generated_facts();
+    const items::catalysts::Source source{
+        {facts.imageTimestamp, facts.imageSize, 0},
+        itemDefinitions,
+        details,
+        rules,
+        pools,
+        members,
+    };
+    return items::catalysts::matches_derived(source, facts, catalysts);
+}
+
 } // namespace sunrise::state::build_data::cache::records

+ 21 - 4
Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp

@@ -7,6 +7,7 @@
 #include "../../abilities/ability_bucket_catalog.h"
 #include "../../hash_names/hash_name_catalog.h"
 #include "../../inventory/buckets/inventory_bucket_catalog.h"
+#include "../../items/catalysts/exotic_catalyst_catalog.h"
 #include "../../items/details/item_detail_catalog.h"
 #include "../../items/socket_plugs/socket_plug_catalog.h"
 #include "../../material_requirements/material_requirement_catalog.h"
@@ -74,6 +75,12 @@ namespace {
            || (left.itemDefinitionIndex == right.itemDefinitionIndex && left.lane < right.lane);
 }
 
+/** @return Native item-index order for catalyst rows. */
+[[nodiscard]] bool catalyst_less(const items::catalysts::Definition& left,
+                                 const items::catalysts::Definition& right) noexcept {
+    return left.itemDefinitionIndex < right.itemDefinitionIndex;
+}
+
 /** @return Native definition-index order for item rows. */
 [[nodiscard]] bool item_less(const items::Definition& left,
                              const items::Definition& right) noexcept {
@@ -140,6 +147,7 @@ template <typename Value, typename Less>
            && counts.socketPlugRules <= domains.socketPlugRules.size()
            && counts.socketPlugPools <= domains.socketPlugPools.size()
            && counts.socketPlugMembers <= domains.socketPlugMembers.size()
+           && counts.exoticCatalysts <= domains.exoticCatalysts.size()
            && counts.inventoryBuckets <= domains.inventoryBuckets.size()
            && counts.socketEntryLists <= domains.socketEntryLists.size()
            && counts.socketEntryTables <= domains.socketEntryTables.size()
@@ -171,6 +179,7 @@ bool canonicalize(MutableDomains domains, const DomainCounts& counts) noexcept {
         domains.materialRequirementSets.first(counts.materialRequirementSets);
     const auto itemDetails = domains.itemDetails.first(counts.itemDetails);
     const auto socketPlugRules = domains.socketPlugRules.first(counts.socketPlugRules);
+    const auto exoticCatalysts = domains.exoticCatalysts.first(counts.exoticCatalysts);
     const auto inventoryBuckets = domains.inventoryBuckets.first(counts.inventoryBuckets);
     const auto socketEntryLists = domains.socketEntryLists.first(counts.socketEntryLists);
     std::sort(named.begin(), named.end(), named_less);
@@ -184,6 +193,7 @@ bool canonicalize(MutableDomains domains, const DomainCounts& counts) noexcept {
     if (!std::is_sorted(socketPlugRules.begin(), socketPlugRules.end(), socket_plug_rule_less)) {
         return false;
     }
+    std::sort(exoticCatalysts.begin(), exoticCatalysts.end(), catalyst_less);
     std::sort(inventoryBuckets.begin(), inventoryBuckets.end(), bucket_less);
     const auto abilityBuckets = domains.abilityBuckets.first(counts.abilityBuckets);
     const auto socketEntryTables = domains.socketEntryTables.first(counts.socketEntryTables);
@@ -201,8 +211,8 @@ bool valid_domains(Domains domains) noexcept {
         || domains.constants.weaponPowerStatRow >= constants::kStatRowCount || domains.named.empty()
         || domains.items.empty() || domains.collectibles.empty()
         || domains.materialRequirementSets.empty() || domains.socketPlugRules.empty()
-        || domains.socketPlugPools.empty() || domains.inventoryBuckets.empty()
-        || domains.socketEntryLists.empty()
+        || domains.socketPlugPools.empty() || domains.exoticCatalysts.empty()
+        || domains.inventoryBuckets.empty() || domains.socketEntryLists.empty()
         || !std::all_of(domains.named.begin(), domains.named.end(), valid_name)
         || !strictly_ordered(domains.named, named_less) || !items::valid(domains.items)
         || !collectibles::valid(domains.collectibles)
@@ -216,6 +226,7 @@ bool valid_domains(Domains domains) noexcept {
         || !strictly_ordered(domains.itemDetails, detail_less)
         || !items::socket_plugs::valid(
             domains.socketPlugRules, domains.socketPlugPools, domains.socketPlugMembers)
+        || !items::catalysts::valid(domains.exoticCatalysts)
         || !abilities::valid(domains.abilityBuckets)
         || !strictly_ordered(domains.abilityBuckets, ability_less)
         || !progressions::valid(domains.progressions)
@@ -265,8 +276,14 @@ bool valid_domains(Domains domains) noexcept {
            && valid_socket_plug_links(domains.socketPlugRules,
                                       domains.socketPlugPools,
                                       domains.socketPlugMembers,
-                                      domains.items,
-                                      domains.itemDetails);
+                                       domains.items,
+                                       domains.itemDetails)
+           && valid_exotic_catalyst_links(domains.exoticCatalysts,
+                                           domains.items,
+                                           domains.itemDetails,
+                                           domains.socketPlugRules,
+                                           domains.socketPlugPools,
+                                           domains.socketPlugMembers);
 }
 
 } // namespace sunrise::state::build_data::cache::records

+ 43 - 0
Sunrise/src/state/build_data/cache/records/cache_exotic_catalyst_record_codec.cpp

@@ -0,0 +1,43 @@
+#include "codec.h"
+
+namespace sunrise::state::build_data::cache::records {
+
+bool encode(const items::catalysts::Definition& value,
+            ExoticCatalystRecord& record) noexcept {
+    record = {};
+    if (value.reserved != 0
+        || (value.availability != items::catalysts::Availability::released
+            && value.availability != items::catalysts::Availability::placeholder
+            && value.availability != items::catalysts::Availability::unsupported)) {
+        return false;
+    }
+    record.itemDefinitionHash = value.itemDefinitionHash;
+    record.itemDefinitionIndex = value.itemDefinitionIndex;
+    record.completedPlugDefinitionIndex = value.completedPlugDefinitionIndex;
+    record.effectDefinitionIndex = value.effectDefinitionIndex;
+    record.socketLane = value.socketLane;
+    record.availability = static_cast<std::uint8_t>(value.availability);
+    return true;
+}
+
+bool decode(const ExoticCatalystRecord& record,
+            items::catalysts::Definition& value) noexcept {
+    value = {};
+    const auto availability = static_cast<items::catalysts::Availability>(record.availability);
+    if (record.reserved != 0
+        || (availability != items::catalysts::Availability::released
+            && availability != items::catalysts::Availability::placeholder
+            && availability != items::catalysts::Availability::unsupported)) {
+        return false;
+    }
+    value = {record.itemDefinitionHash,
+             record.itemDefinitionIndex,
+             record.completedPlugDefinitionIndex,
+             record.effectDefinitionIndex,
+             record.socketLane,
+             availability,
+             0};
+    return true;
+}
+
+} // namespace sunrise::state::build_data::cache::records

+ 16 - 0
Sunrise/src/state/build_data/cache/records/codec.h

@@ -137,6 +137,22 @@ namespace sunrise::state::build_data::cache::records {
 [[nodiscard]] bool decode(const SocketPlugMemberRecord& record,
                           items::socket_plugs::Member& value) noexcept;
 
+/**
+ * @param value Runtime catalyst relation to pack.
+ * @param record Receives the canonical disk form.
+ * @return True when the runtime relation has a supported availability value.
+ */
+[[nodiscard]] bool encode(const items::catalysts::Definition& value,
+                          ExoticCatalystRecord& record) noexcept;
+
+/**
+ * @param record Canonical disk form to unpack.
+ * @param value Receives the runtime catalyst relation.
+ * @return True when the disk form has a supported availability value.
+ */
+[[nodiscard]] bool decode(const ExoticCatalystRecord& record,
+                          items::catalysts::Definition& value) noexcept;
+
 /**
  * @param value Runtime row to pack.
  * @param record Receives the packed disk row.

+ 4 - 0
Sunrise/src/state/build_data/cache/records/domains.h

@@ -8,6 +8,7 @@
 #include "../../collectibles/collectible_catalog.h"
 #include "../../hash_names/definition.h"
 #include "../../inventory/buckets/definition.h"
+#include "../../items/catalysts/definition.h"
 #include "../../items/details/definition.h"
 #include "../../items/item_catalog.h"
 #include "../../items/socket_plugs/definition.h"
@@ -31,6 +32,7 @@ struct DomainCounts {
     std::size_t socketPlugRules{};
     std::size_t socketPlugPools{};
     std::size_t socketPlugMembers{};
+    std::size_t exoticCatalysts{};
     std::size_t inventoryBuckets{};
     std::size_t socketEntryLists{};
     std::size_t socketEntryTables{};
@@ -60,6 +62,7 @@ struct MutableDomains {
     std::span<items::socket_plugs::Rule> socketPlugRules;
     std::span<items::socket_plugs::Pool> socketPlugPools;
     std::span<items::socket_plugs::Member> socketPlugMembers;
+    std::span<items::catalysts::Definition> exoticCatalysts;
     std::span<inventory::buckets::Descriptor> inventoryBuckets;
     std::span<socket_entry_lists::Definition> socketEntryLists;
     std::span<socket_entry_lists::EntryTable> socketEntryTables;
@@ -88,6 +91,7 @@ struct Domains {
     std::span<const items::socket_plugs::Rule> socketPlugRules;
     std::span<const items::socket_plugs::Pool> socketPlugPools;
     std::span<const items::socket_plugs::Member> socketPlugMembers;
+    std::span<const items::catalysts::Definition> exoticCatalysts;
     std::span<const inventory::buckets::Descriptor> inventoryBuckets;
     std::span<const socket_entry_lists::Definition> socketEntryLists;
     std::span<const socket_entry_lists::EntryTable> socketEntryTables;

+ 17 - 1
Sunrise/src/state/build_data/cache/records/format.h

@@ -11,6 +11,7 @@
 #include "../../constants/definition.h"
 #include "../../definition.h"
 #include "../../hash_names/definition.h"
+#include "../../items/catalysts/definition.h"
 #include "../../items/details/definition.h"
 #include "../../items/item_catalog.h"
 #include "../../items/socket_plugs/definition.h"
@@ -74,6 +75,7 @@ struct Header {
     std::uint32_t socketPlugRuleCount{};
     std::uint32_t socketPlugPoolCount{};
     std::uint32_t socketPlugMemberCount{};
+    std::uint32_t exoticCatalystCount{};
     std::uint32_t inventoryBucketCount{};
     std::uint32_t socketEntryListCount{};
     std::uint32_t socketEntryTableCount{};
@@ -203,6 +205,18 @@ struct SocketPlugMemberRecord {
     std::uint16_t itemDefinitionIndex{};
 };
 
+/** Disk form of one build-derived exotic weapon catalyst relation. */
+struct ExoticCatalystRecord {
+    std::uint32_t itemDefinitionHash{};
+    std::uint16_t itemDefinitionIndex{};
+    std::uint16_t completedPlugDefinitionIndex{};
+    std::uint16_t effectDefinitionIndex{};
+    std::uint8_t socketLane{};
+    std::uint8_t availability{};
+    /** Must remain zero so all unused bytes have one canonical value. */
+    std::uint16_t reserved{};
+};
+
 /** Disk form of one inventory-bucket array-routing descriptor. */
 struct InventoryBucketRecord {
     std::uint8_t bucketId{};
@@ -423,7 +437,7 @@ static_assert(sizeof(Prefix) == kCacheMagic.size() + sizeof(std::uint32_t));
 static_assert(sizeof(InvestmentConstants)
               == constants::kCharacterStatRowCount + 3 * sizeof(std::uint8_t));
 static_assert(sizeof(Header)
-              == kCacheMagic.size() + 26 * sizeof(std::uint32_t) + 2 * sizeof(std::uint64_t)
+              == kCacheMagic.size() + 27 * sizeof(std::uint32_t) + 2 * sizeof(std::uint64_t)
                      + sizeof(InvestmentConstants));
 static_assert(sizeof(SpawnPointRecord)
               == spawn_sets::kPositionComponents * sizeof(float) + sizeof(std::uint32_t)
@@ -493,6 +507,8 @@ static_assert(sizeof(SocketPlugRuleRecord)
               == sizeof(std::uint16_t) + 2 * sizeof(std::uint8_t) + sizeof(std::uint32_t));
 static_assert(sizeof(SocketPlugPoolRecord) == 2 * sizeof(std::uint32_t));
 static_assert(sizeof(SocketPlugMemberRecord) == sizeof(std::uint16_t));
+static_assert(sizeof(ExoticCatalystRecord)
+              == sizeof(std::uint32_t) + 4 * sizeof(std::uint16_t) + 2 * sizeof(std::uint8_t));
 static_assert(sizeof(InventoryBucketRecord)
               == 4 * sizeof(std::uint8_t) + 2 * sizeof(std::uint16_t));
 static_assert(sizeof(SocketEntryListRecord)

+ 19 - 1
Sunrise/src/state/build_data/cache/records/validation.h

@@ -36,7 +36,25 @@ valid_socket_plug_links(std::span<const items::socket_plugs::Rule> rules,
                         std::span<const items::socket_plugs::Pool> pools,
                         std::span<const items::socket_plugs::Member> members,
                         std::span<const items::Definition> itemDefinitions,
-                        std::span<const items::details::Definition> details) noexcept;
+                         std::span<const items::details::Definition> details) noexcept;
+
+/**
+ * Checks catalyst item, socket, plug, lifecycle, and pinned release links.
+ * @param catalysts Complete candidate catalyst catalog.
+ * @param itemDefinitions Complete installed item table.
+ * @param details Complete configured item-detail table.
+ * @param rules Complete item-and-lane socket rule table.
+ * @param pools Complete interned socket plug pools.
+ * @param members Complete socket plug pool members.
+ * @return True when a fresh derivation exactly matches the candidate catalog.
+ */
+[[nodiscard]] bool valid_exotic_catalyst_links(
+    std::span<const items::catalysts::Definition> catalysts,
+    std::span<const items::Definition> itemDefinitions,
+    std::span<const items::details::Definition> details,
+    std::span<const items::socket_plugs::Rule> rules,
+    std::span<const items::socket_plugs::Pool> pools,
+    std::span<const items::socket_plugs::Member> members) noexcept;
 
 /**
  * Checks every collectible item index against the complete dense item table.

+ 1 - 0
Sunrise/src/state/build_data/cache/write/cache_file_writer.cpp

@@ -24,6 +24,7 @@ namespace {
            && domains.socketPlugRules.size() <= kMaximumCount
            && domains.socketPlugPools.size() <= kMaximumCount
            && domains.socketPlugMembers.size() <= kMaximumCount
+           && domains.exoticCatalysts.size() <= kMaximumCount
            && domains.inventoryBuckets.size() <= kMaximumCount
            && domains.socketEntryLists.size() <= kMaximumCount
            && domains.socketEntryTables.size() <= kMaximumCount

+ 2 - 0
Sunrise/src/state/build_data/cache/write/cache_payload_writer.cpp

@@ -72,6 +72,7 @@ bool payload_checksum(records::Domains domains, std::uint64_t& checksum) noexcep
            && checksum_domain<records::SocketPlugRuleRecord>(domains.socketPlugRules, checksum)
            && checksum_domain<records::SocketPlugPoolRecord>(domains.socketPlugPools, checksum)
            && checksum_domain<records::SocketPlugMemberRecord>(domains.socketPlugMembers, checksum)
+           && checksum_domain<records::ExoticCatalystRecord>(domains.exoticCatalysts, checksum)
            && checksum_domain<records::InventoryBucketRecord>(domains.inventoryBuckets, checksum)
            && checksum_domain<records::SocketEntryListRecord>(domains.socketEntryLists, checksum)
            && checksum_domain<records::SocketEntryTableRecord>(domains.socketEntryTables, checksum)
@@ -101,6 +102,7 @@ bool write_payload(HANDLE file, records::Domains domains) noexcept {
            && write_domain<records::SocketPlugRuleRecord>(file, domains.socketPlugRules)
            && write_domain<records::SocketPlugPoolRecord>(file, domains.socketPlugPools)
            && write_domain<records::SocketPlugMemberRecord>(file, domains.socketPlugMembers)
+           && write_domain<records::ExoticCatalystRecord>(file, domains.exoticCatalysts)
            && write_domain<records::InventoryBucketRecord>(file, domains.inventoryBuckets)
            && write_domain<records::SocketEntryListRecord>(file, domains.socketEntryLists)
            && write_domain<records::SocketEntryTableRecord>(file, domains.socketEntryTables)

+ 1 - 0
Sunrise/src/state/build_data/cache/write/temporary/temporary_cache_file.cpp

@@ -114,6 +114,7 @@ enum class WriteStatus {
         static_cast<std::uint32_t>(domains.socketPlugRules.size()),
         static_cast<std::uint32_t>(domains.socketPlugPools.size()),
         static_cast<std::uint32_t>(domains.socketPlugMembers.size()),
+        static_cast<std::uint32_t>(domains.exoticCatalysts.size()),
         static_cast<std::uint32_t>(domains.inventoryBuckets.size()),
         static_cast<std::uint32_t>(domains.socketEntryLists.size()),
         static_cast<std::uint32_t>(domains.socketEntryTables.size()),

+ 108 - 0
Sunrise/src/state/build_data/items/catalysts/definition.h

@@ -0,0 +1,108 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <string_view>
+
+namespace sunrise::state::build_data::items::catalysts {
+
+/** The target build contains fewer than 128 exotic weapon catalyst-shaped sockets. */
+inline constexpr std::size_t kDefinitionCapacity = 128;
+
+/** Release status from the build-scoped Season 11 availability data. */
+enum class Availability : std::uint8_t {
+    released = 0,
+    placeholder = 1,
+    unsupported = 2,
+};
+
+/** Safe catalog and resolver outcomes. */
+enum class Error : std::uint8_t {
+    none = 0,
+    noCatalyst,
+    placeholderOnly,
+    unsupportedBuild,
+    missingReleased,
+    ambiguousLifecycle,
+    invalidSocket,
+    invalidPlug,
+    invalidEffect,
+    invalidItemState,
+};
+
+/**
+ * @param error Catalog or item-resolution result.
+ * @return Stable log name for the result.
+ */
+[[nodiscard]] constexpr std::string_view error_name(Error error) noexcept {
+    switch (error) {
+    case Error::none:
+        return "none";
+    case Error::noCatalyst:
+        return "no_catalyst";
+    case Error::placeholderOnly:
+        return "placeholder_only";
+    case Error::unsupportedBuild:
+        return "unsupported_build";
+    case Error::missingReleased:
+        return "missing_released";
+    case Error::ambiguousLifecycle:
+        return "ambiguous_lifecycle";
+    case Error::invalidSocket:
+        return "invalid_socket";
+    case Error::invalidPlug:
+        return "invalid_plug";
+    case Error::invalidEffect:
+        return "invalid_effect";
+    case Error::invalidItemState:
+        return "invalid_item_state";
+    }
+    return "unknown";
+}
+
+/** One build-derived exotic weapon catalyst relation. */
+struct Definition {
+    std::uint32_t itemDefinitionHash{};
+    std::uint16_t itemDefinitionIndex{};
+    /** Unsupported rows use the unavailable item-index value. */
+    std::uint16_t completedPlugDefinitionIndex{};
+    /** Item row that supplies the completed catalyst's native perks and stat changes. */
+    std::uint16_t effectDefinitionIndex{};
+    std::uint8_t socketLane{};
+    Availability availability{Availability::unsupported};
+    /** Must remain zero so the cache form stays canonical. */
+    std::uint16_t reserved{};
+};
+
+/** The only state a released catalyst exposes to callers. */
+struct CompletedCatalyst {
+    std::uint8_t socketLane{};
+    std::uint16_t completedPlugDefinitionIndex{};
+    std::uint16_t effectDefinitionIndex{};
+};
+
+/** One resolver result. Errors never carry a completed state. */
+struct Result {
+    Error error{Error::noCatalyst};
+    Availability availability{Availability::unsupported};
+    CompletedCatalyst completed{};
+};
+
+/** Whole-catalog counts plus the first unsafe released relation. */
+struct Report {
+    std::size_t released{};
+    std::size_t placeholder{};
+    std::size_t unsupported{};
+    Error error{Error::none};
+    std::uint32_t itemDefinitionHash{};
+    std::uint8_t socketLane{};
+};
+
+/** Result of the one catalog-aware catalyst item change. */
+enum class ApplyResult : std::uint8_t {
+    unchanged = 0,
+    completed,
+    failed,
+};
+
+} // namespace sunrise::state::build_data::items::catalysts

+ 155 - 0
Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_build_data_runtime.cpp

@@ -0,0 +1,155 @@
+#include "../../runtime.h"
+
+#include <Windows.h>
+
+#include <algorithm>
+#include <array>
+
+#include "../../runtime/persistence/build_data_persistence.h"
+#include "../../runtime/persistence/publication_transaction.h"
+#include "../details/item_detail_catalog.h"
+#include "../item_catalog.h"
+#include "../socket_plugs/socket_plug_catalog.h"
+#include "exotic_catalyst_builder.h"
+#include "exotic_catalyst_catalog.h"
+
+namespace sunrise::state::build_data {
+namespace {
+
+/** Checks one derived record against all published source domains. */
+[[nodiscard]] bool valid_publication(
+    std::span<const items::catalysts::Definition> definitions) noexcept {
+    if (!items::catalysts::valid(definitions) || !items::catalysts::matches_target_build(
+                                                   runtime::persistence::context().buildIdentity)) {
+        return false;
+    }
+    const items::catalysts::Facts facts = items::catalysts::generated_facts();
+    std::array<bool, items::catalysts::kDefinitionCapacity> releasedFound{};
+    for (const items::catalysts::Definition& catalyst : definitions) {
+        items::Definition item{};
+        items::Definition completedPlug{};
+        items::Definition effect{};
+        items::details::Definition detail{};
+        items::details::Definition effectDetail{};
+        const auto released = std::lower_bound(facts.releasedWeaponHashes.begin(),
+                                               facts.releasedWeaponHashes.end(),
+                                               catalyst.itemDefinitionHash);
+        const bool isReleased = released != facts.releasedWeaponHashes.end()
+                                && *released == catalyst.itemDefinitionHash;
+        if (isReleased) {
+            const std::size_t index = static_cast<std::size_t>(
+                released - facts.releasedWeaponHashes.begin());
+            if (releasedFound[index]
+                || catalyst.availability != items::catalysts::Availability::released) {
+                return false;
+            }
+            releasedFound[index] = true;
+        } else if (catalyst.availability == items::catalysts::Availability::released) {
+            return false;
+        }
+        if (!items::find_index(catalyst.itemDefinitionIndex, item)
+            || item.definitionHash != catalyst.itemDefinitionHash
+            || !items::details::find(catalyst.itemDefinitionIndex, detail)
+            || detail.definitionHash != catalyst.itemDefinitionHash
+            || catalyst.socketLane >= detail.ordinarySocketCount) {
+            return false;
+        }
+        if (catalyst.availability != items::catalysts::Availability::unsupported
+            && !items::socket_plugs::allowed(catalyst.itemDefinitionIndex,
+                                              catalyst.socketLane,
+                                              catalyst.completedPlugDefinitionIndex)) {
+            return false;
+        }
+        if (catalyst.availability != items::catalysts::Availability::unsupported
+            && (!items::find_index(catalyst.completedPlugDefinitionIndex, completedPlug)
+                || !items::find_index(catalyst.effectDefinitionIndex, effect)
+                || !items::details::find(catalyst.effectDefinitionIndex, effectDetail)
+                || effectDetail.definitionHash != effect.definitionHash
+                || completedPlug.plugCategoryHash == 0
+                || completedPlug.plugCategoryHash != effect.plugCategoryHash
+                || effect.tier != static_cast<std::uint8_t>(items::Tier::exotic)
+                || effectDetail.instancedDefinitionState
+                       != items::details::InstancedDefinitionState::stackable
+                || (effectDetail.sandboxPerkCount == 0 && effectDetail.statCount == 0))) {
+            return false;
+        }
+    }
+    return std::all_of(releasedFound.begin(),
+                       releasedFound.begin() + facts.releasedWeaponHashes.size(),
+                       [](bool found) { return found; });
+}
+
+} // namespace
+
+bool exotic_catalysts_ready() noexcept {
+    return items::catalysts::count() != 0;
+}
+
+bool derive_exotic_catalysts(
+    std::span<const items::Definition> itemDefinitions,
+    std::span<const items::details::Definition> itemDetails,
+    std::span<const items::socket_plugs::Rule> socketPlugRules,
+    std::span<const items::socket_plugs::Pool> socketPlugPools,
+    std::span<const items::socket_plugs::Member> socketPlugMembers,
+    std::span<items::catalysts::Definition> output,
+    std::size_t& count,
+    items::catalysts::Report& report) noexcept {
+    runtime::persistence::Context& state = runtime::persistence::context();
+    AcquireSRWLockShared(&state.lock);
+    const BuildIdentity build = state.buildIdentity;
+    const bool enabled = state.enabled;
+    ReleaseSRWLockShared(&state.lock);
+    if (!enabled) {
+        count = 0;
+        report = {};
+        report.error = items::catalysts::Error::unsupportedBuild;
+        report.unsupported = 1;
+        return false;
+    }
+    const items::catalysts::Source source{build,
+                                          itemDefinitions,
+                                          itemDetails,
+                                          socketPlugRules,
+                                          socketPlugPools,
+                                          socketPlugMembers};
+    return items::catalysts::derive(
+        source, items::catalysts::generated_facts(), output, count, report);
+}
+
+bool publish_exotic_catalysts(
+    std::span<const items::catalysts::Definition> definitions) noexcept {
+    runtime::persistence::Transaction transaction;
+    if (!transaction.active() || !valid_publication(definitions)) {
+        return false;
+    }
+    return transaction.finish(items::catalysts::replace(definitions), items::catalysts::clear);
+}
+
+items::catalysts::ApplyResult complete_exotic_catalyst(
+    std::uint16_t itemDefinitionIndex,
+    std::uint32_t& flags,
+    std::span<std::optional<std::uint16_t>> plugs) noexcept {
+    return items::catalysts::apply_completed(itemDefinitionIndex, flags, plugs);
+}
+
+std::uint16_t resolve_exotic_catalyst_effect(std::uint16_t itemDefinitionIndex,
+                                             std::uint8_t socketLane,
+                                             std::uint16_t plugDefinitionIndex) noexcept {
+    return items::catalysts::resolve_effect(
+        itemDefinitionIndex, socketLane, plugDefinitionIndex);
+}
+
+bool is_exotic_catalyst_lane(std::uint16_t itemDefinitionIndex,
+                              std::uint8_t socketLane) noexcept {
+    return items::catalysts::owns_lane(itemDefinitionIndex, socketLane);
+}
+
+items::catalysts::Report exotic_catalyst_report() noexcept {
+    return items::catalysts::report();
+}
+
+void set_exotic_catalyst_completion_enabled(bool enabled) noexcept {
+    items::catalysts::set_completion_enabled(enabled);
+}
+
+} // namespace sunrise::state::build_data

+ 426 - 0
Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_builder.cpp

@@ -0,0 +1,426 @@
+#include "exotic_catalyst_builder.h"
+
+#include <algorithm>
+#include <array>
+#include <functional>
+#include <optional>
+
+namespace sunrise::state::build_data::items::catalysts {
+namespace {
+
+/** Native equipment slots 7, 8, and 9 are kinetic, energy, and heavy weapons. */
+constexpr std::int8_t kFirstWeaponSlot = 7;
+constexpr std::int8_t kLastWeaponSlot = 9;
+
+struct LaneResult {
+    bool catalyst{};
+    Error error{Error::none};
+    std::uint16_t completedPlugDefinitionIndex{};
+    std::uint16_t effectDefinitionIndex{};
+};
+
+/**
+ * @param hashes Sorted hashes to validate.
+ * @return True when the table is strict and contains no zero hash.
+ */
+[[nodiscard]] bool valid_hashes(std::span<const std::uint32_t> hashes) noexcept {
+    return std::none_of(
+               hashes.begin(), hashes.end(), [](std::uint32_t hash) { return hash == 0; })
+           && std::adjacent_find(hashes.begin(), hashes.end(), std::greater_equal{})
+                  == hashes.end();
+}
+
+/**
+ * Finds a source item without requiring a dense test fixture.
+ * @param definitions Source item definitions in native index order.
+ * @param index Native item index to find.
+ * @return The matching item, or null when no item has the index.
+ */
+[[nodiscard]] const items::Definition*
+find_item(std::span<const items::Definition> definitions, std::uint16_t index) noexcept {
+    if (static_cast<std::size_t>(index) < definitions.size()
+        && definitions[index].definitionIndex == index) {
+        return &definitions[index];
+    }
+    const auto found = std::lower_bound(
+        definitions.begin(),
+        definitions.end(),
+        index,
+        [](const items::Definition& value, auto key) { return value.definitionIndex < key; });
+    return found != definitions.end() && found->definitionIndex == index ? &*found : nullptr;
+}
+
+/**
+ * Finds a source detail without requiring a dense test fixture.
+ * @param definitions Source item details in native index order.
+ * @param index Native item index to find.
+ * @return The matching detail, or null when no detail has the index.
+ */
+[[nodiscard]] const details::Definition*
+find_detail(std::span<const details::Definition> definitions, std::uint16_t index) noexcept {
+    if (static_cast<std::size_t>(index) < definitions.size()
+        && definitions[index].definitionIndex == index) {
+        return &definitions[index];
+    }
+    const auto found = std::lower_bound(
+        definitions.begin(),
+        definitions.end(),
+        index,
+        [](const details::Definition& value, auto key) { return value.definitionIndex < key; });
+    return found != definitions.end() && found->definitionIndex == index ? &*found : nullptr;
+}
+
+/**
+ * Resolves the exotic item row that owns one completed plug's native perks and stat changes.
+ * Later catalysts use that item as their socket plug. Legacy sockets use a display-only plug in
+ * the same category, so the unique exotic stackable item in that category supplies the effect.
+ * @param source Parsed target-build tables.
+ * @param completedPlugDefinitionIndex Completed socket plug.
+ * @return Completed lane relation, or an effect mapping error.
+ */
+[[nodiscard]] LaneResult complete_lane(const Source& source,
+                                       std::uint16_t completedPlugDefinitionIndex) noexcept {
+    const items::Definition* completed = find_item(source.items, completedPlugDefinitionIndex);
+    if (completed == nullptr || completed->plugCategoryHash == 0) {
+        return {true, Error::invalidEffect, completedPlugDefinitionIndex, 0};
+    }
+
+    std::optional<std::uint16_t> effect;
+    for (const items::Definition& candidate : source.items) {
+        if (candidate.plugCategoryHash != completed->plugCategoryHash
+            || candidate.tier != static_cast<std::uint8_t>(items::Tier::exotic)) {
+            continue;
+        }
+        const details::Definition* detail = find_detail(source.details, candidate.definitionIndex);
+        if (detail == nullptr || detail->definitionHash != candidate.definitionHash
+            || detail->instancedDefinitionState
+                   != details::InstancedDefinitionState::stackable
+            || (detail->sandboxPerkCount == 0 && detail->statCount == 0)) {
+            continue;
+        }
+        if (effect.has_value()) {
+            return {true, Error::invalidEffect, completedPlugDefinitionIndex, 0};
+        }
+        effect = candidate.definitionIndex;
+    }
+    return effect.has_value()
+               ? LaneResult{true,
+                            Error::none,
+                            completedPlugDefinitionIndex,
+                            *effect}
+               : LaneResult{true, Error::invalidEffect, completedPlugDefinitionIndex, 0};
+}
+
+/**
+ * Finds one exact item and lane rule in its canonical order.
+ * @param rules Socket rules in item and lane order.
+ * @param itemDefinitionIndex Native item index to find.
+ * @param lane Native socket lane to find.
+ * @return The matching rule, or null when no rule matches.
+ */
+[[nodiscard]] const socket_plugs::Rule*
+find_rule(std::span<const socket_plugs::Rule> rules,
+          std::uint16_t itemDefinitionIndex,
+          std::uint8_t lane) noexcept {
+    const auto found = std::lower_bound(
+        rules.begin(),
+        rules.end(),
+        std::pair{itemDefinitionIndex, lane},
+        [](const socket_plugs::Rule& value, const auto& key) {
+            return value.itemDefinitionIndex < key.first
+                   || (value.itemDefinitionIndex == key.first && value.lane < key.second);
+        });
+    return found != rules.end() && found->itemDefinitionIndex == itemDefinitionIndex
+                   && found->lane == lane
+               ? &*found
+               : nullptr;
+}
+
+/**
+ * @param released Sorted released weapon hashes.
+ * @param hash Weapon hash to find.
+ * @return Index of a released hash, or no value for a placeholder.
+ */
+[[nodiscard]] std::optional<std::size_t>
+released_index(std::span<const std::uint32_t> released, std::uint32_t hash) noexcept {
+    const auto found = std::lower_bound(released.begin(), released.end(), hash);
+    if (found == released.end() || *found != hash) {
+        return std::nullopt;
+    }
+    return static_cast<std::size_t>(found - released.begin());
+}
+
+/**
+ * Classifies one lane when its pool has a target-build catalyst marker.
+ * @param source Parsed target-build tables.
+ * @param facts Pinned facts for the target build.
+ * @param detail Item detail row that owns the lane.
+ * @param lane Native socket lane to classify.
+ * @return The catalyst state and any safe-failure reason.
+ */
+[[nodiscard]] LaneResult classify_lane(const Source& source,
+                                       const Facts& facts,
+                                       const details::Definition& detail,
+                                       std::uint8_t lane) noexcept {
+    const socket_plugs::Rule* rule =
+        find_rule(source.socketPlugRules, detail.definitionIndex, lane);
+    if (rule == nullptr) {
+        return {};
+    }
+    if (rule->reserved != 0 || rule->poolIndex >= source.socketPlugPools.size()) {
+        return {true, Error::invalidSocket, 0, 0};
+    }
+    const socket_plugs::Pool& pool = source.socketPlugPools[rule->poolIndex];
+    if (pool.memberOffset > source.socketPlugMembers.size()
+        || pool.memberCount > source.socketPlugMembers.size() - pool.memberOffset) {
+        return {true, Error::invalidSocket, 0, 0};
+    }
+
+    const auto members = source.socketPlugMembers.subspan(pool.memberOffset, pool.memberCount);
+    bool hasEmpty = false;
+    std::size_t legacyCount = 0;
+    std::uint16_t legacyCompletionIndex = 0;
+    bool invalidMember = false;
+    for (const std::uint16_t member : members) {
+        const items::Definition* plug = find_item(source.items, member);
+        if (plug == nullptr) {
+            invalidMember = true;
+            continue;
+        }
+        if (plug->definitionHash == facts.emptyCatalystPlugHash) {
+            hasEmpty = true;
+        }
+        if (std::binary_search(facts.legacyCompletionPlugHashes.begin(),
+                               facts.legacyCompletionPlugHashes.end(),
+                               plug->definitionHash)) {
+            ++legacyCount;
+            legacyCompletionIndex = member;
+        }
+    }
+    if (!hasEmpty && legacyCount == 0) {
+        return {};
+    }
+    if (invalidMember) {
+        return {true, Error::invalidPlug, 0, 0};
+    }
+
+    const std::uint16_t defaultIndex = detail.initialPlugIndices[lane];
+    const items::Definition* defaultPlug = find_item(source.items, defaultIndex);
+    if (defaultIndex == details::kUnavailableItemIndex || defaultPlug == nullptr
+        || std::find(members.begin(), members.end(), defaultIndex) == members.end()) {
+        return {true, Error::invalidPlug, 0, 0};
+    }
+
+    if (members.size() == 2 && hasEmpty && legacyCount == 0
+        && defaultPlug->definitionHash == facts.emptyCatalystPlugHash) {
+        const auto active =
+            std::find_if(members.begin(), members.end(), [defaultIndex](auto member) {
+                return member != defaultIndex;
+            });
+        return active != members.end() ? complete_lane(source, *active)
+                                       : LaneResult{true, Error::ambiguousLifecycle, 0, 0};
+    }
+    if (members.size() == 3 && !hasEmpty && legacyCount == 1
+        && legacyCompletionIndex != defaultIndex) {
+        return complete_lane(source, legacyCompletionIndex);
+    }
+    return {true, Error::ambiguousLifecycle, 0, 0};
+}
+
+/**
+ * Clears staged output and records one safe failure.
+ * @param output Staged catalog rows to clear.
+ * @param count Staged row count to reset.
+ * @param report Build report to update.
+ * @param error Failure reason.
+ * @param itemDefinitionHash Item hash that caused the failure, if known.
+ * @param lane Socket lane that caused the failure, if known.
+ * @return Always false.
+ */
+[[nodiscard]] bool fail(std::span<Definition> output,
+                        std::size_t& count,
+                        Report& report,
+                        Error error,
+                        std::uint32_t itemDefinitionHash = 0,
+                        std::uint8_t lane = 0) noexcept {
+    std::fill(output.begin(), output.end(), Definition{});
+    count = 0;
+    ++report.unsupported;
+    report.error = error;
+    report.itemDefinitionHash = itemDefinitionHash;
+    report.socketLane = lane;
+    return false;
+}
+
+/**
+ * @param item Source item row.
+ * @param detail Source item detail row.
+ * @return True for an exotic in one of the three weapon equipment slots.
+ */
+[[nodiscard]] bool exotic_weapon(const items::Definition& item,
+                                  const details::Definition& detail) noexcept {
+    return item.tier == static_cast<std::uint8_t>(items::Tier::exotic)
+           && detail.instancedDefinitionState == details::InstancedDefinitionState::instanced
+           && detail.equipmentSlot.has_value() && *detail.equipmentSlot >= kFirstWeaponSlot
+           && *detail.equipmentSlot <= kLastWeaponSlot
+           && detail.ordinarySocketState == details::OrdinarySocketState::present
+           && detail.ordinarySocketCount <= details::kInitialPlugCapacity;
+}
+
+} // namespace
+
+bool derive(const Source& source,
+            const Facts& facts,
+            std::span<Definition> output,
+            std::size_t& count,
+            Report& report) noexcept {
+    count = 0;
+    report = {};
+    std::fill(output.begin(), output.end(), Definition{});
+    if (facts.imageTimestamp == 0 || facts.imageSize == 0 || facts.emptyCatalystPlugHash == 0
+        || facts.legacyCompletionPlugHashes.size() > kDefinitionCapacity
+        || facts.releasedWeaponHashes.size() > kDefinitionCapacity
+        || !valid_hashes(facts.legacyCompletionPlugHashes)
+        || !valid_hashes(facts.releasedWeaponHashes)) {
+        return fail(output, count, report, Error::unsupportedBuild);
+    }
+    if (source.build.imageTimestamp != facts.imageTimestamp
+        || source.build.imageSize != facts.imageSize) {
+        return fail(output, count, report, Error::unsupportedBuild);
+    }
+
+    std::array<bool, kDefinitionCapacity> releasedFound{};
+    for (const details::Definition& detail : source.details) {
+        const items::Definition* item = find_item(source.items, detail.definitionIndex);
+        if (item == nullptr || item->definitionHash != detail.definitionHash
+            || !exotic_weapon(*item, detail)) {
+            continue;
+        }
+
+        std::optional<std::size_t> release =
+            released_index(facts.releasedWeaponHashes, item->definitionHash);
+        std::optional<CompletedCatalyst> completed;
+        std::optional<Error> unclear;
+        std::uint8_t detectedLane = 0;
+        for (std::size_t laneIndex = 0; laneIndex < detail.ordinarySocketCount; ++laneIndex) {
+            const auto lane = static_cast<std::uint8_t>(laneIndex);
+            const LaneResult result = classify_lane(source, facts, detail, lane);
+            if (!result.catalyst) {
+                continue;
+            }
+            detectedLane = lane;
+            if (result.error != Error::none) {
+                unclear = result.error;
+                continue;
+            }
+            if (completed.has_value()) {
+                unclear = Error::ambiguousLifecycle;
+                continue;
+            }
+            completed = CompletedCatalyst{
+                lane, result.completedPlugDefinitionIndex, result.effectDefinitionIndex};
+        }
+        if (!completed.has_value() && !unclear.has_value()) {
+            continue;
+        }
+        if (unclear.has_value()) {
+            if (release.has_value()) {
+                return fail(output,
+                            count,
+                            report,
+                            *unclear,
+                            item->definitionHash,
+                            detectedLane);
+            }
+            if (count >= output.size() || count >= kDefinitionCapacity) {
+                return fail(output,
+                            count,
+                            report,
+                            Error::invalidSocket,
+                            item->definitionHash,
+                            detectedLane);
+            }
+            output[count++] = Definition{item->definitionHash,
+                                         item->definitionIndex,
+                                         details::kUnavailableItemIndex,
+                                         details::kUnavailableItemIndex,
+                                         detectedLane,
+                                         Availability::unsupported,
+                                         0};
+            ++report.unsupported;
+            continue;
+        }
+        if (count >= output.size() || count >= kDefinitionCapacity) {
+            return fail(output,
+                        count,
+                        report,
+                        Error::invalidSocket,
+                        item->definitionHash,
+                        completed->socketLane);
+        }
+        if (release.has_value()) {
+            if (releasedFound[*release]) {
+                return fail(output,
+                            count,
+                            report,
+                            Error::ambiguousLifecycle,
+                            item->definitionHash,
+                            completed->socketLane);
+            }
+            releasedFound[*release] = true;
+            ++report.released;
+        } else {
+            ++report.placeholder;
+        }
+        output[count++] = Definition{item->definitionHash,
+                                     item->definitionIndex,
+                                     completed->completedPlugDefinitionIndex,
+                                     completed->effectDefinitionIndex,
+                                     completed->socketLane,
+                                     release.has_value() ? Availability::released
+                                                         : Availability::placeholder,
+                                     0};
+    }
+
+    for (std::size_t index = 0; index < facts.releasedWeaponHashes.size(); ++index) {
+        if (!releasedFound[index]) {
+            return fail(output,
+                        count,
+                        report,
+                        Error::missingReleased,
+                        facts.releasedWeaponHashes[index]);
+        }
+    }
+    std::sort(output.begin(), output.begin() + count, [](const Definition& left,
+                                                         const Definition& right) {
+        return left.itemDefinitionIndex < right.itemDefinitionIndex;
+    });
+    return true;
+}
+
+bool matches_derived(const Source& source,
+                     const Facts& facts,
+                     std::span<const Definition> definitions) noexcept {
+    std::array<Definition, kDefinitionCapacity> expected{};
+    std::size_t expectedCount = 0;
+    Report report{};
+    if (!derive(source, facts, expected, expectedCount, report)
+        || expectedCount != definitions.size()) {
+        return false;
+    }
+    return std::equal(
+        expected.begin(),
+        expected.begin() + expectedCount,
+        definitions.begin(),
+        [](const Definition& left, const Definition& right) {
+            return left.itemDefinitionHash == right.itemDefinitionHash
+                   && left.itemDefinitionIndex == right.itemDefinitionIndex
+                   && left.completedPlugDefinitionIndex == right.completedPlugDefinitionIndex
+                   && left.effectDefinitionIndex == right.effectDefinitionIndex
+                   && left.socketLane == right.socketLane
+                   && left.availability == right.availability
+                   && left.reserved == right.reserved;
+        });
+}
+
+} // namespace sunrise::state::build_data::items::catalysts

+ 70 - 0
Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_builder.h

@@ -0,0 +1,70 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../definition.h"
+#include "../details/definition.h"
+#include "../item_catalog.h"
+#include "../socket_plugs/definition.h"
+#include "definition.h"
+
+namespace sunrise::state::build_data::items::catalysts {
+
+/** Build-scoped facts that package structure cannot identify safely. */
+struct Facts {
+    std::uint32_t imageTimestamp{};
+    std::uint32_t imageSize{};
+    std::uint32_t emptyCatalystPlugHash{};
+    std::span<const std::uint32_t> legacyCompletionPlugHashes;
+    std::span<const std::uint32_t> releasedWeaponHashes;
+};
+
+/** Installed domains used to derive exact item, socket, and completed-plug relations. */
+struct Source {
+    BuildIdentity build;
+    std::span<const items::Definition> items;
+    std::span<const details::Definition> details;
+    std::span<const socket_plugs::Rule> socketPlugRules;
+    std::span<const socket_plugs::Pool> socketPlugPools;
+    std::span<const socket_plugs::Member> socketPlugMembers;
+};
+
+/** @return The generated facts pinned to Destiny 2 build 86657.20.08.23. */
+[[nodiscard]] Facts generated_facts() noexcept;
+
+/**
+ * @param build Executable identity to compare with the generated content build.
+ * @return True when the executable identity matches the generated content build.
+ */
+[[nodiscard]] bool matches_target_build(const BuildIdentity& build) noexcept;
+
+/**
+ * Derives all released and placeholder catalyst records without display text.
+ * On failure, count is zero and no partial record is visible.
+ * @param source Installed build domains and executable identity.
+ * @param facts Build-scoped role and release facts.
+ * @param output Fixed storage that receives the complete catalog.
+ * @param count Receives the number of used output rows.
+ * @param report Receives counts and the first unsafe released relation.
+ * @return True when all released relations are clear and the catalog fits.
+ */
+[[nodiscard]] bool derive(const Source& source,
+                          const Facts& facts,
+                          std::span<Definition> output,
+                          std::size_t& count,
+                          Report& report) noexcept;
+
+/**
+ * Re-derives a catalog and compares every row with a stored candidate.
+ * @param source Installed build domains and executable identity.
+ * @param facts Build-scoped role and release facts.
+ * @param definitions Candidate catalog to verify.
+ * @return True when the candidate exactly matches one safe derivation.
+ */
+[[nodiscard]] bool matches_derived(const Source& source,
+                                   const Facts& facts,
+                                   std::span<const Definition> definitions) noexcept;
+
+} // namespace sunrise::state::build_data::items::catalysts

+ 198 - 0
Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_catalog.cpp

@@ -0,0 +1,198 @@
+#include "exotic_catalyst_catalog.h"
+
+#include <algorithm>
+#include <atomic>
+
+#include "../../../account/inventory/item_state.h"
+#include "../../table.h"
+#include "../details/definition.h"
+
+namespace sunrise::state::build_data::items::catalysts {
+namespace {
+
+Lock g_lock;
+Table<Definition, kDefinitionCapacity> g_definitions;
+Report g_report;
+std::atomic<bool> g_completionEnabled{true};
+
+/**
+ * @param left First catalyst definition.
+ * @param right Second catalyst definition.
+ * @return True when the first native item index is less than the second.
+ */
+[[nodiscard]] bool less(const Definition& left, const Definition& right) noexcept {
+    return left.itemDefinitionIndex < right.itemDefinitionIndex;
+}
+
+/**
+ * Finds one item in a sorted catalog while its lock is held.
+ * @param definitions Catalyst definitions in native item index order.
+ * @param itemDefinitionIndex Native item index to find.
+ * @return The matching definition, or null when no definition matches.
+ */
+[[nodiscard]] const Definition*
+find(std::span<const Definition> definitions, std::uint16_t itemDefinitionIndex) noexcept {
+    const auto found = std::lower_bound(
+        definitions.begin(),
+        definitions.end(),
+        itemDefinitionIndex,
+        [](const Definition& value, auto key) { return value.itemDefinitionIndex < key; });
+    return found != definitions.end() && found->itemDefinitionIndex == itemDefinitionIndex
+               ? &*found
+               : nullptr;
+}
+
+} // namespace
+
+void clear() noexcept {
+    const Lock::Exclusive guard(g_lock);
+    g_definitions.clear();
+    g_report = {};
+    g_completionEnabled.store(true, std::memory_order_release);
+}
+
+void set_completion_enabled(bool enabled) noexcept {
+    g_completionEnabled.store(enabled, std::memory_order_release);
+}
+
+bool completion_enabled() noexcept {
+    return g_completionEnabled.load(std::memory_order_acquire);
+}
+
+bool valid(std::span<const Definition> definitions) noexcept {
+    if (definitions.empty() || definitions.size() > kDefinitionCapacity) {
+        return false;
+    }
+    for (std::size_t index = 0; index < definitions.size(); ++index) {
+        const Definition& definition = definitions[index];
+        const bool hasCompletedPlug =
+            definition.completedPlugDefinitionIndex != details::kUnavailableItemIndex;
+        const bool hasEffect =
+            definition.effectDefinitionIndex != details::kUnavailableItemIndex;
+        if (definition.itemDefinitionHash == 0
+            || definition.socketLane >= details::kInitialPlugCapacity
+            || definition.reserved != 0
+            || (definition.availability != Availability::released
+                && definition.availability != Availability::placeholder
+                && definition.availability != Availability::unsupported)
+            || (definition.availability == Availability::unsupported
+                && (hasCompletedPlug || hasEffect))
+            || (definition.availability != Availability::unsupported
+                && (!hasCompletedPlug || !hasEffect))
+            || (index != 0 && !less(definitions[index - 1], definition))) {
+            return false;
+        }
+    }
+    return true;
+}
+
+bool replace(std::span<const Definition> definitions) noexcept {
+    if (!valid(definitions)) {
+        return false;
+    }
+    Report nextReport{};
+    for (const Definition& definition : definitions) {
+        if (definition.availability == Availability::released) {
+            ++nextReport.released;
+        } else if (definition.availability == Availability::placeholder) {
+            ++nextReport.placeholder;
+        } else {
+            ++nextReport.unsupported;
+        }
+    }
+    const Lock::Exclusive guard(g_lock);
+    if (!g_definitions.replace(definitions)) {
+        return false;
+    }
+    g_report = nextReport;
+    return true;
+}
+
+Result resolve(std::uint16_t itemDefinitionIndex) noexcept {
+    const Lock::Shared guard(g_lock);
+    const Definition* definition = find(g_definitions.rows(), itemDefinitionIndex);
+    if (definition == nullptr) {
+        return {};
+    }
+    if (definition->availability == Availability::placeholder) {
+        return {Error::placeholderOnly, Availability::placeholder, {}};
+    }
+    if (definition->availability != Availability::released) {
+        return {Error::ambiguousLifecycle, Availability::unsupported, {}};
+    }
+    return {Error::none,
+            Availability::released,
+            {definition->socketLane,
+             definition->completedPlugDefinitionIndex,
+             definition->effectDefinitionIndex}};
+}
+
+std::uint16_t resolve_effect(std::uint16_t itemDefinitionIndex,
+                             std::uint8_t socketLane,
+                             std::uint16_t plugDefinitionIndex) noexcept {
+    const Lock::Shared guard(g_lock);
+    const Definition* definition = find(g_definitions.rows(), itemDefinitionIndex);
+    if (definition == nullptr || definition->availability != Availability::released
+        || definition->socketLane != socketLane
+        || definition->completedPlugDefinitionIndex != plugDefinitionIndex
+        || definition->effectDefinitionIndex == details::kUnavailableItemIndex) {
+        return plugDefinitionIndex;
+    }
+    return definition->effectDefinitionIndex;
+}
+
+bool owns_lane(std::uint16_t itemDefinitionIndex, std::uint8_t socketLane) noexcept {
+    const Lock::Shared guard(g_lock);
+    const Definition* definition = find(g_definitions.rows(), itemDefinitionIndex);
+    return definition != nullptr && definition->socketLane == socketLane;
+}
+
+ApplyResult apply_completed(std::uint16_t itemDefinitionIndex,
+                            std::uint32_t& flags,
+                            std::span<std::optional<std::uint16_t>> plugs) noexcept {
+    if (!completion_enabled()) {
+        return ApplyResult::unchanged;
+    }
+    const Result result = resolve(itemDefinitionIndex);
+    if (result.error == Error::noCatalyst || result.availability != Availability::released) {
+        return ApplyResult::unchanged;
+    }
+    if (result.error != Error::none
+        || !account::inventory::valid_item_state(flags)
+        || result.completed.socketLane >= plugs.size()
+        || result.completed.completedPlugDefinitionIndex == details::kUnavailableItemIndex) {
+        return ApplyResult::failed;
+    }
+
+    const std::uint32_t completedFlags = flags | account::inventory::kMasterworkItemFlag;
+    const std::optional<std::uint16_t> completedPlug =
+        result.completed.completedPlugDefinitionIndex;
+    plugs[result.completed.socketLane] = completedPlug;
+    flags = completedFlags;
+    return ApplyResult::completed;
+}
+
+bool snapshot(std::span<Definition> output,
+              std::size_t& outputCount,
+              Report& outputReport) noexcept {
+    outputCount = 0;
+    outputReport = {};
+    const Lock::Shared guard(g_lock);
+    if (!g_definitions.snapshot(output, outputCount)) {
+        return false;
+    }
+    outputReport = g_report;
+    return true;
+}
+
+std::size_t count() noexcept {
+    const Lock::Shared guard(g_lock);
+    return g_definitions.count();
+}
+
+Report report() noexcept {
+    const Lock::Shared guard(g_lock);
+    return g_report;
+}
+
+} // namespace sunrise::state::build_data::items::catalysts

+ 88 - 0
Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_catalog.h

@@ -0,0 +1,88 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <optional>
+#include <span>
+
+#include "definition.h"
+
+namespace sunrise::state::build_data::items::catalysts {
+
+/** Clears all build-derived catalyst records and their report. */
+void clear() noexcept;
+
+/** @param enabled True to complete released catalysts during item resolution. */
+void set_completion_enabled(bool enabled) noexcept;
+
+/** @return The current global default-completion policy. */
+[[nodiscard]] bool completion_enabled() noexcept;
+
+/**
+ * @param definitions Complete candidate catalog in native item-index order.
+ * @return True when all rows and unavailable fields are canonical.
+ */
+[[nodiscard]] bool valid(std::span<const Definition> definitions) noexcept;
+
+/**
+ * @param definitions Complete validated catalog in native item-index order.
+ * @return True when the catalog fits fixed storage and replaces the old rows.
+ */
+[[nodiscard]] bool replace(std::span<const Definition> definitions) noexcept;
+
+/**
+ * @param itemDefinitionIndex Native weapon definition index.
+ * @return Released state, safe skip state, or absence for the item.
+ */
+[[nodiscard]] Result resolve(std::uint16_t itemDefinitionIndex) noexcept;
+
+/**
+ * Resolves the item row that supplies a socketed catalyst's native perks and stat changes.
+ * Direct catalyst plugs resolve to themselves. Legacy display plugs resolve to their linked
+ * effect item. Unreleased, incomplete, and non-catalyst plugs stay unchanged.
+ * @param itemDefinitionIndex Native weapon definition index.
+ * @param socketLane Ordinary socket lane.
+ * @param plugDefinitionIndex Socketed plug definition index.
+ * @return Effective item definition index for perk and stat output.
+ */
+[[nodiscard]] std::uint16_t resolve_effect(std::uint16_t itemDefinitionIndex,
+                                           std::uint8_t socketLane,
+                                           std::uint16_t plugDefinitionIndex) noexcept;
+
+/**
+ * @param itemDefinitionIndex Native weapon definition index.
+ * @param socketLane Ordinary socket lane.
+ * @return True when the catalog owns this item socket lane.
+ */
+[[nodiscard]] bool owns_lane(std::uint16_t itemDefinitionIndex, std::uint8_t socketLane) noexcept;
+
+/**
+ * Applies a released catalyst plug and Masterwork bit as one checked item change.
+ * Placeholder and non-catalyst items stay unchanged.
+ * @param itemDefinitionIndex Native item definition index.
+ * @param flags Candidate accumulated item-state bits.
+ * @param plugs Candidate ordinary socket plugs.
+ * @return Completed, unchanged, or failed without a partial change.
+ */
+[[nodiscard]] ApplyResult
+apply_completed(std::uint16_t itemDefinitionIndex,
+                std::uint32_t& flags,
+                std::span<std::optional<std::uint16_t>> plugs) noexcept;
+
+/**
+ * Copies the complete catalog under its shared lock.
+ * @param output Caller-owned fixed catalog storage.
+ * @param count Receives the number of copied rows.
+ * @param report Receives the published catalog counts.
+ * @return False only when output cannot hold the complete catalog.
+ */
+[[nodiscard]] bool
+snapshot(std::span<Definition> output, std::size_t& count, Report& report) noexcept;
+
+/** @return Published catalyst record count. */
+[[nodiscard]] std::size_t count() noexcept;
+
+/** @return Counts for the current complete catalog. */
+[[nodiscard]] Report report() noexcept;
+
+} // namespace sunrise::state::build_data::items::catalysts

+ 51 - 0
Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_generated.cpp

@@ -0,0 +1,51 @@
+#include "exotic_catalyst_builder.h"
+
+#include <array>
+
+namespace sunrise::state::build_data::items::catalysts {
+namespace {
+
+/** PE identity of the supported 86657.20.08.23 client. */
+constexpr std::uint32_t kImageTimestamp = 0x5F43138BU;
+constexpr std::uint32_t kImageSize = 0x08A5EA00U;
+/** Later catalysts use this build's shared empty socket plug. */
+constexpr std::uint32_t kEmptyCatalystPlugHash = 0x5957A904U;
+
+/** Legacy completed-plug facts generated from the pinned target manifest. */
+constexpr std::array<std::uint32_t, 30> kLegacyCompletionPlugHashes{
+    0x151E1554U, 0x151E1555U, 0x174B3FEBU, 0x206EDFE0U, 0x206EDFE1U,
+    0x288A777DU, 0x2FB02CF0U, 0x32F442E6U, 0x4FE33F81U, 0x6096F61AU,
+    0x6096F61BU, 0x61935831U, 0x641204BFU, 0x69A468F9U, 0x70B8A117U,
+    0x7D463B2FU, 0x7FB372AAU, 0x8808888CU, 0x8808888DU, 0x8F90F557U,
+    0x9C8C0A61U, 0xA651C900U, 0xAA5EEFD0U, 0xAA5EEFD1U, 0xC9C0E4C0U,
+    0xE2CB93CBU, 0xE3700214U, 0xE681F877U, 0xFC5C45A8U, 0xFC5C45A9U,
+};
+
+/** Released Season 11 catalyst facts, in definition-hash order. */
+constexpr std::array<std::uint32_t, 45> kReleasedWeaponHashes{
+    0x012248BAU, 0x14B465B2U, 0x17D8FEABU, 0x2E43BDEEU, 0x3092080DU,
+    0x4F5CCF1DU, 0x50384F32U, 0x50384F33U, 0x5141601FU, 0x59EFED62U,
+    0x5BDBCC56U, 0x634C6957U, 0x6F22FCECU, 0x70BEF156U, 0x83A19696U,
+    0x8843C72AU, 0x8C8180D6U, 0x8CD074B1U, 0x93E680C3U, 0xA7DBFF3AU,
+    0xAA45882AU, 0xAD4746D4U, 0xAD4746D5U, 0xB824C63DU, 0xBB46CCD2U,
+    0xBB46CCD3U, 0xBF704917U, 0xCB6F6266U, 0xCB7B5EDFU, 0xCCE7D927U,
+    0xD15517D4U, 0xD38BCABAU, 0xD38BCABBU, 0xD5704484U, 0xD5704485U,
+    0xD84E04AAU, 0xD84E04ABU, 0xE0794C51U, 0xE5296126U, 0xE86A25CFU,
+    0xEF7D3366U, 0xF0923C79U, 0xF5DE4480U, 0xF9C0B6B0U, 0xFDA23E68U,
+};
+
+} // namespace
+
+Facts generated_facts() noexcept {
+    return {kImageTimestamp,
+            kImageSize,
+            kEmptyCatalystPlugHash,
+            kLegacyCompletionPlugHashes,
+            kReleasedWeaponHashes};
+}
+
+bool matches_target_build(const BuildIdentity& build) noexcept {
+    return build.imageTimestamp == kImageTimestamp && build.imageSize == kImageSize;
+}
+
+} // namespace sunrise::state::build_data::items::catalysts

+ 72 - 0
Sunrise/src/state/build_data/runtime.h

@@ -3,6 +3,7 @@
 #include <array>
 #include <cstddef>
 #include <cstdint>
+#include <optional>
 #include <span>
 #include <string_view>
 
@@ -12,6 +13,7 @@
 #include "definition.h"
 #include "hash_names/definition.h"
 #include "inventory/buckets/definition.h"
+#include "items/catalysts/definition.h"
 #include "items/details/definition.h"
 #include "items/item_catalog.h"
 #include "items/socket_plugs/definition.h"
@@ -189,6 +191,76 @@ publish_socket_plug_rules(std::span<const items::socket_plugs::Rule> rules,
 /** @return True when one plug definition occurs in any installed ordinary-socket plug pool. */
 [[nodiscard]] bool is_socket_plug_pooled(std::uint16_t plugDefinitionIndex) noexcept;
 
+/** @return True when the build-scoped exotic catalyst catalog is ready. */
+[[nodiscard]] bool exotic_catalysts_ready() noexcept;
+
+/**
+ * Derives released and placeholder catalysts from one complete package pass.
+ * The active PE identity supplies the build fingerprint.
+ * @param itemDefinitions Complete installed item table.
+ * @param itemDetails Complete configured item-detail table.
+ * @param socketPlugRules Complete item-and-lane rule table.
+ * @param socketPlugPools Complete interned plug-pool table.
+ * @param socketPlugMembers Complete plug-pool member table.
+ * @param output Fixed storage that receives the catalyst catalog.
+ * @param count Receives the used output row count.
+ * @param report Receives catalog counts and the first unsafe released relation.
+ * @return True when the complete catalog is safe for publication.
+ */
+[[nodiscard]] bool derive_exotic_catalysts(
+    std::span<const items::Definition> itemDefinitions,
+    std::span<const items::details::Definition> itemDetails,
+    std::span<const items::socket_plugs::Rule> socketPlugRules,
+    std::span<const items::socket_plugs::Pool> socketPlugPools,
+    std::span<const items::socket_plugs::Member> socketPlugMembers,
+    std::span<items::catalysts::Definition> output,
+    std::size_t& count,
+    items::catalysts::Report& report) noexcept;
+
+/**
+ * @param definitions Complete build-derived catalyst catalog.
+ * @return True when validation, publication, and any due cache write succeed.
+ */
+[[nodiscard]] bool publish_exotic_catalysts(
+    std::span<const items::catalysts::Definition> definitions) noexcept;
+
+/**
+ * @param itemDefinitionIndex Native item definition index.
+ * @param flags Candidate accumulated item-state bits.
+ * @param plugs Candidate ordinary socket plugs.
+ * @return Completed, unchanged, or failed without a partial change.
+ */
+[[nodiscard]] items::catalysts::ApplyResult complete_exotic_catalyst(
+    std::uint16_t itemDefinitionIndex,
+    std::uint32_t& flags,
+    std::span<std::optional<std::uint16_t>> plugs) noexcept;
+
+/**
+ * Resolves the item row that supplies one socketed catalyst's native perks and stat changes.
+ * @param itemDefinitionIndex Native weapon definition index.
+ * @param socketLane Ordinary socket lane.
+ * @param plugDefinitionIndex Socketed plug definition index.
+ * @return Effective definition index, or the input plug when no released completion matches.
+ */
+[[nodiscard]] std::uint16_t resolve_exotic_catalyst_effect(
+    std::uint16_t itemDefinitionIndex,
+    std::uint8_t socketLane,
+    std::uint16_t plugDefinitionIndex) noexcept;
+
+/**
+ * @param itemDefinitionIndex Native item definition index.
+ * @param socketLane Ordinary socket lane.
+ * @return True when the catalog owns this catalyst socket lane.
+ */
+[[nodiscard]] bool is_exotic_catalyst_lane(std::uint16_t itemDefinitionIndex,
+                                            std::uint8_t socketLane) noexcept;
+
+/** @return Counts and status for the published catalyst catalog. */
+[[nodiscard]] items::catalysts::Report exotic_catalyst_report() noexcept;
+
+/** @param enabled True to complete released catalysts during item resolution. */
+void set_exotic_catalyst_completion_enabled(bool enabled) noexcept;
+
 /**
  * Answers whether one profile definition needs an item-instance resident so the native socket
  * action route can materialize it. Only stackable installed socket plugs in the supported mod and

+ 2 - 0
Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp

@@ -6,6 +6,7 @@
 #include "../constants/investment_constant_catalog.h"
 #include "../hash_names/hash_name_catalog.h"
 #include "../inventory/buckets/inventory_bucket_catalog.h"
+#include "../items/catalysts/exotic_catalyst_catalog.h"
 #include "../items/details/item_detail_catalog.h"
 #include "../items/socket_plugs/socket_plug_catalog.h"
 #include "../material_requirements/material_requirement_catalog.h"
@@ -365,6 +366,7 @@ void clear_catalogs() noexcept {
     items::details::clear();
     details::clear();
     items::socket_plugs::clear();
+    items::catalysts::clear();
     inventory::buckets::clear();
     socket_entry_lists::clear();
     rollback_ability_publication();

+ 14 - 0
Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp

@@ -14,6 +14,7 @@
 #include "../../constants/investment_constant_catalog.h"
 #include "../../hash_names/hash_name_catalog.h"
 #include "../../inventory/buckets/inventory_bucket_catalog.h"
+#include "../../items/catalysts/exotic_catalyst_catalog.h"
 #include "../../items/details/item_detail_catalog.h"
 #include "../../items/socket_plugs/socket_plug_catalog.h"
 #include "../../material_requirements/material_requirement_catalog.h"
@@ -64,6 +65,7 @@ to_record(const constants::InvestmentConstants& value) noexcept {
     counts = {};
     const cache::records::MutableDomains scratch = scratch_domains(state);
     *scratch.constants = to_record(constants::snapshot());
+    items::catalysts::Report catalystReport{};
     return content::snapshot(scratch.named, counts.named)
            && items::snapshot(scratch.items, counts.items)
            && collectibles::snapshot(scratch.collectibles, counts.collectibles)
@@ -76,6 +78,8 @@ to_record(const constants::InvestmentConstants& value) noexcept {
                                             counts.socketPlugPools,
                                             scratch.socketPlugMembers,
                                             counts.socketPlugMembers)
+           && items::catalysts::snapshot(
+               scratch.exoticCatalysts, counts.exoticCatalysts, catalystReport)
            && inventory::buckets::snapshot(scratch.inventoryBuckets, counts.inventoryBuckets)
            && socket_entry_lists::snapshot(scratch.socketEntryLists, counts.socketEntryLists)
            && socket_entry_lists::snapshot_entry_tables(scratch.socketEntryTables,
@@ -108,6 +112,7 @@ bool all_domains_ready() noexcept {
     constants::InvestmentConstants published{};
     return runtime::named::ready() && item_definitions_ready() && configured_item_details_ready()
            && collectible_definitions_ready() && socket_plug_rules_ready()
+           && exotic_catalysts_ready()
            && material_requirement_sets_ready() && inventory_bucket_descriptors_ready()
            && socket_entry_lists_ready() && ability_buckets_ready()
            && progression_definitions_ready() && scenario_layouts_ready() && spawn_sets_ready()
@@ -139,6 +144,10 @@ cache::records::MutableDomains scratch_domains(Context& state) noexcept {
     const auto socketPlugMembers = ensure_scratch<build_data::items::socket_plugs::Member,
                                                   build_data::items::socket_plugs::kMemberCapacity>(
         state.socketPlugMemberScratch);
+    const auto exoticCatalysts =
+        ensure_scratch<build_data::items::catalysts::Definition,
+                       build_data::items::catalysts::kDefinitionCapacity>(
+            state.exoticCatalystScratch);
     const auto inventoryBuckets =
         ensure_scratch<inventory::buckets::Descriptor, inventory::buckets::kDescriptorCapacity>(
             state.inventoryBucketScratch);
@@ -188,6 +197,7 @@ cache::records::MutableDomains scratch_domains(Context& state) noexcept {
         socketPlugRules,
         socketPlugPools,
         socketPlugMembers,
+        exoticCatalysts,
         inventoryBuckets,
         socketEntryLists,
         socketEntryTables,
@@ -225,6 +235,7 @@ void release_scratch_locked(Context& state) noexcept {
     release_bank(state.socketPlugRuleScratch);
     release_bank(state.socketPlugPoolScratch);
     release_bank(state.socketPlugMemberScratch);
+    release_bank(state.exoticCatalystScratch);
     release_bank(state.inventoryBucketScratch);
     release_bank(state.socketEntryListScratch);
     release_bank(state.socketEntryTableScratch);
@@ -265,6 +276,8 @@ cache::records::Domains occupied_domains(Context& state,
         state.socketPlugPoolScratch.data(), counts.socketPlugPools};
     const std::span<const items::socket_plugs::Member> socketPlugMembers{
         state.socketPlugMemberScratch.data(), counts.socketPlugMembers};
+    const std::span<const items::catalysts::Definition> exoticCatalysts{
+        state.exoticCatalystScratch.data(), counts.exoticCatalysts};
     return {
         state.constantsScratch,
         std::span<const content::Definition>{state.namedScratch.data(), counts.named},
@@ -277,6 +290,7 @@ cache::records::Domains occupied_domains(Context& state,
         socketPlugRules,
         socketPlugPools,
         socketPlugMembers,
+        exoticCatalysts,
         std::span<const inventory::buckets::Descriptor>{state.inventoryBucketScratch.data(),
                                                         counts.inventoryBuckets},
         std::span<const socket_entry_lists::Definition>{state.socketEntryListScratch.data(),

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

@@ -14,6 +14,7 @@
 #include "../../definition.h"
 #include "../../hash_names/definition.h"
 #include "../../inventory/buckets/definition.h"
+#include "../../items/catalysts/definition.h"
 #include "../../items/details/definition.h"
 #include "../../items/item_catalog.h"
 #include "../../items/socket_plugs/definition.h"
@@ -37,6 +38,7 @@ struct Context {
     std::vector<items::socket_plugs::Rule> socketPlugRuleScratch{};
     std::vector<items::socket_plugs::Pool> socketPlugPoolScratch{};
     std::vector<items::socket_plugs::Member> socketPlugMemberScratch{};
+    std::vector<items::catalysts::Definition> exoticCatalystScratch{};
     std::vector<inventory::buckets::Descriptor> inventoryBucketScratch{};
     std::vector<socket_entry_lists::Definition> socketEntryListScratch{};
     std::vector<socket_entry_lists::EntryTable> socketEntryTableScratch{};

+ 4 - 4
Sunrise/src/state/runtime/state_account_socket_runtime.cpp

@@ -159,6 +159,9 @@ void report_socket_plug(std::string_view stage,
             targetDefinition.definitionIndex, socketLane, plugDefinitionIndex)) {
         return fail("definition_or_compatibility");
     }
+    if (build_data::is_exotic_catalyst_lane(targetDefinition.definitionIndex, socketLane)) {
+        return fail("catalyst_lane_requires_atomic_change");
+    }
 
     // Ownership only matters where the plug is a finite supply the account draws down. A shader is
     // one: pulled from Collections into a profile stack and spent on apply. An ornament is a
@@ -367,11 +370,8 @@ void report_socket_plug(std::string_view stage,
                                     std::uint32_t flags,
                                     PendingItemState& mutation) noexcept {
     mutation = {};
-    // Bits 0 and 1 are the two states the client sends. Any other bit is a request we cannot
-    // honour.
-    constexpr std::uint32_t kSupportedItemStateMask = 0x3U;
     if (!account::valid(snapshot) || characterIndex >= snapshot.characterCount
-        || targetInstanceSoid == 0 || (flags & ~kSupportedItemStateMask) != 0) {
+        || targetInstanceSoid == 0 || !authored_inventory::valid_item_state(flags)) {
         return false;
     }
     const CharacterState& before = snapshot.characters[characterIndex];

+ 2 - 0
Sunrise/src/state/runtime/state_runtime.cpp

@@ -233,6 +233,8 @@ bool initialize(void* module,
     if (!build_data::initialize(module, runtime::equipment::configured_hash(*runtimeAccount))) {
         return false;
     }
+    build_data::set_exotic_catalyst_completion_enabled(
+        core::settings::get().completeExoticCatalysts);
     // A cache hit already has the complete plug relation, so publish canonical profile identities
     // in the first State image. On a first cache build, snapshot preparation repeats this step
     // after package extraction has published the relation.

+ 119 - 0
tests/Sunrise.Tests.vcxproj

@@ -0,0 +1,119 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
+  <ItemGroup Label="ProjectConfigurations">
+    <ProjectConfiguration Include="Debug|x64">
+      <Configuration>Debug</Configuration>
+      <Platform>x64</Platform>
+    </ProjectConfiguration>
+    <ProjectConfiguration Include="Release|x64">
+      <Configuration>Release</Configuration>
+      <Platform>x64</Platform>
+    </ProjectConfiguration>
+  </ItemGroup>
+  <PropertyGroup Label="Globals">
+    <VCProjectVersion>18.0</VCProjectVersion>
+    <ProjectGuid>{9D2A5866-9D2F-4A07-A88C-6E713A355C94}</ProjectGuid>
+    <RootNamespace>sunrise_tests</RootNamespace>
+    <WindowsTargetPlatformVersion>10.0.26100.0</WindowsTargetPlatformVersion>
+    <ProjectName>Sunrise.Tests</ProjectName>
+  </PropertyGroup>
+  <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
+  <PropertyGroup Label="Configuration">
+    <ConfigurationType>Application</ConfigurationType>
+    <PlatformToolset>v145</PlatformToolset>
+    <CharacterSet>Unicode</CharacterSet>
+  </PropertyGroup>
+  <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration">
+    <UseDebugLibraries>true</UseDebugLibraries>
+  </PropertyGroup>
+  <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration">
+    <UseDebugLibraries>false</UseDebugLibraries>
+  </PropertyGroup>
+  <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
+  <PropertyGroup>
+    <OutDir>$(ProjectDir)..\build\$(Platform)\$(Configuration)\</OutDir>
+    <IntDir>$(ProjectDir)..\build\obj\$(Platform)\$(Configuration)\tests\</IntDir>
+  </PropertyGroup>
+  <ItemDefinitionGroup>
+    <ClCompile>
+      <LanguageStandard>stdcpp20</LanguageStandard>
+      <WarningLevel>Level4</WarningLevel>
+      <TreatWarningAsError>true</TreatWarningAsError>
+      <ConformanceMode>true</ConformanceMode>
+      <MultiProcessorCompilation>false</MultiProcessorCompilation>
+      <AdditionalOptions>/utf-8 %(AdditionalOptions)</AdditionalOptions>
+      <AdditionalIncludeDirectories>$(ProjectDir)..\Sunrise\src;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
+      <PreprocessorDefinitions>WIN32;_WINDOWS;WIN32_LEAN_AND_MEAN;NOMINMAX;%(PreprocessorDefinitions)</PreprocessorDefinitions>
+    </ClCompile>
+  </ItemDefinitionGroup>
+  <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'">
+    <ClCompile>
+      <Optimization>Disabled</Optimization>
+      <RuntimeLibrary>MultiThreadedDebug</RuntimeLibrary>
+    </ClCompile>
+    <Link>
+      <GenerateDebugInformation>true</GenerateDebugInformation>
+    </Link>
+  </ItemDefinitionGroup>
+  <ItemDefinitionGroup Condition="'$(Configuration)'=='Release'">
+    <ClCompile>
+      <Optimization>MaxSpeed</Optimization>
+      <FunctionLevelLinking>true</FunctionLevelLinking>
+      <RuntimeLibrary>MultiThreaded</RuntimeLibrary>
+    </ClCompile>
+    <Link>
+      <GenerateDebugInformation>true</GenerateDebugInformation>
+      <OptimizeReferences>true</OptimizeReferences>
+    </Link>
+  </ItemDefinitionGroup>
+  <ItemGroup>
+    <ClCompile Include="item_state_regression_tests.cpp" />
+    <ClCompile Include="exotic_catalyst_regression_tests.cpp" />
+    <ClCompile Include="loadout_catalyst_regression_tests.cpp" />
+    <ClCompile Include="opcode406_item_state_regression_tests.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\json_number.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\json_scalar.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\json_structure.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\settings_parser.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\state_settings.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\address_text.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\client\client_settings_parser.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\client\client_ui_settings_parser.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\client\external\client_external_settings_parser.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\server\activation\activation_settings_parser.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\server\entitlement_settings_parser.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\server\gameplay\gameplay_settings_parser.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\server\gameplay\gameplay_settings_validation.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\server\server_settings_parser.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\state\account_rows_parser.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\state\account_settings_parser.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\state\activity_arrival_override_parser.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\state\activity_default_destination_parser.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\state\audio_parser.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\state\controls_parser.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\state\display_parser.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\state\family5_override_parser.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\state\interface_parser.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\state\inventory_parser.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\state\key_bindings_parser.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\state\social_parser.cpp" />
+    <ClCompile Include="..\Sunrise\src\core\settings\steam\steam_settings_parser.cpp" />
+    <ClCompile Include="..\Sunrise\src\state\account\inventory\inventory_state.cpp" />
+    <ClCompile Include="..\Sunrise\src\state\build_data\items\catalysts\exotic_catalyst_builder.cpp" />
+    <ClCompile Include="..\Sunrise\src\state\build_data\items\catalysts\exotic_catalyst_generated.cpp" />
+    <ClCompile Include="..\Sunrise\src\state\build_data\items\catalysts\exotic_catalyst_catalog.cpp" />
+    <ClCompile Include="..\Sunrise\src\state\build_data\cache\records\cache_exotic_catalyst_record_codec.cpp" />
+    <ClCompile Include="..\Sunrise\src\middleware\datagen\family4\loadout\loadout_item_resolver.cpp" />
+    <ClCompile Include="..\Sunrise\src\middleware\datagen\family4\loadout\subclass_socket_selection.cpp" />
+    <ClCompile Include="..\Sunrise\src\middleware\datagen\character_record\appearance\character_appearance_banks.cpp" />
+    <ClCompile Include="..\Sunrise\src\middleware\datagen\character_record\appearance\character_appearance_render.cpp" />
+    <ClCompile Include="..\Sunrise\src\middleware\datagen\character_record\appearance\character_appearance_stats.cpp" />
+    <ClCompile Include="..\Sunrise\src\state\build_data\items\item_catalog.cpp" />
+    <ClCompile Include="..\Sunrise\src\state\build_data\items\details\item_detail_catalog.cpp" />
+    <ClCompile Include="..\Sunrise\src\state\build_data\inventory\buckets\inventory_bucket_catalog.cpp" />
+    <ClCompile Include="..\Sunrise\src\state\build_data\socket_entry_lists\socket_entry_list_catalog.cpp" />
+    <ClCompile Include="..\Sunrise\src\middleware\encoding\bit_reader.cpp" />
+    <ClCompile Include="..\Sunrise\src\middleware\web_service\messages\opcode406_codec.cpp" />
+  </ItemGroup>
+  <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
+</Project>

+ 385 - 0
tests/exotic_catalyst_regression_tests.cpp

@@ -0,0 +1,385 @@
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <optional>
+#include <span>
+
+#include "state/account/inventory/item_state.h"
+#include "state/build_data/cache/records/codec.h"
+#include "state/build_data/items/catalysts/exotic_catalyst_builder.h"
+#include "state/build_data/items/catalysts/exotic_catalyst_catalog.h"
+
+extern int failures;
+
+namespace {
+
+namespace catalysts = sunrise::state::build_data::items::catalysts;
+namespace cache_records = sunrise::state::build_data::cache::records;
+namespace details = sunrise::state::build_data::items::details;
+namespace items = sunrise::state::build_data::items;
+namespace socket_plugs = sunrise::state::build_data::items::socket_plugs;
+
+constexpr std::uint32_t kTimestamp = 0x5F43138BU;
+constexpr std::uint32_t kImageSize = 0x08A5EA00U;
+constexpr std::uint32_t kEmptyCatalyst = 0x5957A904U;
+constexpr std::uint32_t kWorldline = 0x6F22FCECU;
+constexpr std::uint32_t kWorldlineDefault = 0x9B283E92U;
+constexpr std::uint32_t kWorldlineProgress = 0x172766F5U;
+constexpr std::uint32_t kWorldlineComplete = 0x61935831U;
+constexpr std::uint32_t kWorldlineEffect = 0x9F651900U;
+constexpr std::uint32_t kWorldlineCategory = 0x06989D65U;
+constexpr std::uint32_t kWitherhoard = 0x8C8180D6U;
+constexpr std::uint32_t kWitherhoardComplete = 0xAC29C6ACU;
+constexpr std::uint32_t kWitherhoardCategory = 0x1234ABCDU;
+constexpr std::uint32_t kWishEnder = 0x3092080CU;
+constexpr std::uint32_t kWishEnderActive = 0xCE1AD8BBU;
+
+void expect(bool value, const char* label) noexcept {
+    if (!value) {
+        std::fprintf(stderr, "FAIL %s\n", label);
+        ++failures;
+    }
+}
+
+items::Definition item(std::uint16_t index,
+                       std::uint32_t hash,
+                       items::Tier tier = items::Tier::none) noexcept {
+    items::Definition value{};
+    value.definitionIndex = index;
+    value.definitionHash = hash;
+    value.tier = static_cast<std::uint8_t>(tier);
+    return value;
+}
+
+details::Definition weapon_detail(std::uint16_t index,
+                                  std::uint32_t hash,
+                                  std::uint8_t lane,
+                                  std::uint16_t defaultPlug) noexcept {
+    details::Definition value{};
+    value.definitionIndex = index;
+    value.definitionHash = hash;
+    value.instancedDefinitionState = details::InstancedDefinitionState::instanced;
+    value.equipmentSlot = std::int8_t{7};
+    value.ordinarySocketState = details::OrdinarySocketState::present;
+    value.ordinarySocketCount = static_cast<std::uint8_t>(lane + 1);
+    value.initialPlugIndices[lane] = defaultPlug;
+    return value;
+}
+
+struct Fixture {
+    std::array<items::Definition, 5> itemRows{};
+    std::array<details::Definition, 2> detailRows{};
+    std::array<socket_plugs::Rule, 1> rules{{{10, 7, 0, 1}}};
+    std::array<socket_plugs::Pool, 2> pools{{{0, 0}, {0, 3}}};
+    std::array<socket_plugs::Member, 3> members{{20, 21, 22}};
+    std::array<std::uint32_t, 1> legacy{{kWorldlineComplete}};
+    std::array<std::uint32_t, 1> released{{kWorldline}};
+    std::array<catalysts::Definition, catalysts::kDefinitionCapacity> output{};
+
+    Fixture() noexcept {
+        itemRows = {item(10, kWorldline, items::Tier::exotic),
+                    item(20, kWorldlineDefault),
+                    item(21, kWorldlineProgress),
+                    item(22, kWorldlineComplete),
+                    item(23, kWorldlineEffect, items::Tier::exotic)};
+        for (std::size_t index = 1; index < itemRows.size(); ++index) {
+            itemRows[index].plugCategoryHash = kWorldlineCategory;
+        }
+        detailRows[0] = weapon_detail(10, kWorldline, 7, 20);
+        detailRows[1].definitionIndex = 23;
+        detailRows[1].definitionHash = kWorldlineEffect;
+        detailRows[1].instancedDefinitionState =
+            details::InstancedDefinitionState::stackable;
+        detailRows[1].sandboxPerkCount = 1;
+        detailRows[1].sandboxPerks[0] = 77;
+    }
+
+    [[nodiscard]] catalysts::Source source() const noexcept {
+        return {{kTimestamp, kImageSize, 0}, itemRows, detailRows, rules, pools, members};
+    }
+
+    [[nodiscard]] catalysts::Facts facts() const noexcept {
+        return {kTimestamp, kImageSize, kEmptyCatalyst, legacy, released};
+    }
+};
+
+void test_worldline_legacy_completion() noexcept {
+    Fixture fixture;
+    std::size_t count = 0;
+    catalysts::Report report{};
+    expect(catalysts::derive(fixture.source(), fixture.facts(), fixture.output, count, report),
+           "Worldline catalog derives");
+    expect(count == 1, "Worldline catalog has one record");
+    expect(report.released == 1 && report.placeholder == 0 && report.unsupported == 0,
+           "Worldline report is released");
+    expect(catalysts::matches_derived(
+               fixture.source(), fixture.facts(), std::span(fixture.output).first(count)),
+           "Worldline stored catalog matches fresh derivation");
+    if (count != 1) {
+        return;
+    }
+    const catalysts::Definition& record = fixture.output[0];
+    expect(record.itemDefinitionHash == kWorldline, "Worldline record keeps weapon hash");
+    expect(record.itemDefinitionIndex == 10, "Worldline record keeps weapon index");
+    expect(record.socketLane == 7, "Worldline resolves lane 7");
+    expect(record.completedPlugDefinitionIndex == 22, "Worldline resolves completed plug");
+    expect(record.effectDefinitionIndex == 23, "Worldline resolves actual catalyst effect");
+    expect(record.availability == catalysts::Availability::released,
+           "Worldline is released");
+
+    catalysts::clear();
+    expect(catalysts::replace(std::span(fixture.output).first(count)),
+           "Worldline catalog publishes");
+    expect(catalysts::resolve_effect(10, 7, 22) == 23,
+           "Worldline display plug resolves actual catalyst effect");
+    expect(catalysts::resolve_effect(10, 7, 20) == 20,
+           "Worldline default plug has no catalyst effect");
+    for (std::uint32_t flags = 0; flags <= 3; ++flags) {
+        std::array<std::optional<std::uint16_t>, 12> plugs{};
+        plugs[7] = std::uint16_t{20};
+        std::uint32_t changedFlags = flags;
+        expect(catalysts::apply_completed(10, changedFlags, plugs)
+                   == catalysts::ApplyResult::completed,
+               "Worldline completion applies");
+        expect(changedFlags == (flags | sunrise::state::account::inventory::kMasterworkItemFlag),
+               "Worldline completion preserves item-state bits");
+        expect(plugs[7] == 22, "Worldline completion applies plug with flag");
+    }
+    catalysts::set_completion_enabled(false);
+    std::array<std::optional<std::uint16_t>, 12> disabledPlugs{};
+    disabledPlugs[7] = std::uint16_t{20};
+    const auto disabledBefore = disabledPlugs;
+    std::uint32_t disabledFlags = 3;
+    expect(catalysts::apply_completed(10, disabledFlags, disabledPlugs)
+               == catalysts::ApplyResult::unchanged,
+           "global policy can disable catalyst completion");
+    expect(disabledFlags == 3 && disabledPlugs == disabledBefore,
+           "disabled catalyst completion leaves item unchanged");
+    catalysts::set_completion_enabled(true);
+}
+
+void test_later_two_plug_and_placeholder() noexcept {
+    std::array<items::Definition, 3> itemRows{
+        item(10, kWitherhoard, items::Tier::exotic),
+        item(20, kEmptyCatalyst),
+        item(21, kWitherhoardComplete),
+    };
+    itemRows[1].plugCategoryHash = kWitherhoardCategory;
+    itemRows[2].plugCategoryHash = kWitherhoardCategory;
+    itemRows[2].tier = static_cast<std::uint8_t>(items::Tier::exotic);
+    std::array<details::Definition, 2> detailRows{
+        weapon_detail(10, kWitherhoard, 10, 20),
+        details::Definition{},
+    };
+    detailRows[1].definitionIndex = 21;
+    detailRows[1].definitionHash = kWitherhoardComplete;
+    detailRows[1].instancedDefinitionState = details::InstancedDefinitionState::stackable;
+    detailRows[1].sandboxPerkCount = 1;
+    detailRows[1].sandboxPerks[0] = 88;
+    std::array<socket_plugs::Rule, 1> rules{{{10, 10, 0, 1}}};
+    std::array<socket_plugs::Pool, 2> pools{{{0, 0}, {0, 2}}};
+    std::array<socket_plugs::Member, 2> members{{20, 21}};
+    std::array<std::uint32_t, 1> released{{kWitherhoard}};
+    std::array<catalysts::Definition, catalysts::kDefinitionCapacity> output{};
+    const catalysts::Facts facts{kTimestamp, kImageSize, kEmptyCatalyst, {}, released};
+    const catalysts::Source source{
+        {kTimestamp, kImageSize, 0}, itemRows, detailRows, rules, pools, members};
+    std::size_t count = 0;
+    catalysts::Report report{};
+    expect(catalysts::derive(source, facts, output, count, report),
+           "Witherhoard catalog derives");
+    expect(count == 1 && output[0].socketLane == 10
+               && output[0].completedPlugDefinitionIndex == 21
+               && output[0].effectDefinitionIndex == 21,
+           "Witherhoard resolves later lifecycle");
+
+    itemRows[0].definitionHash = kWishEnder;
+    itemRows[2].definitionHash = kWishEnderActive;
+    detailRows[0].definitionHash = kWishEnder;
+    detailRows[1].definitionHash = kWishEnderActive;
+    const catalysts::Facts placeholderFacts{kTimestamp, kImageSize, kEmptyCatalyst, {}, {}};
+    count = 0;
+    report = {};
+    expect(catalysts::derive(source, placeholderFacts, output, count, report),
+           "Wish-Ender placeholder catalog derives");
+    expect(count == 1 && output[0].availability == catalysts::Availability::placeholder,
+           "Wish-Ender stays a placeholder");
+    expect(catalysts::replace(std::span(output).first(count)),
+           "Wish-Ender placeholder publishes");
+    const catalysts::Result result = catalysts::resolve(10);
+    expect(result.error == catalysts::Error::placeholderOnly,
+           "Wish-Ender does not resolve a completed catalyst");
+    std::array<std::optional<std::uint16_t>, 12> plugs{};
+    plugs[10] = std::uint16_t{20};
+    const auto before = plugs;
+    std::uint32_t flags = 3;
+    expect(catalysts::apply_completed(10, flags, plugs) == catalysts::ApplyResult::unchanged,
+           "Wish-Ender completion is skipped");
+    expect(flags == 3 && plugs == before, "Wish-Ender state stays unchanged");
+}
+
+void test_exclusions_and_errors() noexcept {
+    Fixture fixture;
+    std::size_t count = 0;
+    catalysts::Report report{};
+
+    fixture.itemRows[0].tier = static_cast<std::uint8_t>(items::Tier::legendary);
+    const std::array<std::uint32_t, 0> noReleased{};
+    const catalysts::Facts noReleaseFacts{
+        kTimestamp, kImageSize, kEmptyCatalyst, fixture.legacy, noReleased};
+    expect(catalysts::derive(
+               fixture.source(), noReleaseFacts, fixture.output, count, report)
+               && count == 0,
+           "non-exotic weapon is excluded");
+
+    fixture.itemRows[0].tier = static_cast<std::uint8_t>(items::Tier::exotic);
+    fixture.detailRows[0].equipmentSlot = std::int8_t{10};
+    count = 0;
+    report = {};
+    expect(catalysts::derive(
+               fixture.source(), noReleaseFacts, fixture.output, count, report)
+               && count == 0,
+           "exotic armor is excluded");
+
+    fixture.detailRows[0].equipmentSlot = std::int8_t{7};
+    fixture.itemRows[2].definitionHash = kWorldlineComplete;
+    fixture.legacy = {kWorldlineComplete};
+    count = 0;
+    report = {};
+    expect(!catalysts::derive(fixture.source(), fixture.facts(), fixture.output, count, report)
+               && count == 0 && report.error == catalysts::Error::ambiguousLifecycle,
+           "unclear legacy lifecycle fails without partial records");
+
+    fixture.itemRows[2].definitionHash = kWorldlineProgress;
+    fixture.members = {21, 22, 10};
+    count = 0;
+    report = {};
+    expect(!catalysts::derive(fixture.source(), fixture.facts(), fixture.output, count, report)
+               && count == 0 && report.error == catalysts::Error::invalidPlug,
+           "default plug outside its pool fails");
+
+    fixture.members = {20, 21, 22};
+    fixture.itemRows[4].tier = static_cast<std::uint8_t>(items::Tier::none);
+    count = 0;
+    report = {};
+    expect(!catalysts::derive(fixture.source(), fixture.facts(), fixture.output, count, report)
+               && count == 0 && report.error == catalysts::Error::invalidEffect,
+           "missing legacy catalyst effect fails");
+    fixture.itemRows[4].tier = static_cast<std::uint8_t>(items::Tier::exotic);
+
+    catalysts::Source wrongBuild = fixture.source();
+    wrongBuild.build.imageTimestamp ^= 1U;
+    count = 0;
+    report = {};
+    expect(!catalysts::derive(wrongBuild, fixture.facts(), fixture.output, count, report)
+               && count == 0 && report.error == catalysts::Error::unsupportedBuild,
+           "build fingerprint mismatch fails");
+}
+
+void test_unclear_placeholder_is_safe() noexcept {
+    Fixture fixture;
+    fixture.itemRows[2].definitionHash = kWorldlineComplete;
+    const std::array<std::uint32_t, 0> noReleased{};
+    const catalysts::Facts facts{
+        kTimestamp, kImageSize, kEmptyCatalyst, fixture.legacy, noReleased};
+    std::size_t count = 0;
+    catalysts::Report report{};
+    expect(catalysts::derive(fixture.source(), facts, fixture.output, count, report),
+           "unclear placeholder does not abort the catalog");
+    expect(count == 1 && report.unsupported == 1 && report.error == catalysts::Error::none,
+           "unclear placeholder is reported as unsupported");
+    if (count != 1) {
+        return;
+    }
+    expect(fixture.output[0].availability == catalysts::Availability::unsupported
+               && fixture.output[0].completedPlugDefinitionIndex
+                      == details::kUnavailableItemIndex
+               && fixture.output[0].effectDefinitionIndex
+                      == details::kUnavailableItemIndex,
+           "unclear placeholder has no completed state");
+    expect(catalysts::replace(std::span(fixture.output).first(count)),
+           "unsupported placeholder catalog publishes");
+    std::array<std::optional<std::uint16_t>, 12> plugs{};
+    plugs[7] = std::uint16_t{20};
+    const auto before = plugs;
+    std::uint32_t flags = 3;
+    expect(catalysts::apply_completed(10, flags, plugs) == catalysts::ApplyResult::unchanged,
+           "unsupported placeholder is skipped");
+    expect(flags == 3 && plugs == before, "unsupported placeholder stays unchanged");
+}
+
+void test_cache_record_round_trip() noexcept {
+    const catalysts::Definition released{
+        kWorldline, 10, 22, 23, 7, catalysts::Availability::released, 0};
+    cache_records::ExoticCatalystRecord record{};
+    catalysts::Definition decoded{};
+    expect(cache_records::encode(released, record), "released catalyst cache record encodes");
+    expect(cache_records::decode(record, decoded), "released catalyst cache record decodes");
+    expect(decoded.itemDefinitionHash == released.itemDefinitionHash
+               && decoded.itemDefinitionIndex == released.itemDefinitionIndex
+               && decoded.completedPlugDefinitionIndex
+                      == released.completedPlugDefinitionIndex
+               && decoded.effectDefinitionIndex == released.effectDefinitionIndex
+               && decoded.socketLane == released.socketLane
+               && decoded.availability == released.availability && decoded.reserved == 0,
+           "released catalyst cache record round-trips");
+
+    const catalysts::Definition unsupported{kWishEnder,
+                                             11,
+                                             details::kUnavailableItemIndex,
+                                             details::kUnavailableItemIndex,
+                                             10,
+                                             catalysts::Availability::unsupported,
+                                             0};
+    expect(cache_records::encode(unsupported, record),
+           "unsupported catalyst cache record encodes");
+    expect(cache_records::decode(record, decoded)
+               && decoded.availability == catalysts::Availability::unsupported
+               && decoded.completedPlugDefinitionIndex == details::kUnavailableItemIndex
+               && decoded.effectDefinitionIndex == details::kUnavailableItemIndex,
+           "unsupported catalyst cache record round-trips");
+    record.reserved = 1;
+    expect(!cache_records::decode(record, decoded),
+           "noncanonical catalyst cache record is rejected");
+    expect(cache_records::kCacheFormatVersion == 46,
+           "catalyst records use the next cache format");
+}
+
+void test_atomic_rollback() noexcept {
+    Fixture fixture;
+    std::size_t count = 0;
+    catalysts::Report report{};
+    if (!catalysts::derive(fixture.source(), fixture.facts(), fixture.output, count, report)
+        || !catalysts::replace(std::span(fixture.output).first(count))) {
+        expect(false, "rollback fixture publishes");
+        return;
+    }
+
+    std::array<std::optional<std::uint16_t>, 12> plugs{};
+    plugs[7] = std::uint16_t{20};
+    const auto before = plugs;
+    std::uint32_t flags = 0x8U;
+    expect(catalysts::apply_completed(10, flags, plugs) == catalysts::ApplyResult::failed,
+           "invalid item state rejects completion");
+    expect(flags == 0x8U && plugs == before, "invalid item state rolls back both fields");
+
+    std::array<std::optional<std::uint16_t>, 7> shortPlugs{};
+    const auto shortBefore = shortPlugs;
+    flags = 3;
+    expect(catalysts::apply_completed(10, flags, shortPlugs) == catalysts::ApplyResult::failed,
+           "missing catalyst lane rejects completion");
+    expect(flags == 3 && shortPlugs == shortBefore, "missing lane rolls back both fields");
+}
+
+} // namespace
+
+void test_exotic_catalysts() noexcept {
+    test_worldline_legacy_completion();
+    test_later_two_plug_and_placeholder();
+    test_exclusions_and_errors();
+    test_unclear_placeholder_is_safe();
+    test_atomic_rollback();
+    test_cache_record_round_trip();
+    catalysts::clear();
+}

+ 156 - 0
tests/item_state_regression_tests.cpp

@@ -0,0 +1,156 @@
+#include <Windows.h>
+
+#include <crtdbg.h>
+
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <span>
+#include <string_view>
+
+#include "core/settings/parser.h"
+#include "core/settings/settings.h"
+
+namespace sunrise::core::settings::parser {
+
+struct ParserTestAccess {
+    static bool equipment_item(Parser& parser,
+                               state::account::inventory::Item& item) noexcept {
+        return parser.equipment_item(item);
+    }
+
+    static bool at_end(Parser& parser) noexcept { return parser.at_end(); }
+};
+
+} // namespace sunrise::core::settings::parser
+
+namespace sunrise::core::log {
+
+Settings defaults() noexcept { return {}; }
+
+void early(std::string_view) noexcept {}
+
+void write(Channel, Level, std::string_view) noexcept {}
+
+} // namespace sunrise::core::log
+
+namespace sunrise::state::activity::defaults {
+
+ActivityDefaults authored() noexcept { return {}; }
+
+bool valid(const DefaultDestination&) noexcept { return true; }
+
+bool valid(const ActivityDefaults&) noexcept { return true; }
+
+} // namespace sunrise::state::activity::defaults
+
+namespace sunrise::state::entitlements {
+
+Table authored() noexcept { return {}; }
+
+bool valid(const Entitlement&) noexcept { return true; }
+
+bool valid(const Table&) noexcept { return true; }
+
+} // namespace sunrise::state::entitlements
+
+namespace sunrise::state::account {
+
+bool valid_authored(const AccountState&) noexcept { return true; }
+
+} // namespace sunrise::state::account
+
+namespace sunrise::state::account::settings {
+
+bool valid(const AccountSettings&) noexcept { return true; }
+
+} // namespace sunrise::state::account::settings
+
+int failures{};
+
+namespace {
+
+void expect(bool value, const char* label) noexcept {
+    if (!value) {
+        std::fprintf(stderr, "FAIL %s\n", label);
+        ++failures;
+    }
+}
+
+bool parse_item_flags(std::uint32_t flags,
+                      sunrise::state::account::inventory::Item& item) noexcept {
+    char json[192]{};
+    const int written = std::snprintf(
+        json,
+        sizeof json,
+        R"({"instance_soid":"0x1","definition_hash":"0x12345678",)"
+        R"("level":50,"quantity":1,"plugs":[],"flags":%u})",
+        flags);
+    if (written <= 0 || static_cast<std::size_t>(written) >= sizeof json) {
+        return false;
+    }
+
+    sunrise::core::settings::parser::Parser parser(
+        std::string_view{json, static_cast<std::size_t>(written)});
+    return sunrise::core::settings::parser::ParserTestAccess::equipment_item(parser, item)
+           && sunrise::core::settings::parser::ParserTestAccess::at_end(parser);
+}
+
+void test_catalyst_item_state_flags() noexcept {
+    for (std::uint32_t flags = 0; flags <= 0x7U; ++flags) {
+        sunrise::state::account::inventory::Item item{};
+        expect(parse_item_flags(flags, item), "parser accepts known item-state bits");
+        expect(item.flags == flags, "parser preserves known item-state bits");
+    }
+
+    sunrise::state::account::inventory::Item item{};
+    expect(!parse_item_flags(0x8U, item), "parser rejects unknown item-state bits");
+}
+
+bool parse_catalyst_policy(std::string_view json, bool& output) noexcept {
+    sunrise::core::settings::Settings settings{};
+    if (!sunrise::core::settings::parse(json, settings)) {
+        return false;
+    }
+    output = settings.completeExoticCatalysts;
+    return true;
+}
+
+void test_catalyst_policy_setting() noexcept {
+    bool enabled = true;
+    expect(parse_catalyst_policy(R"({"version":8,"complete_exotic_catalysts":false})", enabled)
+               && !enabled,
+           "global catalyst policy parses false");
+    expect(parse_catalyst_policy(R"({"version":8})", enabled) && enabled,
+           "missing global catalyst policy keeps its default");
+    expect(!parse_catalyst_policy(
+               R"({"complete_exotic_catalysts":true,"complete_exotic_catalysts":false})",
+               enabled),
+           "duplicate global catalyst policy is rejected");
+    expect(!parse_catalyst_policy(R"({"complete_exotic_catalysts":1})", enabled),
+           "non-boolean global catalyst policy is rejected");
+}
+
+} // namespace
+
+void test_exotic_catalysts() noexcept;
+void test_resolved_catalyst_output() noexcept;
+void test_opcode406_item_state() noexcept;
+
+int main() {
+#if defined(_DEBUG)
+    _CrtSetReportMode(_CRT_ASSERT, _CRTDBG_MODE_FILE);
+    _CrtSetReportFile(_CRT_ASSERT, _CRTDBG_FILE_STDERR);
+#endif
+    test_catalyst_item_state_flags();
+    test_catalyst_policy_setting();
+    test_opcode406_item_state();
+    test_exotic_catalysts();
+    test_resolved_catalyst_output();
+    if (failures != 0) {
+        std::fprintf(stderr, "%d regression test(s) failed\n", failures);
+        return 1;
+    }
+    std::puts("All regression tests passed");
+    return 0;
+}

+ 216 - 0
tests/loadout_catalyst_regression_tests.cpp

@@ -0,0 +1,216 @@
+#include <array>
+#include <cstdint>
+#include <cstdio>
+#include <optional>
+#include <span>
+
+#include "middleware/datagen/character_record/appearance/internal.h"
+#include "middleware/datagen/family4/loadout/loadout_item_resolver.h"
+#include "state/build_data/inventory/buckets/inventory_bucket_catalog.h"
+#include "state/build_data/items/catalysts/exotic_catalyst_catalog.h"
+#include "state/build_data/items/details/item_detail_catalog.h"
+#include "state/build_data/items/item_catalog.h"
+#include "state/build_data/runtime.h"
+#include "state/build_data/socket_entry_lists/socket_entry_list_catalog.h"
+
+extern int failures;
+
+namespace sunrise::state::build_data {
+
+bool find_item_definition_hash(std::uint32_t definitionHash,
+                               items::Definition& definition) noexcept {
+    return items::find_hash(definitionHash, definition);
+}
+
+bool find_configured_item_detail(std::uint16_t definitionIndex,
+                                 items::details::Definition& definition) noexcept {
+    return items::details::find(definitionIndex, definition);
+}
+
+bool find_inventory_bucket_descriptor(
+    std::uint8_t bucketId,
+    inventory::buckets::Descriptor& descriptor) noexcept {
+    return inventory::buckets::find(bucketId, descriptor);
+}
+
+bool find_socket_entry_list(std::uint16_t definitionIndex,
+                            socket_entry_lists::Definition& definition) noexcept {
+    return socket_entry_lists::find(definitionIndex, definition);
+}
+
+bool find_socket_entry_table(std::uint16_t definitionIndex,
+                             socket_entry_lists::EntryTable& table) noexcept {
+    return socket_entry_lists::find_entry_table(definitionIndex, table);
+}
+
+items::catalysts::ApplyResult complete_exotic_catalyst(
+    std::uint16_t itemDefinitionIndex,
+    std::uint32_t& flags,
+    std::span<std::optional<std::uint16_t>> plugs) noexcept {
+    return items::catalysts::apply_completed(itemDefinitionIndex, flags, plugs);
+}
+
+bool find_investment_constants(constants::InvestmentConstants& value) noexcept {
+    value = {.extracted = true,
+             .lightStatRow = 1,
+             .characterStatRows = {2, 3, 4, 5, 6, 7}};
+    return true;
+}
+
+std::uint16_t resolve_exotic_catalyst_effect(std::uint16_t itemDefinitionIndex,
+                                             std::uint8_t socketLane,
+                                             std::uint16_t plugDefinitionIndex) noexcept {
+    return items::catalysts::resolve_effect(
+        itemDefinitionIndex, socketLane, plugDefinitionIndex);
+}
+
+} // namespace sunrise::state::build_data
+
+namespace {
+
+namespace buckets = sunrise::state::build_data::inventory::buckets;
+namespace catalysts = sunrise::state::build_data::items::catalysts;
+namespace details = sunrise::state::build_data::items::details;
+namespace items = sunrise::state::build_data::items;
+namespace loadout = sunrise::middleware::datagen::family4::loadout;
+namespace appearance = sunrise::middleware::datagen::character_record::appearance;
+namespace character_layout = sunrise::middleware::datagen::character_record::layout;
+namespace socket_lists = sunrise::state::build_data::socket_entry_lists;
+
+constexpr std::uint32_t kWeaponHash = 0x12345678U;
+constexpr std::uint32_t kDefaultPlugHash = 0x23456789U;
+constexpr std::uint32_t kProgressPlugHash = 0x3456789AU;
+constexpr std::uint32_t kCompletedPlugHash = 0x456789ABU;
+constexpr std::uint32_t kEffectHash = 0x56789ABCU;
+constexpr std::uint32_t kCatalystCategory = 0x6789ABCDU;
+constexpr std::uint16_t kCatalystPerk = 77;
+constexpr std::uint8_t kCatalystStatRow = 24;
+constexpr std::int32_t kCatalystStatValue = 7;
+
+void expect(bool value, const char* label) noexcept {
+    if (!value) {
+        std::fprintf(stderr, "FAIL %s\n", label);
+        ++failures;
+    }
+}
+
+items::Definition item(std::uint16_t index,
+                       std::uint32_t hash,
+                       items::Tier tier = items::Tier::none) noexcept {
+    items::Definition value{};
+    value.definitionHash = hash;
+    value.definitionIndex = index;
+    value.tier = static_cast<std::uint8_t>(tier);
+    return value;
+}
+
+void clear_catalogs() noexcept {
+    catalysts::clear();
+    socket_lists::clear();
+    buckets::clear();
+    details::clear();
+    items::clear();
+}
+
+} // namespace
+
+void test_resolved_catalyst_output() noexcept {
+    clear_catalogs();
+    std::array<items::Definition, 5> itemRows{
+        item(0, kWeaponHash, items::Tier::exotic),
+        item(1, kDefaultPlugHash),
+        item(2, kProgressPlugHash),
+        item(3, kCompletedPlugHash),
+        item(4, kEffectHash, items::Tier::exotic),
+    };
+    itemRows[0].bucketId = 0;
+    itemRows[3].plugCategoryHash = kCatalystCategory;
+    itemRows[4].plugCategoryHash = kCatalystCategory;
+
+    details::Definition detail{};
+    detail.definitionIndex = 0;
+    detail.definitionHash = kWeaponHash;
+    detail.bucketId = 0;
+    detail.maxStackSize = 1;
+    detail.instancedDefinitionState = details::InstancedDefinitionState::instanced;
+    detail.equipmentSlot = std::int8_t{7};
+    detail.ordinarySocketState = details::OrdinarySocketState::present;
+    detail.ordinarySocketCount = 8;
+    detail.initialPlugIndices[7] = 1;
+    detail.socketEntryListIndex = 0;
+    details::Definition effectDetail{};
+    effectDetail.definitionIndex = 4;
+    effectDetail.definitionHash = kEffectHash;
+    effectDetail.bucketId = 0;
+    effectDetail.maxStackSize = 1;
+    effectDetail.instancedDefinitionState = details::InstancedDefinitionState::stackable;
+    effectDetail.sandboxPerkCount = 1;
+    effectDetail.sandboxPerks[0] = kCatalystPerk;
+    effectDetail.statCount = 1;
+    effectDetail.stats[0] = {kCatalystStatRow, kCatalystStatValue};
+    const std::array detailRows{detail, effectDetail};
+
+    const std::array bucketRows{
+        buckets::Descriptor{0, buckets::ArraySelector::character, 0, 10, 7, 0},
+    };
+    const std::array socketListRows{
+        socket_lists::Definition{0x12345678U, 0, 0, 0},
+    };
+    const std::array catalystRows{
+        catalysts::Definition{kWeaponHash, 0, 3, 4, 7, catalysts::Availability::released, 0},
+    };
+
+    const bool ready = items::replace(itemRows) && details::replace(detailRows)
+                       && buckets::replace(bucketRows)
+                       && socket_lists::replace(socketListRows)
+                       && catalysts::replace(catalystRows);
+    expect(ready, "resolved-output fixture publishes");
+    if (!ready) {
+        clear_catalogs();
+        return;
+    }
+
+    sunrise::state::account::inventory::Item authored{};
+    authored.instanceSoid = 1;
+    authored.definitionHash = kWeaponHash;
+    authored.level = 50;
+    authored.quantity = 1;
+    authored.flags = 3;
+    sunrise::state::CharacterState character{};
+    loadout::Candidate output{};
+    expect(loadout::resolve_item(
+               authored, character, itemRows.size(), socketListRows.size(), output),
+           "resolved client item accepts catalyst completion");
+    expect(output.item.flags == 7, "resolved client item carries all item-state bits");
+    expect(output.item.instance.ordinarySockets.plugs[7] == 3,
+           "resolved client item carries the completed catalyst plug");
+
+    loadout::ResolvedInstances instances{};
+    instances.itemCount = 1;
+    instances.items[0].equipmentSlot = 7;
+    instances.items[0].instance = output.item.instance;
+    character_layout::Appearance appearanceRecord{};
+    appearanceRecord.smallBankA.fill(character_layout::kEmptyDefinitionIndex);
+    appearanceRecord.overflowHashes.fill(character_layout::kNoHash);
+    appearance::apply_perk_banks(instances, appearanceRecord);
+    expect(appearanceRecord.smallBankA[0] == kCatalystPerk,
+           "Worldline display plug publishes the actual catalyst perk");
+    appearance::apply_overflow_hashes(instances, appearanceRecord);
+    expect(appearanceRecord.overflowHashes[0] == kEffectHash,
+           "Worldline display plug publishes the actual catalyst hash");
+    expect(appearance::apply_stats(instances, 50, appearanceRecord),
+           "Worldline display plug builds catalyst stats");
+    expect(appearanceRecord.weaponStats[0][0].key
+                   == static_cast<std::int8_t>(kCatalystStatRow)
+               && appearanceRecord.weaponStats[0][0].value == kCatalystStatValue,
+           "Worldline display plug publishes the actual catalyst stat");
+
+    authored.flags = 0x8U;
+    output.item.flags = 0xA5A5U;
+    expect(!loadout::resolve_item(
+               authored, character, itemRows.size(), socketListRows.size(), output),
+           "resolved client item rejects an invalid accumulated state");
+    expect(output.item.flags == 0xA5A5U,
+           "failed resolved client item leaves caller output unchanged");
+    clear_catalogs();
+}

+ 68 - 0
tests/opcode406_item_state_regression_tests.cpp

@@ -0,0 +1,68 @@
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <span>
+
+#include "middleware/web_service/messages/opcode406.h"
+
+extern int failures;
+
+namespace {
+
+namespace opcode406 = sunrise::middleware::web_service::messages::opcode406;
+
+void expect(bool value, const char* label) noexcept {
+    if (!value) {
+        std::fprintf(stderr, "FAIL %s\n", label);
+        ++failures;
+    }
+}
+
+void write_bits(std::span<std::byte> output,
+                std::size_t& position,
+                std::uint64_t value,
+                std::uint8_t width) noexcept {
+    for (std::uint8_t index = 0; index < width; ++index) {
+        const unsigned shift = static_cast<unsigned>(width - index - 1);
+        const auto bit = static_cast<unsigned>((value >> shift) & 1U);
+        const std::size_t byteIndex = position / 8;
+        const unsigned byteShift = static_cast<unsigned>(7 - (position % 8));
+        output[byteIndex] |= std::byte{static_cast<unsigned char>(bit << byteShift)};
+        ++position;
+    }
+}
+
+std::array<std::byte, 15> payload(std::uint32_t flags) noexcept {
+    std::array<std::byte, 15> output{};
+    std::size_t position = 0;
+    write_bits(output, position, 1, 1);
+    write_bits(output, position, 0x1234U, 64);
+    write_bits(output, position, 1, 1);
+    write_bits(output, position, 42, 15);
+    write_bits(output, position, 0x80000000ULL + flags, 32);
+    write_bits(output, position, 0, 7);
+    return output;
+}
+
+} // namespace
+
+void test_opcode406_item_state() noexcept {
+    for (std::uint32_t flags = 0; flags <= 7; ++flags) {
+        const auto bytes = payload(flags);
+        const sunrise::middleware::web_service::Message message{
+            opcode406::kOpcode, 1, bytes};
+        opcode406::Request request{};
+        expect(opcode406::parse_request(message, request),
+               "opcode 406 accepts known item-state bits");
+        expect(request.instanceSoid == 0x1234U && request.definitionIndex == 42
+                   && request.flags == flags,
+               "opcode 406 preserves known item-state bits");
+    }
+
+    const auto bytes = payload(8);
+    const sunrise::middleware::web_service::Message message{opcode406::kOpcode, 1, bytes};
+    opcode406::Request request{};
+    expect(!opcode406::parse_request(message, request),
+           "opcode 406 rejects unknown item-state bits");
+}