Explorar el Código

Merge pull request #85 from yazan-albaiz/fix/exotic_catalyst_investment_state

Fix: Exotic Catalyst investment state
stan hace 4 días
padre
commit
bc2c0bc184
Se han modificado 54 ficheros con 2533 adiciones y 114 borrados
  1. 9 0
      Sunrise/Sunrise.vcxproj
  2. 1 0
      Sunrise/resources/default_settings.json
  3. 16 0
      Sunrise/src/client/content/items/packages/internal.h
  4. 25 0
      Sunrise/src/client/content/items/packages/package_build_report.cpp
  5. 147 0
      Sunrise/src/client/content/items/packages/package_catalyst_condition_reader.cpp
  6. 21 2
      Sunrise/src/client/content/items/packages/package_item_build.cpp
  7. 57 8
      Sunrise/src/client/content/items/packages/package_item_rows.cpp
  8. 125 9
      Sunrise/src/client/content/items/packages/package_socket_plug_build.cpp
  9. 63 2
      Sunrise/src/client/content/items/packages/package_socket_plug_build.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. 19 0
      Sunrise/src/middleware/content/packages/tables/definition_index_table.h
  14. 4 3
      Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_banks.cpp
  15. 14 0
      Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_render.cpp
  16. 2 2
      Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_stats.cpp
  17. 9 0
      Sunrise/src/middleware/datagen/character_record/appearance/internal.h
  18. 3 0
      Sunrise/src/middleware/datagen/family4/account/account_encoder.cpp
  19. 8 0
      Sunrise/src/middleware/datagen/family4/loadout/loadout_item_resolver.cpp
  20. 1 1
      Sunrise/src/middleware/web_service/messages/opcode406.h
  21. 3 3
      Sunrise/src/middleware/web_service/messages/opcode406_codec.cpp
  22. 6 5
      Sunrise/src/server/web_service/web_service_runtime.cpp
  23. 2 5
      Sunrise/src/state/account/inventory/inventory_state.h
  24. 25 0
      Sunrise/src/state/account/inventory/item_state.h
  25. 10 1
      Sunrise/src/state/build_data/build_data_runtime.cpp
  26. 4 1
      Sunrise/src/state/build_data/cache/read/cache_file_reader.cpp
  27. 37 26
      Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp
  28. 3 0
      Sunrise/src/state/build_data/cache/read/cache_payload_reader.h
  29. 26 0
      Sunrise/src/state/build_data/cache/records/cache_detail_links.cpp
  30. 7 2
      Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp
  31. 55 0
      Sunrise/src/state/build_data/cache/records/cache_exotic_catalyst_record_codec.cpp
  32. 16 0
      Sunrise/src/state/build_data/cache/records/codec.h
  33. 4 0
      Sunrise/src/state/build_data/cache/records/domains.h
  34. 28 2
      Sunrise/src/state/build_data/cache/records/format.h
  35. 21 3
      Sunrise/src/state/build_data/cache/records/validation.h
  36. 2 1
      Sunrise/src/state/build_data/cache/write/cache_file_writer.cpp
  37. 2 0
      Sunrise/src/state/build_data/cache/write/cache_payload_writer.cpp
  38. 1 0
      Sunrise/src/state/build_data/cache/write/temporary/temporary_cache_file.cpp
  39. 216 0
      Sunrise/src/state/build_data/items/catalysts/definition.h
  40. 127 0
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_build_data_runtime.cpp
  41. 671 0
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_builder.cpp
  42. 89 0
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_builder.h
  43. 326 0
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_catalog.cpp
  44. 100 0
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_catalog.h
  45. 33 0
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_generated.cpp
  46. 85 1
      Sunrise/src/state/build_data/runtime.h
  47. 2 0
      Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp
  48. 39 15
      Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp
  49. 36 6
      Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h
  50. 1 1
      Sunrise/src/state/build_data/runtime/persistence/publication_transaction.cpp
  51. 1 1
      Sunrise/src/state/investment/investment.h
  52. 6 2
      Sunrise/src/state/runtime/runtime.h
  53. 4 4
      Sunrise/src/state/runtime/state_account_socket_runtime.cpp
  54. 10 4
      Sunrise/src/state/runtime/state_runtime.cpp

+ 9 - 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" />
@@ -1015,6 +1020,7 @@
     <ClCompile Include="src\client\content\items\packages\package_collectible_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_material_requirement_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_detail_build.cpp" />
+    <ClCompile Include="src\client\content\items\packages\package_catalyst_condition_reader.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_socket_plug_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_ability_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_subclass_build.cpp" />
@@ -1458,6 +1464,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,

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

@@ -15,6 +15,7 @@
 #include "../../../../state/build_data/collectibles/collectible_catalog.h"
 #include "../../../../state/build_data/constants/definition.h"
 #include "../../../../state/build_data/inventory/buckets/definition.h"
+#include "../../../../state/build_data/items/catalysts/definition.h"
 #include "../../../../state/build_data/items/details/definition.h"
 #include "../../../../state/build_data/items/item_catalog.h"
 #include "../../../../state/build_data/material_requirements/material_requirement_catalog.h"
@@ -61,6 +62,13 @@ struct Storage {
     std::vector<std::byte> definition{};
     /** Shared reusable/randomized plug-set table read from investment-root slot 51. */
     std::vector<std::byte> plugSetTable{};
+    /** Dense item-indexed catalyst completion expressions for this package pass. */
+    std::vector<state::build_data::items::catalysts::CompletionCondition>
+        catalystCompletionConditions{};
+    /** Dense socket-type-indexed acquired-state gates for this package pass. */
+    std::vector<state::build_data::items::catalysts::AcquisitionGate> catalystAcquisitionGates{};
+    /** Dense native objective completion values used by legacy catalyst progress items. */
+    std::vector<std::int32_t> catalystObjectiveValues{};
     /** Compact 0..3 special plug-category code of every dense installed item row. */
     std::array<std::uint8_t, state::build_data::items::kDefinitionCapacity> specialPlugCategories{};
     /** Inventory routing rows held until the paired bucket-definition table is resolved. */
@@ -308,6 +316,14 @@ void report_socket_plug_count(std::size_t rules,
                               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;
 

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

@@ -128,6 +128,31 @@ 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{};

+ 147 - 0
Sunrise/src/client/content/items/packages/package_catalyst_condition_reader.cpp

@@ -0,0 +1,147 @@
+#include <algorithm>
+#include <array>
+#include <cstring>
+#include <limits>
+
+#include "../../../../middleware/content/packages/tables/definition_index_table.h"
+#include "package_socket_plug_build.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+/** Native postfix opcodes for flag, value, literal, and greater-than-or-equal. */
+constexpr std::uint32_t kFlagOpcode = 1;
+constexpr std::uint32_t kValueOpcode = 10;
+constexpr std::uint32_t kLiteralOpcode = 11;
+constexpr std::uint32_t kGreaterEqualOpcode = 14;
+/** Each postfix token is an opcode and one 32-bit operand. */
+constexpr std::size_t kExpressionTokenSize = 8;
+constexpr std::size_t kValueExpressionTokenCount = 3;
+
+/** Adds one unique positive flag term to a bounded condition. */
+[[nodiscard]] bool append_flag(catalysts::CompletionRequirements& output,
+                               std::uint16_t definitionIndex) noexcept {
+    const auto end = output.flags.begin() + output.flagCount;
+    if (std::find(output.flags.begin(), end, definitionIndex) != end) {
+        return true;
+    }
+    if (output.flagCount >= output.flags.size()) {
+        return false;
+    }
+    output.flags[output.flagCount++] = definitionIndex;
+    return true;
+}
+
+/** Adds or raises one unique signed-value minimum in a bounded condition. */
+[[nodiscard]] bool upsert_value(catalysts::CompletionRequirements& output,
+                                std::uint16_t index,
+                                std::int32_t minimum) noexcept {
+    for (std::size_t row = 0; row < output.valueCount; ++row) {
+        if (output.values[row].index == index) {
+            output.values[row].minimum = (std::max)(output.values[row].minimum, minimum);
+            return true;
+        }
+    }
+    if (output.valueCount >= output.values.size()) {
+        return false;
+    }
+    output.values[output.valueCount++] = {index, minimum};
+    return true;
+}
+
+/** Marks a conflicting or over-capacity native rule and clears its partial operands. */
+void mark_ambiguous(catalysts::CompletionCondition& output) noexcept {
+    output.completion = {};
+    output.objectiveDefinitionIndex = catalysts::kUnavailableObjectiveIndex;
+    output.state = catalysts::CompletionConditionState::ambiguous;
+}
+
+} // namespace
+
+void read_catalyst_completion_condition(std::span<const std::byte> definition,
+                                        std::uint16_t itemDefinitionIndex,
+                                        catalysts::CompletionCondition& output) noexcept {
+    output = {};
+    output.itemDefinitionIndex = itemDefinitionIndex;
+    output.objectiveDefinitionIndex = catalysts::kUnavailableObjectiveIndex;
+    for (std::size_t descriptor = 0; descriptor + 2 * sizeof(std::uint64_t) <= definition.size();
+         descriptor += sizeof(std::uint64_t)) {
+        tables::Array expression{};
+        if (!tables::find_array_at(definition, descriptor, expression)) {
+            continue;
+        }
+        if (expression.elementClass == tables::kObjectiveReferenceArrayClass
+            && expression.count == 1 && expression.dataOffset <= definition.size()
+            && definition.size() - expression.dataOffset >= 2 * sizeof(std::uint32_t)) {
+            std::array<std::uint32_t, 2> reference{};
+            std::memcpy(reference.data(),
+                        definition.data() + expression.dataOffset,
+                        sizeof reference);
+            if (reference[0] < catalysts::kUnavailableObjectiveIndex
+                && reference[1] == tables::kObjectiveReferenceRowClass) {
+                const auto objective = static_cast<std::uint16_t>(reference[0]);
+                if (output.objectiveDefinitionIndex == catalysts::kUnavailableObjectiveIndex) {
+                    output.objectiveDefinitionIndex = objective;
+                } else if (output.objectiveDefinitionIndex != objective) {
+                    mark_ambiguous(output);
+                    return;
+                }
+            }
+            continue;
+        }
+        if (expression.elementClass != tables::kInvestmentExpressionRowClass
+            || expression.dataOffset > definition.size()
+            || expression.count
+                   > (definition.size() - expression.dataOffset) / kExpressionTokenSize) {
+            continue;
+        }
+        for (std::size_t token = 0; token < expression.count; ++token) {
+            std::array<std::uint32_t, 2> current{};
+            std::memcpy(current.data(),
+                        definition.data() + expression.dataOffset
+                            + token * kExpressionTokenSize,
+                        sizeof current);
+            if (current[0] == kFlagOpcode
+                && current[1] < state::build_data::items::kDefinitionCapacity
+                && !append_flag(output.completion, static_cast<std::uint16_t>(current[1]))) {
+                mark_ambiguous(output);
+                return;
+            }
+            if (current[0] != kValueOpcode
+                || token + kValueExpressionTokenCount > expression.count) {
+                continue;
+            }
+            std::array<std::uint32_t, kValueExpressionTokenCount * 2> tokens{};
+            std::memcpy(tokens.data(),
+                        definition.data() + expression.dataOffset
+                            + token * kExpressionTokenSize,
+                        sizeof tokens);
+            if (tokens[2] != kLiteralOpcode || tokens[4] != kGreaterEqualOpcode
+                || tokens[5] != UINT32_MAX
+                || tokens[1] >= catalysts::kUnavailableCompletionValueIndex || tokens[3] == 0
+                || tokens[3]
+                       > static_cast<std::uint32_t>(
+                           (std::numeric_limits<std::int32_t>::max)())) {
+                continue;
+            }
+            if (!upsert_value(output.completion,
+                              static_cast<std::uint16_t>(tokens[1]),
+                              static_cast<std::int32_t>(tokens[3]))) {
+                mark_ambiguous(output);
+                return;
+            }
+        }
+    }
+    std::sort(output.completion.flags.begin(),
+              output.completion.flags.begin() + output.completion.flagCount);
+    std::sort(output.completion.values.begin(),
+              output.completion.values.begin() + output.completion.valueCount,
+              [](const catalysts::CompletionValue& left,
+                 const catalysts::CompletionValue& right) { return left.index < right.index; });
+    if (output.completion.flagCount != 0 || output.completion.valueCount != 0
+        || output.objectiveDefinitionIndex != catalysts::kUnavailableObjectiveIndex) {
+        output.state = catalysts::CompletionConditionState::present;
+    }
+}
+
+} // namespace sunrise::client::content::items::packages

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

@@ -23,6 +23,7 @@
 #include "../../spawn_sets/spawn_set_build.h"
 #include "build.h"
 #include "internal.h"
+#include "package_socket_plug_build.h"
 
 namespace sunrise::client::content::items::packages {
 namespace {
@@ -41,7 +42,8 @@ namespace {
            && state::build_data::progression_definitions_ready()
            && state::build_data::scenario_layouts_ready() && state::build_data::spawn_sets_ready()
            && state::build_data::hash_names_ready()
-           && state::build_data::investment_constants_ready();
+           && state::build_data::investment_constants_ready()
+           && state::build_data::exotic_catalysts_ready();
 }
 
 /** @return True when every item and investment-root domain is published. */
@@ -56,7 +58,8 @@ namespace {
            && state::build_data::ability_buckets_ready()
            && state::build_data::socket_entry_buckets_ready()
            && state::build_data::progression_definitions_ready()
-           && state::build_data::investment_constants_ready();
+           && state::build_data::investment_constants_ready()
+           && state::build_data::exotic_catalysts_ready();
 }
 
 } // namespace
@@ -137,6 +140,22 @@ bool build() noexcept {
                     continue;
                 }
             }
+            if (!state::build_data::exotic_catalysts_ready()) {
+                reason = "catalyst_gates";
+                if (!read_catalyst_acquisition_gates(source,
+                                                     storage.scratch,
+                                                     std::span<const std::byte>{storage.root},
+                                                     storage.child,
+                                                     storage.catalystAcquisitionGates)
+                    || !read_catalyst_objective_values(
+                        source,
+                        storage.scratch,
+                        std::span<const std::byte>{storage.root},
+                        storage.child,
+                        storage.catalystObjectiveValues)) {
+                    continue;
+                }
+            }
             reason = "buckets";
             if (!build_buckets(source, storage, std::span<const std::byte>{storage.root})) {
                 continue;

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

@@ -1,7 +1,7 @@
 #include <array>
 #include <span>
-#include <vector>
 
+#include "../../../../state/build_data/items/catalysts/exotic_catalyst_builder.h"
 #include "../../../../state/build_data/items/details/item_detail_catalog.h"
 #include "../../../../state/build_data/runtime.h"
 #include "internal.h"
@@ -43,16 +43,28 @@ 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;
+    if (needCatalysts) {
+        storage.catalystCompletionConditions.assign(
+            static_cast<std::size_t>(table.count),
+            state::build_data::items::catalysts::CompletionCondition{});
+        for (std::size_t item = 0; item < storage.catalystCompletionConditions.size(); ++item) {
+            storage.catalystCompletionConditions[item].itemDefinitionIndex =
+                static_cast<std::uint16_t>(item);
+        }
+    }
+    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 +100,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 +131,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 +153,17 @@ 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 (needCatalysts
+                && detail.definitionIndex < storage.catalystCompletionConditions.size()) {
+                read_catalyst_completion_condition(
+                    std::span<const std::byte>{storage.definition},
+                    detail.definitionIndex,
+                    storage.catalystCompletionConditions[detail.definitionIndex]);
+            }
+            if (needSocketRows) {
                 (void)socketPlugBuild.append(item,
                                              std::span<const std::byte>{storage.definition},
                                              std::span<const std::byte>{storage.plugSetTable},
@@ -156,6 +175,30 @@ 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{};
+        const state::build_data::items::catalysts::Source catalystSource{
+            {},
+            std::span(storage.rows).first(rowCount),
+            std::span(storage.details).first(builtDetailCount),
+            socketPlugBuild.rules(),
+            socketPlugBuild.pools(),
+            socketPlugBuild.members(),
+            storage.catalystCompletionConditions,
+            storage.catalystAcquisitionGates,
+            storage.catalystObjectiveValues,
+        };
+        bool catalystBuilt = false;
+        if (published && needCatalysts) {
+            reason = "exotic_catalysts";
+            catalystBuilt = state::build_data::derive_exotic_catalysts(
+                catalystSource, catalystRows, catalystCount, catalystReport);
+            report_catalyst_catalog(catalystReport, catalystBuilt);
+            published = catalystBuilt;
+        }
         if (published && needSocketPlugs) {
             const std::size_t rules = socketPlugBuild.rule_count();
             const std::size_t pools = socketPlugBuild.pool_count();
@@ -167,6 +210,11 @@ bool build_item_rows(const reader::Source& source,
                 report_socket_plug_count(rules, pools, members, skipped);
             }
         }
+        if (published && needCatalysts && catalystBuilt) {
+            reason = "exotic_catalysts";
+            published = state::build_data::publish_exotic_catalysts(
+                catalystSource, 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 +247,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();
 }
 

+ 125 - 9
Sunrise/src/client/content/items/packages/package_socket_plug_build.cpp

@@ -1,8 +1,10 @@
 #include "package_socket_plug_build.h"
 
 #include <algorithm>
+#include <cstring>
 #include <limits>
 
+#include "../../../../middleware/content/packages/tables/definition_index_table.h"
 #include "../../../../state/build_data/runtime.h"
 
 namespace sunrise::client::content::items::packages {
@@ -25,6 +27,20 @@ constexpr std::uint16_t kTrackerSocketType = 518;
 /** FNV-1a constants make pool fingerprints stable and cheap. */
 constexpr std::uint64_t kHashOffsetBasis = 14695981039346656037ULL;
 constexpr std::uint64_t kHashPrime = 1099511628211ULL;
+/** One acquired-state rule starts with kind 1 and then its item-definition index. */
+constexpr std::uint32_t kAcquisitionRuleKind = 1;
+constexpr std::size_t kAcquisitionRuleSize = 8;
+
+/** Reads one trivially copied scalar from a bounded package blob. */
+template <typename Value>
+[[nodiscard]] bool
+read(std::span<const std::byte> blob, std::size_t offset, Value& value) noexcept {
+    if (offset > blob.size() || blob.size() - offset < sizeof value) {
+        return false;
+    }
+    std::memcpy(&value, blob.data() + offset, sizeof value);
+    return true;
+}
 
 /** Visitor adapter that appends one list member to a bounded lane candidate. */
 struct VisitorContext {
@@ -32,7 +48,11 @@ struct VisitorContext {
     std::size_t itemDefinitionCount{};
 };
 
-/** @return Whether the package-provided member was accepted into bounded scratch. */
+/**
+ * @param opaque Visitor context supplied by the socket build.
+ * @param itemDefinitionIndex Package-provided plug index.
+ * @return True when the plug was accepted into bounded scratch.
+ */
 [[nodiscard]] bool visit_member(void* opaque, std::uint32_t itemDefinitionIndex) noexcept {
     auto& context = *static_cast<VisitorContext*>(opaque);
     return context.build != nullptr
@@ -41,6 +61,92 @@ struct VisitorContext {
 
 } // namespace
 
+bool read_catalyst_acquisition_gates(const reader::Source& source,
+                                     reader::Scratch& scratch,
+                                     std::span<const std::byte> root,
+                                     std::vector<std::byte>& blob,
+                                     std::vector<catalysts::AcquisitionGate>& output) noexcept {
+    output.clear();
+    std::uint32_t tableTag = 0;
+    tables::Array table{};
+    if (!tables::slot_tag(root, tables::kSocketTypeTableSlot, tableTag) || tableTag == 0
+        || !reader::read_tag(source, scratch, tableTag, blob)
+        || !tables::find_array_at(blob, tables::kTableArrayDescriptor, table)
+        || table.elementClass != tables::kSocketTypeTableClass || table.count == 0
+        || table.count > (std::numeric_limits<std::uint16_t>::max)()) {
+        return false;
+    }
+    const std::uint64_t tableSize = table.count * tables::kSocketTypeRowStride;
+    if (tableSize > blob.size() || table.dataOffset > blob.size() - tableSize) {
+        return false;
+    }
+
+    output.resize(static_cast<std::size_t>(table.count));
+    const std::span<const std::byte> bytes{blob};
+    for (std::size_t index = 0; index < output.size(); ++index) {
+        catalysts::AcquisitionGate& gate = output[index];
+        gate.socketType = static_cast<std::uint16_t>(index);
+        const std::size_t row = table.dataOffset + index * tables::kSocketTypeRowStride;
+        tables::Array rules{};
+        if (!tables::find_array_at(bytes, row + tables::kSocketTypeAcquisitionDescriptor, rules)) {
+            continue;
+        }
+        if (rules.count != 1 || rules.elementClass != tables::kInvestmentExpressionRowClass
+            || rules.dataOffset > bytes.size()
+            || bytes.size() - rules.dataOffset < kAcquisitionRuleSize) {
+            gate.state = catalysts::AcquisitionState::ambiguous;
+            continue;
+        }
+        std::uint32_t kind = 0;
+        std::uint32_t definitionIndex = 0;
+        if (!read(bytes, rules.dataOffset, kind)
+            || !read(bytes, rules.dataOffset + sizeof kind, definitionIndex)
+            || kind != kAcquisitionRuleKind
+            || definitionIndex >= state::build_data::items::kDefinitionCapacity) {
+            gate.state = catalysts::AcquisitionState::ambiguous;
+            continue;
+        }
+        gate.definitionIndex = static_cast<std::uint16_t>(definitionIndex);
+        gate.state = catalysts::AcquisitionState::present;
+    }
+    return true;
+}
+
+bool read_catalyst_objective_values(const reader::Source& source,
+                                    reader::Scratch& scratch,
+                                    std::span<const std::byte> root,
+                                    std::vector<std::byte>& blob,
+                                    std::vector<std::int32_t>& output) noexcept {
+    output.clear();
+    std::uint32_t tableTag = 0;
+    std::uint32_t tableClass = 0;
+    tables::Array table{};
+    if (!tables::slot_tag(root, tables::kObjectiveTableSlot, tableTag) || tableTag == 0
+        || !reader::read_tag(source, scratch, tableTag, blob, tableClass)
+        || tableClass != tables::kObjectiveTableClass
+        || !tables::find_array_at(blob, tables::kTableArrayDescriptor, table)
+        || table.elementClass != tables::kObjectiveRowClass || table.count == 0
+        || table.count > catalysts::kUnavailableObjectiveIndex) {
+        return false;
+    }
+    const std::uint64_t tableSize = table.count * tables::kObjectiveRowStride;
+    if (tableSize > blob.size() || table.dataOffset > blob.size() - tableSize) {
+        return false;
+    }
+
+    output.resize(static_cast<std::size_t>(table.count));
+    const std::span<const std::byte> bytes{blob};
+    for (std::size_t index = 0; index < output.size(); ++index) {
+        const std::size_t offset = table.dataOffset + index * tables::kObjectiveRowStride
+                                   + tables::kObjectiveCompletionValueOffset;
+        if (!read(bytes, offset, output[index])) {
+            output.clear();
+            return false;
+        }
+    }
+    return true;
+}
+
 /** Returns the compact 1-based code of one native category-expansion family. */
 std::uint8_t special_plug_category(std::uint32_t categoryHash) noexcept {
     for (std::size_t index = 0; index < kExpandableCategories.size(); ++index) {
@@ -218,15 +324,13 @@ bool SocketPlugBuild::append(const tables::items::Row& item,
     return complete;
 }
 
-/** Publishes the bounded relation and releases all transient interning memory. */
+/** Publishes the bounded relation and retains its rows for dependent package builders. */
 bool SocketPlugBuild::publish() noexcept {
-    const bool published =
-        !rules_.empty() && !pools_.empty() && !members_.empty()
-        && state::build_data::publish_socket_plug_rules(std::span(rules_.data(), ruleCount_),
-                                                        std::span(pools_.data(), poolCount_),
-                                                        std::span(members_.data(), memberCount_));
-    release();
-    return published;
+    return !rules_.empty() && !pools_.empty() && !members_.empty()
+           && state::build_data::publish_socket_plug_rules(
+               std::span(rules_.data(), ruleCount_),
+               std::span(pools_.data(), poolCount_),
+               std::span(members_.data(), memberCount_));
 }
 
 /** Reports how many lanes failed closed during extraction. */
@@ -246,6 +350,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();

+ 63 - 2
Sunrise/src/client/content/items/packages/package_socket_plug_build.h

@@ -6,14 +6,61 @@
 #include <span>
 #include <vector>
 
+#include "../../../../middleware/content/packages/reader/reader.h"
 #include "../../../../middleware/content/packages/tables/items.h"
+#include "../../../../state/build_data/items/catalysts/definition.h"
 #include "../../../../state/build_data/items/item_catalog.h"
 #include "../../../../state/build_data/items/socket_plugs/definition.h"
 
 namespace sunrise::client::content::items::packages {
 
+namespace reader = middleware::content::packages::reader;
 namespace tables = middleware::content::packages::tables;
 namespace socket_plugs = state::build_data::items::socket_plugs;
+namespace catalysts = state::build_data::items::catalysts;
+
+/**
+ * Reads the dense socket-type table and extracts each type's acquired-state gate.
+ * @param source Installed package source.
+ * @param scratch Shared package reader scratch.
+ * @param root Investment root bytes.
+ * @param blob Scratch storage for the socket-type table.
+ * @param output Receives one row per native socket type.
+ * @return True when the fixed table shape and every row extent are valid.
+ */
+[[nodiscard]] bool
+read_catalyst_acquisition_gates(const reader::Source& source,
+                                reader::Scratch& scratch,
+                                std::span<const std::byte> root,
+                                std::vector<std::byte>& blob,
+                                std::vector<catalysts::AcquisitionGate>& output) noexcept;
+
+/**
+ * Reads the dense objective table's build-defined completion values.
+ * @param source Installed package source.
+ * @param scratch Shared package reader scratch.
+ * @param root Investment root bytes.
+ * @param blob Scratch storage for the objective table.
+ * @param output Receives one completion value per native objective index.
+ * @return True when the table class, row class, count, and fixed rows are valid.
+ */
+[[nodiscard]] bool
+read_catalyst_objective_values(const reader::Source& source,
+                               reader::Scratch& scratch,
+                               std::span<const std::byte> root,
+                               std::vector<std::byte>& blob,
+                               std::vector<std::int32_t>& output) noexcept;
+
+/**
+ * Finds every positive flag and `value >= literal` term plus one objective reference.
+ * Duplicate terms are folded. Conflicting objectives or fixed-capacity overflow fail closed.
+ * @param definition Complete installed definition of the catalyst effect item.
+ * @param itemDefinitionIndex Native index of the catalyst effect item.
+ * @param output Receives the unique condition or its absent or ambiguous state.
+ */
+void read_catalyst_completion_condition(std::span<const std::byte> definition,
+                                        std::uint16_t itemDefinitionIndex,
+                                        catalysts::CompletionCondition& output) noexcept;
 
 /** Fixed-size, heap-backed interning state for one installed package pass. */
 class SocketPlugBuild final {
@@ -23,7 +70,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;
@@ -34,14 +86,23 @@ public:
                               std::span<const std::byte> plugSetTable,
                               std::size_t itemDefinitionCount) noexcept;
 
-    /** Publishes the completed exact relation, then releases its transient scratch. */
+    /** Publishes the completed exact relation. The object retains source rows until destruction. */
     [[nodiscard]] bool publish() noexcept;
 
     /** @return Socket lanes skipped because their package lists were malformed or over capacity. */
     [[nodiscard]] std::size_t skipped() const noexcept;
+    /** @return Number of extracted socket rules. */
     [[nodiscard]] std::size_t rule_count() const noexcept;
+    /** @return Number of interned socket pools. */
     [[nodiscard]] std::size_t pool_count() const noexcept;
+    /** @return Number of members in all interned socket pools. */
     [[nodiscard]] std::size_t member_count() const noexcept;
+    /** @return Extracted socket rules, valid until this object changes or is destroyed. */
+    [[nodiscard]] std::span<const socket_plugs::Rule> rules() const noexcept;
+    /** @return Interned socket pools, valid until this object changes or is destroyed. */
+    [[nodiscard]] std::span<const socket_plugs::Pool> pools() const noexcept;
+    /** @return Interned socket members, valid until this object changes or is destroyed. */
+    [[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,

+ 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);

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

@@ -183,6 +183,25 @@ slot_tag(std::span<const std::byte> blob, std::size_t index, std::uint32_t& tag)
 inline constexpr std::size_t kItemTableSlot = 48;
 /** The investment root holds the shared reusable/randomized plug-set table at this slot. */
 inline constexpr std::size_t kPlugSetTableSlot = 51;
+/** The investment root holds the dense ordinary socket-type table at this slot. */
+inline constexpr std::size_t kSocketTypeTableSlot = 94;
+/** Element class and row size of the installed ordinary socket-type table. */
+inline constexpr std::uint32_t kSocketTypeTableClass = 0x80807ABBU;
+inline constexpr std::size_t kSocketTypeRowStride = 96;
+/** One socket type points to its acquired-state rule array from this row offset. */
+inline constexpr std::size_t kSocketTypeAcquisitionDescriptor = 16;
+/** Acquired-state and item-completion arrays carry this postfix expression-row class. */
+inline constexpr std::uint32_t kInvestmentExpressionRowClass = 0x80807D31U;
+/** Catalyst progress items reference objective rows through this one-row array. */
+inline constexpr std::uint32_t kObjectiveReferenceArrayClass = 0x808087B1U;
+inline constexpr std::uint32_t kObjectiveReferenceRowClass = 0x808077E3U;
+/** The investment root holds the dense objective definition table at this slot. */
+inline constexpr std::size_t kObjectiveTableSlot = 58;
+/** Installed objective table and row layout. */
+inline constexpr std::uint32_t kObjectiveTableClass = 0x8080775BU;
+inline constexpr std::uint32_t kObjectiveRowClass = 0x8080775FU;
+inline constexpr std::size_t kObjectiveRowStride = 0xA0;
+inline constexpr std::size_t kObjectiveCompletionValueOffset = 0x30;
 /** The investment root holds the socket entry list table at this slot. */
 inline constexpr std::size_t kSocketEntryListTableSlot = 97;
 /** The globals container names the investment root as its first child. */

+ 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]),

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

@@ -152,6 +152,20 @@ bool resolve_equipped(const family4::loadout::SlottedInstance& slotted,
     return true;
 }
 
+/**
+ * Resolves one socketed plug to the item row that supplies its perks and stats.
+ * @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.
+ */
+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,

+ 2 - 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,7 @@ 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;
 

+ 3 - 0
Sunrise/src/middleware/datagen/family4/account/account_encoder.cpp

@@ -95,6 +95,9 @@ bool encode(const state::AccountState& state, std::span<std::byte> output) noexc
     object.acquiredFlags = unlocks.accountFlags;
     object.profileUnlockFlags = unlocks.profileFlags;
     object.objectiveValues = unlocks.objectiveValues;
+    if (!state::build_data::complete_exotic_catalyst_objectives(object.objectiveValues)) {
+        return false;
+    }
     for (layout::CharacterUnlockBlock& block : object.characterUnlocks) {
         block.flags = unlocks.characterFlags;
     }

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

@@ -162,6 +162,14 @@ bool resolve_item(const authored_inventory::Item& authored,
                                      candidate.item.instance.ordinarySockets)) {
         return false;
     }
+    std::uint32_t completedFlags = candidate.item.flags;
+    auto completedPlugs = candidate.item.instance.ordinarySockets.plugs;
+    if (state::build_data::complete_exotic_catalyst(
+            itemDefinition.definitionIndex, completedFlags, completedPlugs)
+        == state::build_data::items::catalysts::ApplyResult::completed) {
+        candidate.item.flags = completedFlags;
+        candidate.item.instance.ordinarySockets.plugs = completedPlugs;
+    }
 
     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 - 3
Sunrise/src/middleware/web_service/messages/opcode406_codec.cpp

@@ -1,5 +1,6 @@
 #include <cstddef>
 
+#include "../../../state/account/inventory/item_state.h"
 #include "../../encoding/bit_reader.h"
 #include "opcode406.h"
 
@@ -16,8 +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
 
@@ -49,7 +48,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

+ 6 - 5
Sunrise/src/server/web_service/web_service_runtime.cpp

@@ -237,9 +237,10 @@ bool consume(std::span<const std::byte> request,
     note_reports(message);
 
     if (message.opcode == messages::opcode205::kOpcode) {
-        const auto investment = state::investment_snapshot();
-        return messages::opcode205::encode_response(
-                   message, investment, next_family5_clock(), response, written)
+        state::InvestmentState investment{};
+        return (state::investment_snapshot(investment)
+                && messages::opcode205::encode_response(
+                    message, investment, next_family5_clock(), response, written))
                || encode_echo(message, response, written);
     }
 
@@ -251,8 +252,8 @@ bool consume(std::span<const std::byte> request,
         if (!bootstrap.hasPrimarySoid) {
             bootstrap.primarySoid = state::account_snapshot().primarySoid;
         }
-        const auto investment = state::investment_snapshot();
-        if (!parsed
+        state::InvestmentState investment{};
+        if (!parsed || !state::investment_snapshot(investment)
             || !messages::opcode503::encode_response(
                 message, bootstrap, investment, next_family5_clock(), response, written)) {
             return encode_echo(message, response, written);

+ 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

+ 10 - 1
Sunrise/src/state/build_data/build_data_runtime.cpp

@@ -12,6 +12,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"
@@ -90,6 +91,11 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept {
     } else {
         detailsReplaced = items::details::replace(domains.itemDetails);
     }
+    // An unsupported executable has a checked cache with no catalyst rows. Other domains still
+    // publish from that cache, while the catalyst catalog stays unavailable.
+    const bool catalystCatalogAvailable = !domains.exoticCatalysts.empty();
+    const bool catalystsReplaced =
+        !catalystCatalogAvailable || items::catalysts::replace(domains.exoticCatalysts);
     const constants::InvestmentConstants cachedConstants{
         domains.constants.extracted != 0,
         domains.constants.lightStatRow,
@@ -107,7 +113,7 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept {
         || !socket_entry_lists::replace_entry_tables(domains.socketEntryTables) || !detailsReplaced
         || !items::socket_plugs::replace(
             domains.socketPlugRules, domains.socketPlugPools, domains.socketPlugMembers)
-        || !abilities::replace(domains.abilityBuckets)
+        || !catalystsReplaced || !abilities::replace(domains.abilityBuckets)
         || !progressions::replace(domains.progressions)
         // The layouts are what activity message 1 reads. Without them a cache hit makes the
         // other domains ready, the package build skips itself, and every destination falls back.
@@ -134,6 +140,9 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept {
     runtime::ability_buckets::publish();
     runtime::spawn_catalog::publish();
     runtime::name_catalog::publish();
+    persistenceState.catalystError = catalystCatalogAvailable
+                                         ? items::catalysts::Error::none
+                                         : items::catalysts::Error::unsupportedBuild;
     persistenceState.persisted = true;
     runtime::persistence::release_scratch_locked(persistenceState);
     ReleaseSRWLockExclusive(&persistenceState.lock);

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

@@ -28,6 +28,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 +57,7 @@ namespace {
         header.socketPlugRuleCount,
         header.socketPlugPoolCount,
         header.socketPlugMemberCount,
+        header.exoticCatalystCount,
         header.inventoryBucketCount,
         header.socketEntryListCount,
         header.socketEntryTableCount,
@@ -176,7 +178,8 @@ LoadStatus load(const wchar_t* path,
     bool valid = required_domains_present(pendingCounts) && counts_fit(pendingCounts, output)
                  && read::expected_size(pendingCounts, expectedSize)
                  && static_cast<std::uint64_t>(actualSize.QuadPart) == expectedSize
-                 && read::read_payload(file, header.constants, pendingCounts, output, checksum)
+                 && read::read_payload(
+                     file, expectedBuild, header.constants, pendingCounts, output, checksum)
                  && checksum == header.payloadChecksum;
     const LoadStatus status = close_with(file, valid ? LoadStatus::loaded : LoadStatus::invalid);
     if (status != LoadStatus::loaded) {

+ 37 - 26
Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp

@@ -74,6 +74,9 @@ void clear(records::MutableDomains output) noexcept {
     std::fill(output.socketPlugMembers.begin(),
               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)
@@ -132,6 +136,7 @@ bool expected_size(const records::DomainCounts& counts, std::uint64_t& size) noe
 
 /** Reads every payload array and checks the decoded domains as one transaction. */
 bool read_payload(HANDLE file,
+                  const BuildIdentity& build,
                   const records::InvestmentConstants& constants,
                   const records::DomainCounts& counts,
                   records::MutableDomains output,
@@ -160,6 +165,9 @@ bool read_payload(HANDLE file,
     valid = valid
             && 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);
@@ -208,32 +216,35 @@ bool read_payload(HANDLE file,
     if (!valid) {
         return false;
     }
-    return records::valid_domains({
-        constants,
-        output.named.first(counts.named),
-        output.items.first(counts.items),
-        output.collectibles.first(counts.collectibles),
-        output.materialRequirementSets.first(counts.materialRequirementSets),
-        output.itemDetails.first(counts.itemDetails),
-        output.socketPlugRules.first(counts.socketPlugRules),
-        output.socketPlugPools.first(counts.socketPlugPools),
-        output.socketPlugMembers.first(counts.socketPlugMembers),
-        output.inventoryBuckets.first(counts.inventoryBuckets),
-        output.socketEntryLists.first(counts.socketEntryLists),
-        output.socketEntryTables.first(counts.socketEntryTables),
-        output.abilityBuckets.first(counts.abilityBuckets),
-        output.progressions.first(counts.progressions),
-        output.scenarios.first(counts.scenarios),
-        output.rosterGroups.first(counts.rosterGroups),
-        output.spawnStems.first(counts.spawnStems),
-        output.spawnNameHashes.first(counts.spawnNameHashes),
-        output.spawnPoints.first(counts.spawnPoints),
-        output.hashNames.first(counts.hashNames),
-        output.vendorIndex.first(counts.vendorIndex),
-        output.vendorDefinitions.first(counts.vendorDefinitions),
-        output.vendorSaleRows.first(counts.vendorSaleRows),
-        output.vendorInstalledRows.first(counts.vendorInstalledRows),
-    });
+    return records::valid_domains(
+        build,
+        {
+            constants,
+            output.named.first(counts.named),
+            output.items.first(counts.items),
+            output.collectibles.first(counts.collectibles),
+            output.materialRequirementSets.first(counts.materialRequirementSets),
+            output.itemDetails.first(counts.itemDetails),
+            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),
+            output.abilityBuckets.first(counts.abilityBuckets),
+            output.progressions.first(counts.progressions),
+            output.scenarios.first(counts.scenarios),
+            output.rosterGroups.first(counts.rosterGroups),
+            output.spawnStems.first(counts.spawnStems),
+            output.spawnNameHashes.first(counts.spawnNameHashes),
+            output.spawnPoints.first(counts.spawnPoints),
+            output.hashNames.first(counts.hashNames),
+            output.vendorIndex.first(counts.vendorIndex),
+            output.vendorDefinitions.first(counts.vendorDefinitions),
+            output.vendorSaleRows.first(counts.vendorSaleRows),
+            output.vendorInstalledRows.first(counts.vendorInstalledRows),
+        });
 }
 
 } // namespace sunrise::state::build_data::cache::read

+ 3 - 0
Sunrise/src/state/build_data/cache/read/cache_payload_reader.h

@@ -4,6 +4,7 @@
 
 #include <cstdint>
 
+#include "../../definition.h"
 #include "../records/domains.h"
 #include "../records/format.h"
 
@@ -36,6 +37,7 @@ void clear(records::MutableDomains output) noexcept;
 /**
  * Reads, checksums, and checks every payload array.
  * @param file Open cache handle, positioned after its header.
+ * @param build Cache build identity.
  * @param constants Header constants, held until the whole load commits.
  * @param counts Checked row counts.
  * @param output Fixed caller storage for every domain.
@@ -43,6 +45,7 @@ void clear(records::MutableDomains output) noexcept;
  * @return True when all records decode and pass their checks together.
  */
 [[nodiscard]] bool read_payload(HANDLE file,
+                                const BuildIdentity& build,
                                 const records::InvestmentConstants& constants,
                                 const records::DomainCounts& counts,
                                 records::MutableDomains output,

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

@@ -1,5 +1,6 @@
 #include <algorithm>
 
+#include "../../items/catalysts/exotic_catalyst_builder.h"
 #include "../../items/details/item_detail_catalog.h"
 #include "../../items/socket_plugs/socket_plug_catalog.h"
 #include "validation.h"
@@ -109,4 +110,29 @@ 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(const BuildIdentity& build, Domains domains) noexcept {
+    const items::catalysts::Facts facts = items::catalysts::generated_facts();
+    if (!items::catalysts::supports_build(build, facts)) {
+        return domains.exoticCatalysts.empty();
+    }
+    if (domains.items.empty() || domains.itemDetails.empty() || domains.exoticCatalysts.empty()
+        || !items::socket_plugs::valid(
+            domains.socketPlugRules, domains.socketPlugPools, domains.socketPlugMembers)) {
+        return false;
+    }
+    const items::catalysts::Source source{
+        build,
+        domains.items,
+        domains.itemDetails,
+        domains.socketPlugRules,
+        domains.socketPlugPools,
+        domains.socketPlugMembers,
+        {},
+        {},
+        {},
+    };
+    return items::catalysts::matches_cached(source, facts, domains.exoticCatalysts);
+}
+
 } // namespace sunrise::state::build_data::cache::records

+ 7 - 2
Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp

@@ -140,6 +140,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 +172,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 +186,8 @@ 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(), items::catalysts::definition_index_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);
@@ -196,7 +200,7 @@ bool canonicalize(MutableDomains domains, const DomainCounts& counts) noexcept {
 }
 
 /** Checks the structure rules, the sort order, and every cross-domain item reference. */
-bool valid_domains(Domains domains) noexcept {
+bool valid_domains(const BuildIdentity& build, Domains domains) noexcept {
     if (domains.constants.extracted != 1U
         || domains.constants.weaponPowerStatRow >= constants::kStatRowCount || domains.named.empty()
         || domains.items.empty() || domains.collectibles.empty()
@@ -266,7 +270,8 @@ bool valid_domains(Domains domains) noexcept {
                                       domains.socketPlugPools,
                                       domains.socketPlugMembers,
                                       domains.items,
-                                      domains.itemDetails);
+                                      domains.itemDetails)
+           && valid_exotic_catalyst_links(build, domains);
 }
 
 } // namespace sunrise::state::build_data::cache::records

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

@@ -0,0 +1,55 @@
+#include "codec.h"
+
+namespace sunrise::state::build_data::cache::records {
+
+bool encode(const items::catalysts::Definition& value, ExoticCatalystRecord& record) noexcept {
+    record = {};
+    if (!items::catalysts::valid_availability(value.availability)) {
+        return false;
+    }
+    record.itemDefinitionHash = value.itemDefinitionHash;
+    record.itemDefinitionIndex = value.itemDefinitionIndex;
+    record.completedPlugDefinitionIndex = value.completedPlugDefinitionIndex;
+    record.progressPlugDefinitionIndex = value.progressPlugDefinitionIndex;
+    record.effectDefinitionIndex = value.effectDefinitionIndex;
+    record.acquisitionDefinitionIndex = value.acquisitionDefinitionIndex;
+    record.completionFlagDefinitionIndices = value.completion.flags;
+    for (std::size_t index = 0; index < value.completion.values.size(); ++index) {
+        record.completionValueIndices[index] = value.completion.values[index].index;
+        record.completionValues[index] = value.completion.values[index].minimum;
+    }
+    record.objectiveDefinitionIndex = value.objective.definitionIndex;
+    record.socketLane = value.socketLane;
+    record.availability = static_cast<std::uint8_t>(value.availability);
+    record.completionFlagCount = value.completion.flagCount;
+    record.completionValueCount = value.completion.valueCount;
+    record.objectiveValue = value.objective.value;
+    return true;
+}
+
+bool decode(const ExoticCatalystRecord& record, items::catalysts::Definition& value) noexcept {
+    value = {};
+    const auto availability = static_cast<items::catalysts::Availability>(record.availability);
+    if (!items::catalysts::valid_availability(availability)) {
+        return false;
+    }
+    value.itemDefinitionHash = record.itemDefinitionHash;
+    value.itemDefinitionIndex = record.itemDefinitionIndex;
+    value.completedPlugDefinitionIndex = record.completedPlugDefinitionIndex;
+    value.progressPlugDefinitionIndex = record.progressPlugDefinitionIndex;
+    value.effectDefinitionIndex = record.effectDefinitionIndex;
+    value.acquisitionDefinitionIndex = record.acquisitionDefinitionIndex;
+    value.completion.flags = record.completionFlagDefinitionIndices;
+    for (std::size_t index = 0; index < value.completion.values.size(); ++index) {
+        value.completion.values[index] = {record.completionValueIndices[index],
+                                         record.completionValues[index]};
+    }
+    value.completion.flagCount = record.completionFlagCount;
+    value.completion.valueCount = record.completionValueCount;
+    value.objective = {record.objectiveDefinitionIndex, record.objectiveValue};
+    value.socketLane = record.socketLane;
+    value.availability = availability;
+    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;

+ 28 - 2
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"
@@ -29,7 +30,7 @@ inline constexpr std::array<char, 8> kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', '
  * Bump it when a stored shape changes, and when the extraction filling it changes what it writes.
  * A cached row survives a code change, so a corrected walk keeps publishing the old rows.
  */
-inline constexpr std::uint32_t kCacheFormatVersion = 46;
+inline constexpr std::uint32_t kCacheFormatVersion = 47;
 /** Signed -1 on disk means there is no equipment slot. */
 inline constexpr std::int8_t kAbsentEquipmentSlot = -1;
 /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */
@@ -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,27 @@ 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 progressPlugDefinitionIndex{};
+    std::uint16_t effectDefinitionIndex{};
+    std::uint16_t acquisitionDefinitionIndex{};
+    std::array<std::uint16_t, items::catalysts::kCompletionFlagCapacity>
+        completionFlagDefinitionIndices{};
+    std::array<std::uint16_t, items::catalysts::kCompletionValueCapacity>
+        completionValueIndices{};
+    std::uint16_t objectiveDefinitionIndex{items::catalysts::kUnavailableObjectiveIndex};
+    std::uint8_t socketLane{};
+    std::uint8_t availability{};
+    std::uint8_t completionFlagCount{};
+    std::uint8_t completionValueCount{};
+    std::array<std::int32_t, items::catalysts::kCompletionValueCapacity> completionValues{};
+    std::int32_t objectiveValue{};
+};
+
 /** Disk form of one inventory-bucket array-routing descriptor. */
 struct InventoryBucketRecord {
     std::uint8_t bucketId{};
@@ -423,7 +446,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 +516,9 @@ 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)
+              == 6 * sizeof(std::uint32_t) + 14 * sizeof(std::uint16_t)
+                     + 4 * sizeof(std::uint8_t));
 static_assert(sizeof(InventoryBucketRecord)
               == 4 * sizeof(std::uint8_t) + 2 * sizeof(std::uint16_t));
 static_assert(sizeof(SocketEntryListRecord)

+ 21 - 3
Sunrise/src/state/build_data/cache/records/validation.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include "../../definition.h"
 #include "domains.h"
 
 namespace sunrise::state::build_data::cache::records {
@@ -28,7 +29,11 @@ valid_item_detail_links(std::span<const items::details::Definition> details,
 
 /**
  * Checks exact socket rules and members against the complete item and detail domains.
- *
+ * @param rules Exact item and lane rules.
+ * @param pools Deduplicated plug pools.
+ * @param members Flat plug indices owned by the pools.
+ * @param itemDefinitions Complete dense item rows.
+ * @param details Configured item details.
  * @return True when every target lane exists and every plug index names an installed item row.
  */
 [[nodiscard]] bool
@@ -38,6 +43,15 @@ valid_socket_plug_links(std::span<const items::socket_plugs::Rule> rules,
                         std::span<const items::Definition> itemDefinitions,
                         std::span<const items::details::Definition> details) noexcept;
 
+/**
+ * Checks catalyst item, socket, plug, lifecycle, and pinned release links.
+ * @param build Cache build identity.
+ * @param domains Complete cache domains.
+ * @return True when a supported build matches exactly, or an unsupported build has no catalog.
+ */
+[[nodiscard]] bool valid_exotic_catalyst_links(const BuildIdentity& build,
+                                               Domains domains) noexcept;
+
 /**
  * Checks every collectible item index against the complete dense item table.
  * @param collectibleDefinitions Complete dense collectible rows.
@@ -48,7 +62,11 @@ valid_socket_plug_links(std::span<const items::socket_plugs::Rule> rules,
 valid_collectible_links(std::span<const collectibles::Definition> collectibleDefinitions,
                         std::span<const items::Definition> itemDefinitions) noexcept;
 
-/** @param domains Complete sorted domains. @return True when every domain passes its checks. */
-[[nodiscard]] bool valid_domains(Domains domains) noexcept;
+/**
+ * @param build Cache build identity.
+ * @param domains Complete sorted domains.
+ * @return True when every domain required by the build passes its checks.
+ */
+[[nodiscard]] bool valid_domains(const BuildIdentity& build, Domains domains) noexcept;
 
 } // namespace sunrise::state::build_data::cache::records

+ 2 - 1
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
@@ -61,7 +62,7 @@ bool write(const wchar_t* directory,
            WriteDisposition disposition) noexcept {
     if (directory == nullptr || path == nullptr || build.imageSize == 0
         || !valid_disposition(disposition) || !counts_fit_header(domains)
-        || !records::valid_domains(domains)) {
+        || !records::valid_domains(build, domains)) {
         return false;
     }
     if (CreateDirectoryW(directory, nullptr) == FALSE && GetLastError() != ERROR_ALREADY_EXISTS) {

+ 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()),

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

@@ -0,0 +1,216 @@
+#pragma once
+
+#include <array>
+#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;
+/** All bits set mark a catalyst relation with no completion-value requirement. */
+inline constexpr std::uint16_t kUnavailableCompletionValueIndex = 0xFFFFU;
+/** All bits set mark a catalyst relation with no completion-flag requirement. */
+inline constexpr std::uint16_t kUnavailableCompletionFlagIndex = 0xFFFFU;
+/** All bits set mark a catalyst relation with no acquired-state gate. */
+inline constexpr std::uint16_t kUnavailableAcquisitionIndex = 0xFFFFU;
+/** All bits set mark a catalyst relation with no account objective. */
+inline constexpr std::uint16_t kUnavailableObjectiveIndex = 0xFFFFU;
+/** No shipped catalyst effect needs more than four distinct Family-5 flags or values. */
+inline constexpr std::size_t kCompletionFlagCapacity = 4;
+inline constexpr std::size_t kCompletionValueCapacity = 4;
+
+/** One Family-5 signed value that must reach a build-derived minimum. */
+struct CompletionValue {
+    std::uint16_t index{};
+    std::int32_t minimum{};
+
+    [[nodiscard]] constexpr bool operator==(const CompletionValue& other) const noexcept = default;
+};
+
+/** All positive Family-5 terms read from one catalyst effect's completion expressions. */
+struct CompletionRequirements {
+    std::array<std::uint16_t, kCompletionFlagCapacity> flags{};
+    std::array<CompletionValue, kCompletionValueCapacity> values{};
+    std::uint8_t flagCount{};
+    std::uint8_t valueCount{};
+
+    [[nodiscard]] constexpr bool
+    operator==(const CompletionRequirements& other) const noexcept = default;
+};
+
+/** Optional account objective that marks one legacy catalyst's work complete. */
+struct ObjectiveCompletion {
+    std::uint16_t definitionIndex{kUnavailableObjectiveIndex};
+    std::int32_t value{};
+
+    [[nodiscard]] constexpr bool operator==(const ObjectiveCompletion& other) const noexcept =
+        default;
+};
+
+/** Release status from the build-scoped Season 11 availability data. */
+enum class Availability : std::uint8_t {
+    released = 0,
+    placeholder = 1,
+    unsupported = 2,
+};
+
+/**
+ * @param availability Stored release state to check.
+ * @return True when the value is one of the declared states.
+ */
+[[nodiscard]] constexpr bool valid_availability(Availability availability) noexcept {
+    return availability == Availability::released || availability == Availability::placeholder
+           || availability == Availability::unsupported;
+}
+
+/** Safe catalog and resolver outcomes. */
+enum class Error : std::uint8_t {
+    none = 0,
+    noCatalyst,
+    placeholderOnly,
+    unsupportedBuild,
+    missingReleased,
+    ambiguousLifecycle,
+    invalidSocket,
+    invalidAcquisition,
+    invalidCompletion,
+    invalidObjective,
+    capacityExceeded,
+};
+
+/**
+ * @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::invalidAcquisition:
+        return "invalid_acquisition";
+    case Error::invalidCompletion:
+        return "invalid_completion";
+    case Error::invalidObjective:
+        return "invalid_objective";
+    case Error::capacityExceeded:
+        return "capacity_exceeded";
+    }
+    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{};
+    /** Legacy in-progress display plug, or the unavailable item-index value. */
+    std::uint16_t progressPlugDefinitionIndex{};
+    /** Item row that supplies the completed catalyst's native perks and stat changes. */
+    std::uint16_t effectDefinitionIndex{};
+    /** Family-5 acquired-state slot that makes the catalyst socket visible. */
+    std::uint16_t acquisitionDefinitionIndex{kUnavailableAcquisitionIndex};
+    /** Family-5 terms required by the catalyst's active effect item. */
+    CompletionRequirements completion{};
+    /** Account objective referenced by a legacy progress plug, when present. */
+    ObjectiveCompletion objective{};
+    std::uint8_t socketLane{};
+    Availability availability{Availability::unsupported};
+
+    /**
+     * @param other Catalyst definition to compare.
+     * @return True when every derived field is equal.
+     */
+    [[nodiscard]] constexpr bool operator==(const Definition& other) const noexcept = default;
+};
+
+/**
+ * @param left First catalyst definition.
+ * @param right Second catalyst definition.
+ * @return True when the first native item index is less than the second.
+ */
+[[nodiscard]] constexpr bool definition_index_less(const Definition& left,
+                                                   const Definition& right) noexcept {
+    return left.itemDefinitionIndex < right.itemDefinitionIndex;
+}
+
+/** The only state a released catalyst exposes to callers. */
+struct CompletedCatalyst {
+    std::uint8_t socketLane{};
+    std::uint16_t completedPlugDefinitionIndex{};
+    std::uint16_t progressPlugDefinitionIndex{};
+    std::uint16_t effectDefinitionIndex{};
+    std::uint16_t acquisitionDefinitionIndex{kUnavailableAcquisitionIndex};
+    CompletionRequirements completion{};
+    ObjectiveCompletion objective{};
+};
+
+/** Result of scanning one effect item's postfix completion expressions. */
+enum class CompletionConditionState : std::uint8_t {
+    absent = 0,
+    present = 1,
+    ambiguous = 2,
+};
+
+/** One item definition's build-derived completion flag and value. */
+struct CompletionCondition {
+    std::uint16_t itemDefinitionIndex{};
+    CompletionRequirements completion{};
+    /** First objective reference carried by the item, when it has exactly one. */
+    std::uint16_t objectiveDefinitionIndex{kUnavailableObjectiveIndex};
+    CompletionConditionState state{CompletionConditionState::absent};
+};
+
+/** Result of reading one socket type's acquired-state rule. */
+enum class AcquisitionState : std::uint8_t {
+    absent = 0,
+    present = 1,
+    ambiguous = 2,
+};
+
+/** One socket type's build-derived Family-5 acquisition gate. */
+struct AcquisitionGate {
+    std::uint16_t socketType{};
+    std::uint16_t definitionIndex{kUnavailableAcquisitionIndex};
+    AcquisitionState state{AcquisitionState::absent};
+};
+
+/** 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

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

@@ -0,0 +1,127 @@
+#include <Windows.h>
+
+#include "../../runtime.h"
+#include "../../runtime/persistence/build_data_persistence.h"
+#include "../../runtime/persistence/publication_transaction.h"
+#include "exotic_catalyst_builder.h"
+#include "exotic_catalyst_catalog.h"
+
+namespace sunrise::state::build_data {
+namespace {
+
+/**
+ * Binds caller-owned package domains to the active executable identity.
+ * @param source Caller-owned installed package domains.
+ * @param bound Receives the same domains with the active build identity.
+ * @return True when the build-data runtime has an active identity.
+ */
+[[nodiscard]] bool bind_active_build(const items::catalysts::Source& source,
+                                     items::catalysts::Source& bound) 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) {
+        return false;
+    }
+    bound = source;
+    bound.build = build;
+    return true;
+}
+
+/**
+ * Re-derives one candidate from the same package domains before publication.
+ * @param source Caller-owned installed package domains.
+ * @param definitions Candidate catalyst rows.
+ * @return True when a fresh derivation exactly matches the candidate.
+ */
+[[nodiscard]] bool
+valid_publication(const items::catalysts::Source& source,
+                  std::span<const items::catalysts::Definition> definitions) noexcept {
+    items::catalysts::Source bound{};
+    return bind_active_build(source, bound) && items::catalysts::valid(definitions)
+           && items::catalysts::matches_derived(
+               bound, items::catalysts::generated_facts(), definitions);
+}
+
+/**
+ * Records the last whole-catalog derivation result for cache policy.
+ * @param error None after a safe derivation, or its exact failure reason.
+ */
+void record_derivation_error(items::catalysts::Error error) noexcept {
+    runtime::persistence::Context& state = runtime::persistence::context();
+    AcquireSRWLockExclusive(&state.lock);
+    if (state.enabled) {
+        state.catalystError = error;
+    }
+    ReleaseSRWLockExclusive(&state.lock);
+}
+
+} // namespace
+
+bool exotic_catalysts_ready() noexcept {
+    return items::catalysts::count() != 0;
+}
+
+bool derive_exotic_catalysts(const items::catalysts::Source& source,
+                             std::span<items::catalysts::Definition> output,
+                             std::size_t& count,
+                             items::catalysts::Report& report) noexcept {
+    items::catalysts::Source bound{};
+    if (!bind_active_build(source, bound)) {
+        count = 0;
+        report = {};
+        report.error = items::catalysts::Error::unsupportedBuild;
+        report.unsupported = 1;
+        record_derivation_error(report.error);
+        return false;
+    }
+    const bool derived =
+        items::catalysts::derive(bound, items::catalysts::generated_facts(), output, count, report);
+    record_derivation_error(derived ? items::catalysts::Error::none : report.error);
+    return derived;
+}
+
+bool publish_exotic_catalysts(const items::catalysts::Source& source,
+                              std::span<const items::catalysts::Definition> definitions) noexcept {
+    if (!valid_publication(source, definitions)) {
+        return false;
+    }
+    runtime::persistence::Transaction transaction;
+    if (!transaction.active()) {
+        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);
+}
+
+bool complete_exotic_catalyst_investment(Family5State& family) noexcept {
+    return items::catalysts::append_investment_overrides(family);
+}
+
+bool complete_exotic_catalyst_objectives(std::span<std::int32_t> values) noexcept {
+    return items::catalysts::append_objective_completions(values);
+}
+
+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);
+}
+
+void set_exotic_catalyst_completion_enabled(bool enabled) noexcept {
+    items::catalysts::set_completion_enabled(enabled);
+}
+
+} // namespace sunrise::state::build_data

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

@@ -0,0 +1,671 @@
+#include "exotic_catalyst_builder.h"
+
+#include <algorithm>
+#include <array>
+#include <functional>
+#include <optional>
+
+#include "../../../unlocks/definition.h"
+
+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{details::kUnavailableItemIndex};
+    std::uint16_t progressPlugDefinitionIndex{details::kUnavailableItemIndex};
+    std::uint16_t effectDefinitionIndex{details::kUnavailableItemIndex};
+    std::uint16_t acquisitionDefinitionIndex{kUnavailableAcquisitionIndex};
+    CompletionRequirements completion{};
+    ObjectiveCompletion objective{};
+};
+
+/**
+ * @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 one row in a dense or sorted native-index table.
+ * @tparam Value Row type.
+ * @tparam Index Returns the row's native index.
+ * @param definitions Source rows in native index order.
+ * @param index Native index to find.
+ * @param indexOf Row-index reader.
+ * @return The matching row, or null when no row has the index.
+ */
+template <typename Value, typename Index>
+[[nodiscard]] const Value*
+find_indexed(std::span<const Value> definitions, std::uint16_t index, Index indexOf) noexcept {
+    if (static_cast<std::size_t>(index) < definitions.size()
+        && indexOf(definitions[index]) == index) {
+        return &definitions[index];
+    }
+    const auto found = std::lower_bound(
+        definitions.begin(), definitions.end(), index, [&indexOf](const Value& value, auto key) {
+            return indexOf(value) < key;
+        });
+    return found != definitions.end() && indexOf(*found) == index ? &*found : nullptr;
+}
+
+/**
+ * @param value Installed item row.
+ * @return The row's native item index.
+ */
+[[nodiscard]] constexpr std::uint16_t item_index(const items::Definition& value) noexcept {
+    return value.definitionIndex;
+}
+
+/**
+ * @param value Installed item detail row.
+ * @return The detail row's native item index.
+ */
+[[nodiscard]] constexpr std::uint16_t detail_index(const details::Definition& value) noexcept {
+    return value.definitionIndex;
+}
+
+/** @return The completion condition's native item index. */
+[[nodiscard]] constexpr std::uint16_t completion_index(const CompletionCondition& value) noexcept {
+    return value.itemDefinitionIndex;
+}
+
+/** @return The acquisition gate's native socket type. */
+[[nodiscard]] constexpr std::uint16_t acquisition_index(const AcquisitionGate& value) noexcept {
+    return value.socketType;
+}
+
+/**
+ * @param definitions Installed item rows in native index order.
+ * @param index Native item index to find.
+ * @return The matching source item, or null.
+ */
+[[nodiscard]] const items::Definition* find_item(std::span<const items::Definition> definitions,
+                                                 std::uint16_t index) noexcept {
+    return find_indexed(definitions, index, item_index);
+}
+
+/**
+ * @param definitions Installed item details in native index order.
+ * @param index Native item index to find.
+ * @return The matching source detail, or null.
+ */
+[[nodiscard]] const details::Definition*
+find_detail(std::span<const details::Definition> definitions, std::uint16_t index) noexcept {
+    return find_indexed(definitions, index, detail_index);
+}
+
+/** @return The matching completion condition, or null. */
+[[nodiscard]] const CompletionCondition*
+find_completion_condition(std::span<const CompletionCondition> definitions,
+                          std::uint16_t index) noexcept {
+    return find_indexed(definitions, index, completion_index);
+}
+
+/** @return The matching acquisition gate, or null. */
+[[nodiscard]] const AcquisitionGate*
+find_acquisition_gate(std::span<const AcquisitionGate> definitions,
+                      std::uint16_t socketType) noexcept {
+    return find_indexed(definitions, socketType, acquisition_index);
+}
+
+/**
+ * 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 display or active plug from the socket pool.
+ * @param socketType Native socket type that owns the acquired-state gate.
+ * @return Completed lane relation, or an effect mapping error.
+ */
+[[nodiscard]] LaneResult complete_lane(const Source& source,
+                                       std::uint16_t completedPlugDefinitionIndex,
+                                       std::uint16_t progressPlugDefinitionIndex,
+                                       std::uint16_t socketType) noexcept {
+    const items::Definition* completed = find_item(source.items, completedPlugDefinitionIndex);
+    if (completed == nullptr || completed->plugCategoryHash == 0) {
+        return {};
+    }
+
+    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 {};
+        }
+        effect = candidate.definitionIndex;
+    }
+    if (!effect.has_value()) {
+        return {};
+    }
+
+    const AcquisitionGate* acquisition = find_acquisition_gate(source.acquisitionGates, socketType);
+    if (acquisition == nullptr || acquisition->state != AcquisitionState::present
+        || acquisition->definitionIndex == kUnavailableAcquisitionIndex) {
+        return {.catalyst = true,
+                .error = Error::invalidAcquisition,
+                .completedPlugDefinitionIndex = completedPlugDefinitionIndex,
+                .progressPlugDefinitionIndex = progressPlugDefinitionIndex,
+                .effectDefinitionIndex = *effect};
+    }
+
+    const CompletionCondition* condition =
+        find_completion_condition(source.completionConditions, *effect);
+    if (condition == nullptr || condition->state != CompletionConditionState::present
+        || (condition->completion.flagCount == 0 && condition->completion.valueCount == 0)) {
+        return {.catalyst = true,
+                .error = Error::invalidCompletion,
+                .completedPlugDefinitionIndex = completedPlugDefinitionIndex,
+                .progressPlugDefinitionIndex = progressPlugDefinitionIndex,
+                .effectDefinitionIndex = *effect,
+                .acquisitionDefinitionIndex = acquisition->definitionIndex};
+    }
+
+    ObjectiveCompletion objective{};
+    if (progressPlugDefinitionIndex != details::kUnavailableItemIndex) {
+        const CompletionCondition* progress =
+            find_completion_condition(source.completionConditions, progressPlugDefinitionIndex);
+        if (progress == nullptr || progress->state == CompletionConditionState::ambiguous) {
+            return {.catalyst = true,
+                    .error = Error::invalidObjective,
+                    .completedPlugDefinitionIndex = completedPlugDefinitionIndex,
+                    .progressPlugDefinitionIndex = progressPlugDefinitionIndex,
+                    .effectDefinitionIndex = *effect,
+                    .acquisitionDefinitionIndex = acquisition->definitionIndex};
+        }
+        if (progress->objectiveDefinitionIndex != kUnavailableObjectiveIndex) {
+            if (progress->state != CompletionConditionState::present
+                || progress->objectiveDefinitionIndex >= source.objectiveCompletionValues.size()
+                || source.objectiveCompletionValues[progress->objectiveDefinitionIndex] <= 0) {
+                return {.catalyst = true,
+                        .error = Error::invalidObjective,
+                        .completedPlugDefinitionIndex = completedPlugDefinitionIndex,
+                        .progressPlugDefinitionIndex = progressPlugDefinitionIndex,
+                        .effectDefinitionIndex = *effect,
+                        .acquisitionDefinitionIndex = acquisition->definitionIndex};
+            }
+            objective = {progress->objectiveDefinitionIndex,
+                         source.objectiveCompletionValues[progress->objectiveDefinitionIndex]};
+        }
+    }
+
+    return {.catalyst = true,
+            .error = Error::none,
+            .completedPlugDefinitionIndex = completedPlugDefinitionIndex,
+            .progressPlugDefinitionIndex = progressPlugDefinitionIndex,
+            .effectDefinitionIndex = *effect,
+            .acquisitionDefinitionIndex = acquisition->definitionIndex,
+            .completion = condition->completion,
+            .objective = objective};
+}
+
+/**
+ * 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 from its installed two-state or legacy three-state lifecycle.
+ * @param source Parsed target-build tables.
+ * @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 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 {.catalyst = true, .error = Error::invalidSocket};
+    }
+    const socket_plugs::Pool& pool = source.socketPlugPools[rule->poolIndex];
+    if (pool.memberOffset > source.socketPlugMembers.size()
+        || pool.memberCount > source.socketPlugMembers.size() - pool.memberOffset) {
+        return {.catalyst = true, .error = Error::invalidSocket};
+    }
+
+    const auto members = source.socketPlugMembers.subspan(pool.memberOffset, pool.memberCount);
+    if (members.size() != 2 && members.size() != 3) {
+        return {};
+    }
+
+    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 {};
+    }
+
+    if (members.size() == 2) {
+        const auto active =
+            std::find_if(members.begin(), members.end(), [defaultIndex](auto member) {
+                return member != defaultIndex;
+            });
+        if (active == members.end()) {
+            return {};
+        }
+        const LaneResult completed = complete_lane(
+            source, *active, details::kUnavailableItemIndex, detail.socketTypes[lane]);
+        return completed.catalyst && completed.effectDefinitionIndex == *active ? completed
+                                                                                : LaneResult{};
+    }
+
+    std::size_t progressCount = 0;
+    std::size_t completedCount = 0;
+    std::uint16_t progressIndex = details::kUnavailableItemIndex;
+    std::uint16_t completedIndex = details::kUnavailableItemIndex;
+    for (const std::uint16_t member : members) {
+        if (member == defaultIndex) {
+            continue;
+        }
+        const items::Definition* plug = find_item(source.items, member);
+        const details::Definition* plugDetail = find_detail(source.details, member);
+        if (plug == nullptr || plugDetail == nullptr
+            || plugDetail->definitionHash != plug->definitionHash
+            || plugDetail->instancedDefinitionState
+                   != details::InstancedDefinitionState::stackable) {
+            return {};
+        }
+        if (plugDetail->maxStackSize == 1) {
+            ++progressCount;
+            progressIndex = member;
+        } else if (plugDetail->maxStackSize > 1) {
+            ++completedCount;
+            completedIndex = member;
+        }
+    }
+    if (progressCount == 1 && completedCount == 1) {
+        const LaneResult completed =
+            complete_lane(source, completedIndex, progressIndex, detail.socketTypes[lane]);
+        return completed.catalyst && completed.effectDefinitionIndex != completedIndex
+                   ? completed
+                   : LaneResult{};
+    }
+    return {};
+}
+
+/**
+ * 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;
+}
+
+/**
+ * Appends one complete row or fails the whole staged catalog.
+ * @param output Staged catalog rows.
+ * @param count Used row count.
+ * @param report Build report to update on failure.
+ * @param definition Complete row to append.
+ * @return True when the row fits.
+ */
+[[nodiscard]] bool append_definition(std::span<Definition> output,
+                                     std::size_t& count,
+                                     Report& report,
+                                     const Definition& definition) noexcept {
+    if (count >= output.size() || count >= kDefinitionCapacity) {
+        return fail(output,
+                    count,
+                    report,
+                    Error::capacityExceeded,
+                    definition.itemDefinitionHash,
+                    definition.socketLane);
+    }
+    output[count++] = definition;
+    return true;
+}
+
+/**
+ * @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 supports_build(const BuildIdentity& build, const Facts& facts) noexcept {
+    return facts.imageTimestamp != 0 && facts.imageSize != 0
+           && build.imageTimestamp == facts.imageTimestamp && build.imageSize == facts.imageSize;
+}
+
+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.releasedWeaponHashes.size() > kDefinitionCapacity
+        || !valid_hashes(facts.releasedWeaponHashes)) {
+        return fail(output, count, report, Error::unsupportedBuild);
+    }
+    if (!supports_build(source.build, facts)) {
+        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, 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.progressPlugDefinitionIndex,
+                                          result.effectDefinitionIndex,
+                                          result.acquisitionDefinitionIndex,
+                                          result.completion,
+                                          result.objective};
+        }
+        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);
+            }
+            Definition unsupported{};
+            unsupported.itemDefinitionHash = item->definitionHash;
+            unsupported.itemDefinitionIndex = item->definitionIndex;
+            unsupported.completedPlugDefinitionIndex = details::kUnavailableItemIndex;
+            unsupported.progressPlugDefinitionIndex = details::kUnavailableItemIndex;
+            unsupported.effectDefinitionIndex = details::kUnavailableItemIndex;
+            unsupported.acquisitionDefinitionIndex = kUnavailableAcquisitionIndex;
+            unsupported.socketLane = detectedLane;
+            unsupported.availability = Availability::unsupported;
+            if (!append_definition(output, count, report, unsupported)) {
+                return false;
+            }
+            ++report.unsupported;
+            continue;
+        }
+        if (release.has_value()) {
+            if (releasedFound[*release]) {
+                return fail(output,
+                            count,
+                            report,
+                            Error::ambiguousLifecycle,
+                            item->definitionHash,
+                            completed->socketLane);
+            }
+        }
+        Definition definition{};
+        definition.itemDefinitionHash = item->definitionHash;
+        definition.itemDefinitionIndex = item->definitionIndex;
+        definition.completedPlugDefinitionIndex = completed->completedPlugDefinitionIndex;
+        definition.progressPlugDefinitionIndex = completed->progressPlugDefinitionIndex;
+        definition.effectDefinitionIndex = completed->effectDefinitionIndex;
+        definition.acquisitionDefinitionIndex = completed->acquisitionDefinitionIndex;
+        definition.completion = completed->completion;
+        definition.objective = completed->objective;
+        definition.socketLane = completed->socketLane;
+        definition.availability =
+            release.has_value() ? Availability::released : Availability::placeholder;
+        if (!append_definition(output, count, report, definition)) {
+            return false;
+        }
+        if (release.has_value()) {
+            releasedFound[*release] = true;
+            ++report.released;
+        } else {
+            ++report.placeholder;
+        }
+    }
+
+    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, definition_index_less);
+    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());
+}
+
+bool matches_cached(const Source& source,
+                    const Facts& facts,
+                    std::span<const Definition> definitions) noexcept {
+    std::array<CompletionCondition, 2 * kDefinitionCapacity> completionConditions{};
+    std::array<AcquisitionGate, kDefinitionCapacity> acquisitionGates{};
+    std::array<std::int32_t, state::unlocks::kObjectiveValueCapacity> objectiveValues{};
+    std::size_t completionCount = 0;
+    std::size_t acquisitionCount = 0;
+    std::size_t objectiveCount = 0;
+
+    const auto appendCondition = [&](const CompletionCondition& condition) {
+        const auto prior = std::find_if(
+            completionConditions.begin(),
+            completionConditions.begin() + completionCount,
+            [&condition](const CompletionCondition& candidate) {
+                return candidate.itemDefinitionIndex == condition.itemDefinitionIndex;
+            });
+        if (prior != completionConditions.begin() + completionCount) {
+            return prior->completion == condition.completion
+                   && prior->objectiveDefinitionIndex == condition.objectiveDefinitionIndex
+                   && prior->state == condition.state;
+        }
+        if (completionCount >= completionConditions.size()) {
+            return false;
+        }
+        completionConditions[completionCount++] = condition;
+        return true;
+    };
+
+    for (const Definition& definition : definitions) {
+        if (definition.availability == Availability::unsupported) {
+            continue;
+        }
+        const details::Definition* detail =
+            find_detail(source.details, definition.itemDefinitionIndex);
+        const bool hasObjective =
+            definition.objective.definitionIndex != kUnavailableObjectiveIndex;
+        if (detail == nullptr || definition.socketLane >= detail->ordinarySocketCount
+            || find_item(source.items, definition.acquisitionDefinitionIndex) == nullptr
+            || definition.completion.flagCount > definition.completion.flags.size()
+            || definition.completion.valueCount > definition.completion.values.size()
+            || hasObjective != (definition.objective.value > 0)
+            || (definition.progressPlugDefinitionIndex != details::kUnavailableItemIndex
+                && find_item(source.items, definition.progressPlugDefinitionIndex) == nullptr)
+            || (hasObjective
+                && (definition.progressPlugDefinitionIndex == details::kUnavailableItemIndex
+                    || definition.objective.definitionIndex >= objectiveValues.size()))) {
+            return false;
+        }
+        for (std::size_t flag = 0; flag < definition.completion.flagCount; ++flag) {
+            if (find_item(source.items, definition.completion.flags[flag]) == nullptr) {
+                return false;
+            }
+        }
+        for (std::size_t value = 0; value < definition.completion.valueCount; ++value) {
+            const CompletionValue& requirement = definition.completion.values[value];
+            if (requirement.index >= kUnavailableCompletionValueIndex
+                || requirement.minimum <= 0) {
+                return false;
+            }
+        }
+
+        const std::uint16_t socketType = detail->socketTypes[definition.socketLane];
+        const auto priorGate = std::find_if(
+            acquisitionGates.begin(),
+            acquisitionGates.begin() + acquisitionCount,
+            [socketType](const AcquisitionGate& gate) { return gate.socketType == socketType; });
+        if (priorGate != acquisitionGates.begin() + acquisitionCount) {
+            if (priorGate->definitionIndex != definition.acquisitionDefinitionIndex) {
+                return false;
+            }
+        } else if (acquisitionCount >= acquisitionGates.size()) {
+            return false;
+        } else {
+            acquisitionGates[acquisitionCount++] = {
+                socketType, definition.acquisitionDefinitionIndex, AcquisitionState::present};
+        }
+
+        CompletionCondition effect{};
+        effect.itemDefinitionIndex = definition.effectDefinitionIndex;
+        effect.completion = definition.completion;
+        effect.objectiveDefinitionIndex = kUnavailableObjectiveIndex;
+        effect.state = CompletionConditionState::present;
+        if (!appendCondition(effect)) {
+            return false;
+        }
+        if (definition.progressPlugDefinitionIndex != details::kUnavailableItemIndex) {
+            CompletionCondition progress{};
+            progress.itemDefinitionIndex = definition.progressPlugDefinitionIndex;
+            if (hasObjective) {
+                progress.objectiveDefinitionIndex = definition.objective.definitionIndex;
+                progress.state = CompletionConditionState::present;
+            } else {
+                progress.objectiveDefinitionIndex = kUnavailableObjectiveIndex;
+                progress.state = CompletionConditionState::absent;
+            }
+            if (!appendCondition(progress)) {
+                return false;
+            }
+        }
+        if (hasObjective) {
+            std::int32_t& objective = objectiveValues[definition.objective.definitionIndex];
+            if (objective != 0 && objective != definition.objective.value) {
+                return false;
+            }
+            objective = definition.objective.value;
+            objectiveCount = (std::max)(objectiveCount,
+                                        static_cast<std::size_t>(
+                                            definition.objective.definitionIndex)
+                                            + 1);
+        }
+    }
+    std::sort(completionConditions.begin(),
+              completionConditions.begin() + completionCount,
+              [](const CompletionCondition& left, const CompletionCondition& right) {
+                  return left.itemDefinitionIndex < right.itemDefinitionIndex;
+              });
+    std::sort(acquisitionGates.begin(),
+              acquisitionGates.begin() + acquisitionCount,
+              [](const AcquisitionGate& left, const AcquisitionGate& right) {
+                  return left.socketType < right.socketType;
+              });
+    Source rebuilt = source;
+    rebuilt.completionConditions = std::span(completionConditions).first(completionCount);
+    rebuilt.acquisitionGates = std::span(acquisitionGates).first(acquisitionCount);
+    rebuilt.objectiveCompletionValues = std::span(objectiveValues).first(objectiveCount);
+    return matches_derived(rebuilt, facts, definitions);
+}
+
+} // namespace sunrise::state::build_data::items::catalysts

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

@@ -0,0 +1,89 @@
+#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{};
+    /** Historical release state is not present in the installed Season 11 item relations. */
+    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;
+    /** Dense item-indexed completion conditions read from item definition expressions. */
+    std::span<const CompletionCondition> completionConditions;
+    /** Dense socket-type-indexed acquired-state gates read from socket type definitions. */
+    std::span<const AcquisitionGate> acquisitionGates;
+    /** Dense objective-indexed completion values read from the installed objective table. */
+    std::span<const std::int32_t> objectiveCompletionValues;
+};
+
+/** @return The generated facts pinned to Destiny 2 build 86657.20.08.23. */
+[[nodiscard]] Facts generated_facts() noexcept;
+
+/**
+ * @param build Installed executable identity.
+ * @param facts Build-scoped catalyst facts.
+ * @return True when the facts apply to the installed executable.
+ */
+[[nodiscard]] bool supports_build(const BuildIdentity& build, const Facts& facts) 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;
+
+/**
+ * Rebuilds package-only transient relations from a stored catalog, then re-derives it.
+ * The stored acquisition and completion flag rows must still name installed items.
+ * Conflicting rows fail closed.
+ * @param source Installed cache domains. Its transient spans are ignored.
+ * @param facts Build-scoped role and release facts.
+ * @param definitions Stored candidate catalog.
+ * @return True when one fresh derivation exactly matches the stored catalog.
+ */
+[[nodiscard]] bool matches_cached(const Source& source,
+                                  const Facts& facts,
+                                  std::span<const Definition> definitions) noexcept;
+
+} // namespace sunrise::state::build_data::items::catalysts

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

@@ -0,0 +1,326 @@
+#include "exotic_catalyst_catalog.h"
+
+#include <algorithm>
+#include <atomic>
+
+#include "../../../account/inventory/item_state.h"
+#include "../../../unlocks/definition.h"
+#include "../../table.h"
+#include "../details/definition.h"
+
+namespace sunrise::state::build_data::items::catalysts {
+namespace {
+
+Lock g_lock;
+Table<Definition, kDefinitionCapacity> g_definitions;
+std::atomic<bool> g_completionEnabled{true};
+
+/**
+ * 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;
+}
+
+/** Sets one flag slot and removes duplicate authored rows for that slot. */
+[[nodiscard]] bool upsert_flag(state::Family5State& family, std::uint16_t slot) noexcept {
+    const std::size_t oldCount = family.flagCount;
+    std::size_t write = 0;
+    bool found = false;
+    for (std::size_t read = 0; read < oldCount; ++read) {
+        const state::UnlockFlagOverride row = family.flags[read];
+        if (row.slot == slot) {
+            if (!found) {
+                family.flags[write++] = {slot, state::unlocks::kFlagSet};
+                found = true;
+            }
+            continue;
+        }
+        family.flags[write++] = row;
+    }
+    if (!found) {
+        if (write >= family.flags.size()) {
+            return false;
+        }
+        family.flags[write++] = {slot, state::unlocks::kFlagSet};
+    }
+    for (std::size_t index = write; index < oldCount; ++index) {
+        family.flags[index] = {};
+    }
+    family.flagCount = write;
+    return true;
+}
+
+/** Raises one signed value slot and removes duplicate authored rows for that slot. */
+[[nodiscard]] bool upsert_completion_value(state::Family5State& family,
+                                           std::uint16_t slot,
+                                           std::int32_t minimum) noexcept {
+    const std::size_t oldCount = family.valueCount;
+    std::size_t write = 0;
+    std::optional<std::size_t> mergedIndex;
+    for (std::size_t read = 0; read < oldCount; ++read) {
+        const state::UnlockValueOverride row = family.values[read];
+        if (row.slot == slot) {
+            if (!mergedIndex.has_value()) {
+                mergedIndex = write;
+                family.values[write++] = {slot, (std::max)(row.value, minimum)};
+            } else {
+                state::UnlockValueOverride& merged = family.values[*mergedIndex];
+                merged.value = (std::max)(merged.value, row.value);
+            }
+            continue;
+        }
+        family.values[write++] = row;
+    }
+    if (!mergedIndex.has_value()) {
+        if (write >= family.values.size()) {
+            return false;
+        }
+        family.values[write++] = {slot, minimum};
+    }
+    for (std::size_t index = write; index < oldCount; ++index) {
+        family.values[index] = {};
+    }
+    family.valueCount = write;
+    return true;
+}
+
+/** @return True when one requirement set uses a strict prefix and a zero tail. */
+[[nodiscard]] bool valid_completion(const CompletionRequirements& completion) noexcept {
+    if (completion.flagCount > completion.flags.size()
+        || completion.valueCount > completion.values.size()
+        || (completion.flagCount == 0 && completion.valueCount == 0)) {
+        return false;
+    }
+    for (std::size_t index = 0; index < completion.flags.size(); ++index) {
+        if (index < completion.flagCount) {
+            if (completion.flags[index] >= kUnavailableCompletionFlagIndex
+                || (index != 0 && completion.flags[index - 1] >= completion.flags[index])) {
+                return false;
+            }
+        } else if (completion.flags[index] != 0) {
+            return false;
+        }
+    }
+    for (std::size_t index = 0; index < completion.values.size(); ++index) {
+        const CompletionValue& value = completion.values[index];
+        if (index < completion.valueCount) {
+            if (value.index >= kUnavailableCompletionValueIndex || value.minimum <= 0
+                || (index != 0 && completion.values[index - 1].index >= value.index)) {
+                return false;
+            }
+        } else if (value.index != 0 || value.minimum != 0) {
+            return false;
+        }
+    }
+    return true;
+}
+
+} // namespace
+
+void clear() noexcept {
+    const Lock::Exclusive guard(g_lock);
+    g_definitions.clear();
+}
+
+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;
+        const bool hasProgress =
+            definition.progressPlugDefinitionIndex != details::kUnavailableItemIndex;
+        const bool hasAcquisition =
+            definition.acquisitionDefinitionIndex != kUnavailableAcquisitionIndex;
+        const bool hasObjective =
+            definition.objective.definitionIndex != kUnavailableObjectiveIndex;
+        const bool directEffect =
+            definition.completedPlugDefinitionIndex == definition.effectDefinitionIndex;
+        if (definition.itemDefinitionHash == 0
+            || definition.socketLane >= details::kInitialPlugCapacity
+            || !valid_availability(definition.availability)
+            || (definition.availability == Availability::unsupported
+                && (hasCompletedPlug || hasProgress || hasEffect || hasAcquisition
+                    || definition.completion.flagCount != 0
+                    || definition.completion.valueCount != 0 || hasObjective
+                    || definition.objective.value != 0))
+            || (definition.availability != Availability::unsupported
+                && (!hasCompletedPlug || !hasEffect || !hasAcquisition
+                    || !valid_completion(definition.completion)))
+            || (hasObjective != (definition.objective.value > 0))
+            || (definition.availability != Availability::unsupported
+                && ((directEffect && (hasProgress || hasObjective))
+                    || (!directEffect && !hasProgress)))
+            || (index != 0 && !definition_index_less(definitions[index - 1], definition))) {
+            return false;
+        }
+    }
+    return true;
+}
+
+bool replace(std::span<const Definition> definitions) noexcept {
+    if (!valid(definitions)) {
+        return false;
+    }
+    const Lock::Exclusive guard(g_lock);
+    return g_definitions.replace(definitions);
+}
+
+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->progressPlugDefinitionIndex,
+             definition->effectDefinitionIndex,
+             definition->acquisitionDefinitionIndex,
+             definition->completion,
+             definition->objective}};
+}
+
+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 != 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.effectDefinitionIndex == details::kUnavailableItemIndex) {
+        return ApplyResult::failed;
+    }
+
+    const std::uint32_t completedFlags = flags | account::inventory::kMasterworkItemFlag;
+    const std::optional<std::uint16_t> completedPlug = result.completed.effectDefinitionIndex;
+    plugs[result.completed.socketLane] = completedPlug;
+    flags = completedFlags;
+    return ApplyResult::completed;
+}
+
+bool append_investment_overrides(state::Family5State& family) noexcept {
+    if (!completion_enabled()) {
+        return true;
+    }
+    if (family.flagCount > family.flags.size() || family.valueCount > family.values.size()) {
+        return false;
+    }
+
+    state::Family5State candidate = family;
+    const Lock::Shared guard(g_lock);
+    for (const Definition& definition : g_definitions.rows()) {
+        if (definition.availability != Availability::released) {
+            continue;
+        }
+        if (!upsert_flag(candidate, definition.acquisitionDefinitionIndex)) {
+            return false;
+        }
+        for (std::size_t flag = 0; flag < definition.completion.flagCount; ++flag) {
+            if (!upsert_flag(candidate, definition.completion.flags[flag])) {
+                return false;
+            }
+        }
+        for (std::size_t value = 0; value < definition.completion.valueCount; ++value) {
+            const CompletionValue& requirement = definition.completion.values[value];
+            if (!upsert_completion_value(candidate, requirement.index, requirement.minimum)) {
+                return false;
+            }
+        }
+    }
+    family = candidate;
+    return true;
+}
+
+bool append_objective_completions(std::span<std::int32_t> values) noexcept {
+    if (!completion_enabled()) {
+        return true;
+    }
+    const Lock::Shared guard(g_lock);
+    for (const Definition& definition : g_definitions.rows()) {
+        if (definition.availability == Availability::released
+            && definition.objective.definitionIndex != kUnavailableObjectiveIndex
+            && (definition.objective.definitionIndex >= values.size()
+                || definition.objective.value <= 0)) {
+            return false;
+        }
+    }
+    for (const Definition& definition : g_definitions.rows()) {
+        if (definition.availability == Availability::released
+            && definition.objective.definitionIndex != kUnavailableObjectiveIndex) {
+            std::int32_t& value = values[definition.objective.definitionIndex];
+            value = (std::max)(value, definition.objective.value);
+        }
+    }
+    return true;
+}
+
+bool snapshot(std::span<Definition> output, std::size_t& outputCount) noexcept {
+    outputCount = 0;
+    const Lock::Shared guard(g_lock);
+    return g_definitions.snapshot(output, outputCount);
+}
+
+std::size_t count() noexcept {
+    const Lock::Shared guard(g_lock);
+    return g_definitions.count();
+}
+
+} // namespace sunrise::state::build_data::items::catalysts

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

@@ -0,0 +1,100 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <optional>
+#include <span>
+
+#include "../../../investment/investment.h"
+#include "definition.h"
+
+namespace sunrise::state::build_data::items::catalysts {
+
+/** Clears all build-derived catalyst records without changing the configured completion policy. */
+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;
+
+/**
+ * Adds acquired-state gates, completion flags, and completion values for released catalysts.
+ * Existing authored rows with the same slot are raised to the required value. The input stays
+ * unchanged when either fixed override bank cannot hold the complete deduplicated result.
+ * @param family Candidate Family-5 state.
+ * @return True when completion is disabled or every released override fits atomically.
+ */
+[[nodiscard]] bool append_investment_overrides(state::Family5State& family) noexcept;
+
+/**
+ * Raises account objective values required by released legacy catalysts.
+ * The input stays unchanged if any derived objective is outside the supplied bank.
+ * @param values Candidate account objective bank.
+ * @return True when completion is disabled or every objective applies atomically.
+ */
+[[nodiscard]] bool append_objective_completions(std::span<std::int32_t> values) noexcept;
+
+/**
+ * Copies the complete catalog under its shared lock.
+ * @param output Caller-owned fixed catalog storage.
+ * @param count Receives the number of copied rows.
+ * @return False only when output cannot hold the complete catalog.
+ */
+[[nodiscard]] bool snapshot(std::span<Definition> output, std::size_t& count) noexcept;
+
+/** @return Published catalyst record count. */
+[[nodiscard]] std::size_t count() noexcept;
+
+} // namespace sunrise::state::build_data::items::catalysts

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

@@ -0,0 +1,33 @@
+#include <array>
+
+#include "exotic_catalyst_builder.h"
+
+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;
+/**
+ * Released Season 11 weapon hashes, in definition-hash order.
+ * The installed build stages later catalyst sockets with the same item, socket, and visibility
+ * fields as released catalysts. Runtime extraction can recover every relation except release date.
+ * Default and completed plugs come from each exact socket pool, so they are not pinned here.
+ */
+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, kReleasedWeaponHashes};
+}
+
+} // namespace sunrise::state::build_data::items::catalysts

+ 85 - 1
Sunrise/src/state/build_data/runtime.h

@@ -3,15 +3,18 @@
 #include <array>
 #include <cstddef>
 #include <cstdint>
+#include <optional>
 #include <span>
 #include <string_view>
 
+#include "../investment/investment.h"
 #include "abilities/definition.h"
 #include "collectibles/collectible_catalog.h"
 #include "constants/definition.h"
 #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"
@@ -23,6 +26,10 @@
 #include "spawn_sets/definition.h"
 #include "vendors/definition.h"
 
+namespace sunrise::state::build_data::items::catalysts {
+struct Source;
+}
+
 namespace sunrise::state::build_data {
 
 /**
@@ -189,6 +196,80 @@ 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 source Complete installed item, detail, and socket-pool domains.
+ * @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(const items::catalysts::Source& source,
+                                           std::span<items::catalysts::Definition> output,
+                                           std::size_t& count,
+                                           items::catalysts::Report& report) noexcept;
+
+/**
+ * @param source Complete installed item, detail, and socket-pool domains.
+ * @param definitions Complete build-derived catalyst catalog.
+ * @return True when validation, publication, and any due cache write succeed.
+ */
+[[nodiscard]] bool
+publish_exotic_catalysts(const items::catalysts::Source& source,
+                         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;
+
+/**
+ * Adds all released catalyst acquisition and completion overrides to one Family-5 snapshot.
+ * @param family Candidate Family-5 state.
+ * @return True when all overrides fit and the complete state commits.
+ */
+[[nodiscard]] bool complete_exotic_catalyst_investment(Family5State& family) noexcept;
+
+/**
+ * Raises every account objective required by a released legacy catalyst.
+ * @param values Candidate account objective bank.
+ * @return True when all derived objectives fit and apply.
+ */
+[[nodiscard]] bool complete_exotic_catalyst_objectives(std::span<std::int32_t> values) 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;
+
+/** @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
@@ -521,7 +602,10 @@ publish_vendor_catalog(std::span<const vendors::IndexEntry> index,
 [[nodiscard]] bool find_vendor_definition(std::uint32_t definitionHash,
                                           vendors::Definition& definition) noexcept;
 
-/** @return True only when every domain is ready and any needed cache write succeeds. */
+/**
+ * An unsupported catalyst build finishes without writing an incomplete cache.
+ * @return True when required domains are ready and cache persistence safely finishes.
+ */
 [[nodiscard]] bool persist() noexcept;
 
 } // namespace sunrise::state::build_data

+ 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();

+ 39 - 15
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"
@@ -76,6 +77,7 @@ to_record(const constants::InvestmentConstants& value) noexcept {
                                             counts.socketPlugPools,
                                             scratch.socketPlugMembers,
                                             counts.socketPlugMembers)
+           && items::catalysts::snapshot(scratch.exoticCatalysts, counts.exoticCatalysts)
            && inventory::buckets::snapshot(scratch.inventoryBuckets, counts.inventoryBuckets)
            && socket_entry_lists::snapshot(scratch.socketEntryLists, counts.socketEntryLists)
            && socket_entry_lists::snapshot_entry_tables(scratch.socketEntryTables,
@@ -96,15 +98,8 @@ to_record(const constants::InvestmentConstants& value) noexcept {
            && cache::records::canonicalize(scratch, counts);
 }
 
-} // namespace
-
-/** @return The process-wide persistence context, shared by lifecycle and writer code. */
-Context& context() noexcept {
-    return g_context;
-}
-
-/** @return True when every extracted domain is complete in State. */
-bool all_domains_ready() noexcept {
+/** @return True when every required extracted domain is complete in State. */
+[[nodiscard]] bool required_domains_ready() noexcept {
     constants::InvestmentConstants published{};
     return runtime::named::ready() && item_definitions_ready() && configured_item_details_ready()
            && collectible_definitions_ready() && socket_plug_rules_ready()
@@ -114,6 +109,13 @@ bool all_domains_ready() noexcept {
            && hash_names_ready() && constants::find(published);
 }
 
+} // namespace
+
+/** @return The process-wide persistence context, shared by lifecycle and writer code. */
+Context& context() noexcept {
+    return g_context;
+}
+
 /** Gives mutable views over every fixed snapshot buffer. */
 cache::records::MutableDomains scratch_domains(Context& state) noexcept {
     const auto named = ensure_scratch<content::Definition, content::kDefinitionCatalogCapacity>(
@@ -139,6 +141,9 @@ 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 +193,7 @@ cache::records::MutableDomains scratch_domains(Context& state) noexcept {
         socketPlugRules,
         socketPlugPools,
         socketPlugMembers,
+        exoticCatalysts,
         inventoryBuckets,
         socketEntryLists,
         socketEntryTables,
@@ -225,6 +231,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);
@@ -249,6 +256,7 @@ void clear_locked(Context& state) noexcept {
     state.cacheDirectory = {};
     state.cachePath = {};
     state.buildIdentity = {};
+    state.catalystError = items::catalysts::Error::none;
     state.enabled = false;
     state.persisted = false;
     state.replaceStaleCache = false;
@@ -265,6 +273,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 +287,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(),
@@ -304,9 +315,10 @@ cache::records::Domains occupied_domains(Context& state,
     };
 }
 
-/** Saves one complete canonical snapshot when every domain is ready. */
-bool persist_if_complete_locked(Context& state) noexcept {
-    if (!all_domains_ready() || state.persisted || !state.enabled) {
+/** Saves one canonical snapshot when all domains required by its build are ready. */
+bool persist_if_ready_locked(Context& state, bool catalystRequired) noexcept {
+    if (!required_domains_ready() || (catalystRequired && !exotic_catalysts_ready())
+        || state.persisted || !state.enabled) {
         return true;
     }
     cache::records::DomainCounts counts{};
@@ -338,12 +350,24 @@ bool persist_if_complete_locked(Context& state) noexcept {
 
 namespace sunrise::state::build_data {
 
-/** @return True only when every domain is ready and any needed cache write works. */
+/** @return True when required domains are ready and any safe cache write works. */
 bool persist() noexcept {
     runtime::persistence::Context& state = runtime::persistence::context();
     AcquireSRWLockExclusive(&state.lock);
-    const bool result = runtime::persistence::all_domains_ready()
-                        && runtime::persistence::persist_if_complete_locked(state);
+    const bool requiredReady = runtime::persistence::required_domains_ready();
+    bool result = false;
+    switch (runtime::persistence::cache_action(
+        requiredReady, exotic_catalysts_ready(), state.catalystError)) {
+    case runtime::persistence::CacheAction::waitForDomains:
+        break;
+    case runtime::persistence::CacheAction::writeRequiredDomains:
+        // Unsupported catalyst facts do not prevent the other build-bound domains from caching.
+        result = runtime::persistence::persist_if_ready_locked(state, false);
+        break;
+    case runtime::persistence::CacheAction::writeCompleteCache:
+        result = runtime::persistence::persist_if_ready_locked(state, true);
+        break;
+    }
     ReleaseSRWLockExclusive(&state.lock);
     return result;
 }

+ 36 - 6
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"
@@ -26,6 +27,34 @@
 
 namespace sunrise::state::build_data::runtime::persistence {
 
+/** Cache action after one extraction pass. */
+enum class CacheAction {
+    waitForDomains,
+    writeRequiredDomains,
+    writeCompleteCache,
+};
+
+/**
+ * Selects the only safe persistence action for the current extraction state.
+ * @param requiredReady True when all required non-catalyst domains are ready.
+ * @param catalystReady True when the complete catalyst catalog is published.
+ * @param catalystError Exact result of the last catalyst derivation.
+ * @return Wait, write required domains, or write every domain.
+ */
+[[nodiscard]] constexpr CacheAction cache_action(bool requiredReady,
+                                                 bool catalystReady,
+                                                 items::catalysts::Error catalystError) noexcept {
+    if (!requiredReady) {
+        return CacheAction::waitForDomains;
+    }
+    if (catalystReady) {
+        return CacheAction::writeCompleteCache;
+    }
+    return catalystError == items::catalysts::Error::unsupportedBuild
+               ? CacheAction::writeRequiredDomains
+               : CacheAction::waitForDomains;
+}
+
 /** Fixed cache paths, identity, and canonical snapshot storage guarded by one State lock. */
 struct Context {
     SRWLOCK lock{SRWLOCK_INIT};
@@ -37,6 +66,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{};
@@ -56,6 +86,8 @@ struct Context {
     core::path::Buffer cacheDirectory;
     core::path::Buffer cachePath;
     BuildIdentity buildIdentity{};
+    /** Last catalyst derivation error for this build. */
+    items::catalysts::Error catalystError{items::catalysts::Error::none};
     bool enabled{};
     bool persisted{};
     bool replaceStaleCache{};
@@ -64,9 +96,6 @@ struct Context {
 /** @return The process-wide persistence context, shared by lifecycle and writer code. */
 [[nodiscard]] Context& context() noexcept;
 
-/** @return True when every generated domain is complete in State. */
-[[nodiscard]] bool all_domains_ready() noexcept;
-
 /**
  * Clears fixed cache paths, identity, flags, and snapshot storage.
  * @param state Its lock must already be held exclusively.
@@ -93,10 +122,11 @@ void release_scratch_locked(Context& state) noexcept;
 occupied_domains(Context& state, const cache::records::DomainCounts& counts) noexcept;
 
 /**
- * Saves one complete canonical snapshot when every domain is ready.
+ * Saves one canonical snapshot when all domains required by its build are ready.
  * @param state Its lock must already be held exclusively.
- * @return True when no write is due, or the complete State is on disk.
+ * @param catalystRequired True when the snapshot must contain the catalyst catalog.
+ * @return True when no write is due, or the required State is on disk.
  */
-[[nodiscard]] bool persist_if_complete_locked(Context& state) noexcept;
+[[nodiscard]] bool persist_if_ready_locked(Context& state, bool catalystRequired) noexcept;
 
 } // namespace sunrise::state::build_data::runtime::persistence

+ 1 - 1
Sunrise/src/state/build_data/runtime/persistence/publication_transaction.cpp

@@ -36,7 +36,7 @@ bool Transaction::finish(bool published, Rollback& rollback) noexcept {
         return false;
     }
     bool result = published;
-    if (result && !persist_if_complete_locked(*state_)) {
+    if (result && !persist_if_ready_locked(*state_, true)) {
         // A failed write must not leave the candidate visible as a published domain.
         rollback();
         result = false;

+ 1 - 1
Sunrise/src/state/investment/investment.h

@@ -7,7 +7,7 @@
 namespace sunrise::state {
 
 /** Fixed runtime capacity for safe account unlock overrides. */
-inline constexpr std::size_t kUnlockOverrideCapacity = 100;
+inline constexpr std::size_t kUnlockOverrideCapacity = 127;
 
 /** One logical unlock-flag value stored by slot. */
 struct UnlockFlagOverride {

+ 6 - 2
Sunrise/src/state/runtime/runtime.h

@@ -492,7 +492,11 @@ commit_profile_item_acquisition(PendingProfileItemAcquisition& mutation) noexcep
 /** @return A copy of the active account state, read under the lock. */
 [[nodiscard]] AccountState account_snapshot() noexcept;
 
-/** @return A copy of the evaluated content state, read under the lock. */
-[[nodiscard]] InvestmentState investment_snapshot() noexcept;
+/**
+ * Copies the evaluated content state and adds build-derived catalyst completion overrides.
+ * @param output Receives one complete Family-5 snapshot on success.
+ * @return False when the fixed override banks cannot hold the complete state.
+ */
+[[nodiscard]] bool investment_snapshot(InvestmentState& output) noexcept;
 
 } // namespace sunrise::state

+ 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];

+ 10 - 4
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.
@@ -362,12 +364,16 @@ bool new_bap_session(BapState& output) noexcept {
     return true;
 }
 
-/** @return A copy of the evaluated content state, read under the lock. */
-InvestmentState investment_snapshot() noexcept {
+/** Copies one complete evaluated content state with build-derived catalyst overrides. */
+bool investment_snapshot(InvestmentState& output) noexcept {
     AcquireSRWLockShared(&runtime::storage::g_stateLock);
-    const InvestmentState snapshot = runtime::storage::g_state.investment;
+    InvestmentState snapshot = runtime::storage::g_state.investment;
     ReleaseSRWLockShared(&runtime::storage::g_stateLock);
-    return snapshot;
+    if (!build_data::complete_exotic_catalyst_investment(snapshot.family5)) {
+        return false;
+    }
+    output = snapshot;
+    return true;
 }
 
 } // namespace sunrise::state