Sfoglia il codice sorgente

fix(investment): restore native catalyst presentation

y9522 2 settimane fa
parent
commit
eca814b265
24 ha cambiato i file con 919 aggiunte e 77 eliminazioni
  1. 1 0
      Sunrise/Sunrise.vcxproj
  2. 7 0
      Sunrise/src/client/content/items/packages/internal.h
  3. 67 0
      Sunrise/src/client/content/items/packages/package_catalyst_condition_reader.cpp
  4. 16 2
      Sunrise/src/client/content/items/packages/package_item_build.cpp
  5. 18 0
      Sunrise/src/client/content/items/packages/package_item_rows.cpp
  6. 68 0
      Sunrise/src/client/content/items/packages/package_socket_plug_build.cpp
  7. 30 0
      Sunrise/src/client/content/items/packages/package_socket_plug_build.h
  8. 9 0
      Sunrise/src/middleware/content/packages/tables/definition_index_table.h
  9. 6 5
      Sunrise/src/server/web_service/web_service_runtime.cpp
  10. 1 1
      Sunrise/src/state/build_data/cache/records/cache_detail_links.cpp
  11. 10 7
      Sunrise/src/state/build_data/cache/records/cache_exotic_catalyst_record_codec.cpp
  12. 6 4
      Sunrise/src/state/build_data/cache/records/format.h
  13. 48 2
      Sunrise/src/state/build_data/items/catalysts/definition.h
  14. 11 1
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_build_data_runtime.cpp
  15. 185 24
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_builder.cpp
  16. 16 0
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_builder.h
  17. 115 8
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_catalog.cpp
  18. 10 0
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_catalog.h
  19. 8 0
      Sunrise/src/state/build_data/runtime.h
  20. 6 2
      Sunrise/src/state/runtime/runtime.h
  21. 9 4
      Sunrise/src/state/runtime/state_runtime.cpp
  22. 2 0
      tests/Sunrise.Tests.vcxproj
  23. 259 14
      tests/exotic_catalyst_regression_tests.cpp
  24. 11 3
      tests/loadout_catalyst_regression_tests.cpp

+ 1 - 0
Sunrise/Sunrise.vcxproj

@@ -1020,6 +1020,7 @@
     <ClCompile Include="src\client\content\items\packages\package_collectible_build.cpp" />
     <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_material_requirement_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_detail_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_socket_plug_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_ability_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_ability_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_subclass_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_subclass_build.cpp" />

+ 7 - 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/collectibles/collectible_catalog.h"
 #include "../../../../state/build_data/constants/definition.h"
 #include "../../../../state/build_data/constants/definition.h"
 #include "../../../../state/build_data/inventory/buckets/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/details/definition.h"
 #include "../../../../state/build_data/items/item_catalog.h"
 #include "../../../../state/build_data/items/item_catalog.h"
 #include "../../../../state/build_data/material_requirements/material_requirement_catalog.h"
 #include "../../../../state/build_data/material_requirements/material_requirement_catalog.h"
@@ -61,6 +62,12 @@ struct Storage {
     std::vector<std::byte> definition{};
     std::vector<std::byte> definition{};
     /** Shared reusable/randomized plug-set table read from investment-root slot 51. */
     /** Shared reusable/randomized plug-set table read from investment-root slot 51. */
     std::vector<std::byte> plugSetTable{};
     std::vector<std::byte> plugSetTable{};
+    /** Dense item-indexed legacy 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{};
     /** Compact 0..3 special plug-category code of every dense installed item row. */
     /** Compact 0..3 special plug-category code of every dense installed item row. */
     std::array<std::uint8_t, state::build_data::items::kDefinitionCapacity> specialPlugCategories{};
     std::array<std::uint8_t, state::build_data::items::kDefinitionCapacity> specialPlugCategories{};
     /** Inventory routing rows held until the paired bucket-definition table is resolved. */
     /** Inventory routing rows held until the paired bucket-definition table is resolved. */

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

@@ -0,0 +1,67 @@
+#include "package_socket_plug_build.h"
+
+#include <array>
+#include <cstring>
+#include <limits>
+
+#include "../../../../middleware/content/packages/tables/definition_index_table.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+/** Native postfix opcodes for value, literal, and greater-than-or-equal. */
+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 kCompletionExpressionTokenCount = 3;
+
+} // namespace
+
+void read_catalyst_completion_condition(
+    std::span<const std::byte> definition,
+    std::uint16_t itemDefinitionIndex,
+    catalysts::CompletionCondition& output) noexcept {
+    output = {};
+    output.itemDefinitionIndex = itemDefinitionIndex;
+    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)
+            || expression.count != kCompletionExpressionTokenCount
+            || expression.elementClass != tables::kInvestmentExpressionRowClass
+            || expression.dataOffset > definition.size()
+            || definition.size() - expression.dataOffset
+                   < kCompletionExpressionTokenCount * kExpressionTokenSize) {
+            continue;
+        }
+        std::array<std::uint32_t, kCompletionExpressionTokenCount * 2> tokens{};
+        std::memcpy(tokens.data(),
+                    definition.data() + expression.dataOffset,
+                    tokens.size() * sizeof(tokens.front()));
+        if (tokens[0] != kValueOpcode || 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;
+        }
+        const auto valueIndex = static_cast<std::uint16_t>(tokens[1]);
+        const auto value = static_cast<std::int32_t>(tokens[3]);
+        if (output.state == catalysts::CompletionConditionState::absent) {
+            output.valueIndex = valueIndex;
+            output.value = value;
+            output.state = catalysts::CompletionConditionState::present;
+        } else if (output.valueIndex != valueIndex || output.value != value) {
+            output.valueIndex = catalysts::kUnavailableCompletionValueIndex;
+            output.value = 0;
+            output.state = catalysts::CompletionConditionState::ambiguous;
+            return;
+        }
+    }
+}
+
+} // namespace sunrise::client::content::items::packages

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

@@ -23,6 +23,7 @@
 #include "../../spawn_sets/spawn_set_build.h"
 #include "../../spawn_sets/spawn_set_build.h"
 #include "build.h"
 #include "build.h"
 #include "internal.h"
 #include "internal.h"
+#include "package_socket_plug_build.h"
 
 
 namespace sunrise::client::content::items::packages {
 namespace sunrise::client::content::items::packages {
 namespace {
 namespace {
@@ -41,7 +42,8 @@ namespace {
            && state::build_data::progression_definitions_ready()
            && state::build_data::progression_definitions_ready()
            && state::build_data::scenario_layouts_ready() && state::build_data::spawn_sets_ready()
            && state::build_data::scenario_layouts_ready() && state::build_data::spawn_sets_ready()
            && state::build_data::hash_names_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. */
 /** @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::ability_buckets_ready()
            && state::build_data::socket_entry_buckets_ready()
            && state::build_data::socket_entry_buckets_ready()
            && state::build_data::progression_definitions_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
 } // namespace
@@ -137,6 +140,17 @@ bool build() noexcept {
                     continue;
                     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)) {
+                    continue;
+                }
+            }
             reason = "buckets";
             reason = "buckets";
             if (!build_buckets(source, storage, std::span<const std::byte>{storage.root})) {
             if (!build_buckets(source, storage, std::span<const std::byte>{storage.root})) {
                 continue;
                 continue;

+ 18 - 0
Sunrise/src/client/content/items/packages/package_item_rows.cpp

@@ -55,6 +55,15 @@ bool build_item_rows(const reader::Source& source,
     if (retainDetails && storage.details.size() != kDetailCapacity) {
     if (retainDetails && storage.details.size() != kDetailCapacity) {
         storage.details.assign(kDetailCapacity, build_details::Definition{});
         storage.details.assign(kDetailCapacity, build_details::Definition{});
     }
     }
+    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 bool detailStorageReady = !retainDetails || storage.details.size() == kDetailCapacity;
     const std::span<const std::byte> container{storage.child};
     const std::span<const std::byte> container{storage.child};
     reason = "rows";
     reason = "rows";
@@ -147,6 +156,13 @@ bool build_item_rows(const reader::Source& source,
             if (retainDetails) {
             if (retainDetails) {
                 storage.details[builtDetailCount++] = detail;
                 storage.details[builtDetailCount++] = detail;
             }
             }
+            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) {
             if (needSocketRows) {
                 (void)socketPlugBuild.append(item,
                 (void)socketPlugBuild.append(item,
                                              std::span<const std::byte>{storage.definition},
                                              std::span<const std::byte>{storage.definition},
@@ -172,6 +188,8 @@ bool build_item_rows(const reader::Source& source,
                 socketPlugBuild.rules(),
                 socketPlugBuild.rules(),
                 socketPlugBuild.pools(),
                 socketPlugBuild.pools(),
                 socketPlugBuild.members(),
                 socketPlugBuild.members(),
+                storage.catalystCompletionConditions,
+                storage.catalystAcquisitionGates,
                 catalystRows,
                 catalystRows,
                 catalystCount,
                 catalystCount,
                 catalystReport);
                 catalystReport);

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

@@ -1,8 +1,10 @@
 #include "package_socket_plug_build.h"
 #include "package_socket_plug_build.h"
 
 
 #include <algorithm>
 #include <algorithm>
+#include <cstring>
 #include <limits>
 #include <limits>
 
 
+#include "../../../../middleware/content/packages/tables/definition_index_table.h"
 #include "../../../../state/build_data/runtime.h"
 #include "../../../../state/build_data/runtime.h"
 
 
 namespace sunrise::client::content::items::packages {
 namespace sunrise::client::content::items::packages {
@@ -25,6 +27,19 @@ constexpr std::uint16_t kTrackerSocketType = 518;
 /** FNV-1a constants make pool fingerprints stable and cheap. */
 /** FNV-1a constants make pool fingerprints stable and cheap. */
 constexpr std::uint64_t kHashOffsetBasis = 14695981039346656037ULL;
 constexpr std::uint64_t kHashOffsetBasis = 14695981039346656037ULL;
 constexpr std::uint64_t kHashPrime = 1099511628211ULL;
 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. */
 /** Visitor adapter that appends one list member to a bounded lane candidate. */
 struct VisitorContext {
 struct VisitorContext {
@@ -41,6 +56,59 @@ struct VisitorContext {
 
 
 } // namespace
 } // 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;
+}
+
 /** Returns the compact 1-based code of one native category-expansion family. */
 /** Returns the compact 1-based code of one native category-expansion family. */
 std::uint8_t special_plug_category(std::uint32_t categoryHash) noexcept {
 std::uint8_t special_plug_category(std::uint32_t categoryHash) noexcept {
     for (std::size_t index = 0; index < kExpandableCategories.size(); ++index) {
     for (std::size_t index = 0; index < kExpandableCategories.size(); ++index) {

+ 30 - 0
Sunrise/src/client/content/items/packages/package_socket_plug_build.h

@@ -6,14 +6,44 @@
 #include <span>
 #include <span>
 #include <vector>
 #include <vector>
 
 
+#include "../../../../middleware/content/packages/reader/reader.h"
 #include "../../../../middleware/content/packages/tables/items.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/item_catalog.h"
 #include "../../../../state/build_data/items/socket_plugs/definition.h"
 #include "../../../../state/build_data/items/socket_plugs/definition.h"
 
 
 namespace sunrise::client::content::items::packages {
 namespace sunrise::client::content::items::packages {
 
 
+namespace reader = middleware::content::packages::reader;
 namespace tables = middleware::content::packages::tables;
 namespace tables = middleware::content::packages::tables;
 namespace socket_plugs = state::build_data::items::socket_plugs;
 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;
+
+/**
+ * Finds the unique three-token `value >= literal` completion rule in one effect item.
+ * Duplicate expressions with the same values remain one condition. Distinct matches are marked
+ * ambiguous so a released catalyst fails before publication.
+ */
+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. */
 /** Fixed-size, heap-backed interning state for one installed package pass. */
 class SocketPlugBuild final {
 class SocketPlugBuild final {

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

@@ -183,6 +183,15 @@ slot_tag(std::span<const std::byte> blob, std::size_t index, std::uint32_t& tag)
 inline constexpr std::size_t kItemTableSlot = 48;
 inline constexpr std::size_t kItemTableSlot = 48;
 /** The investment root holds the shared reusable/randomized plug-set table at this slot. */
 /** The investment root holds the shared reusable/randomized plug-set table at this slot. */
 inline constexpr std::size_t kPlugSetTableSlot = 51;
 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;
 /** The investment root holds the socket entry list table at this slot. */
 /** The investment root holds the socket entry list table at this slot. */
 inline constexpr std::size_t kSocketEntryListTableSlot = 97;
 inline constexpr std::size_t kSocketEntryListTableSlot = 97;
 /** The globals container names the investment root as its first child. */
 /** The globals container names the investment root as its first child. */

+ 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);
     note_reports(message);
 
 
     if (message.opcode == messages::opcode205::kOpcode) {
     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);
                || encode_echo(message, response, written);
     }
     }
 
 
@@ -251,8 +252,8 @@ bool consume(std::span<const std::byte> request,
         if (!bootstrap.hasPrimarySoid) {
         if (!bootstrap.hasPrimarySoid) {
             bootstrap.primarySoid = state::account_snapshot().primarySoid;
             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(
             || !messages::opcode503::encode_response(
                 message, bootstrap, investment, next_family5_clock(), response, written)) {
                 message, bootstrap, investment, next_family5_clock(), response, written)) {
             return encode_echo(message, response, written);
             return encode_echo(message, response, written);

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

@@ -131,7 +131,7 @@ bool valid_exotic_catalyst_links(
         pools,
         pools,
         members,
         members,
     };
     };
-    return items::catalysts::matches_derived(source, facts, catalysts);
+    return items::catalysts::matches_cached(source, facts, catalysts);
 }
 }
 
 
 } // namespace sunrise::state::build_data::cache::records
 } // namespace sunrise::state::build_data::cache::records

+ 10 - 7
Sunrise/src/state/build_data/cache/records/cache_exotic_catalyst_record_codec.cpp

@@ -5,18 +5,20 @@ namespace sunrise::state::build_data::cache::records {
 bool encode(const items::catalysts::Definition& value,
 bool encode(const items::catalysts::Definition& value,
             ExoticCatalystRecord& record) noexcept {
             ExoticCatalystRecord& record) noexcept {
     record = {};
     record = {};
-    if (value.reserved != 0
-        || (value.availability != items::catalysts::Availability::released
+    if (value.availability != items::catalysts::Availability::released
             && value.availability != items::catalysts::Availability::placeholder
             && value.availability != items::catalysts::Availability::placeholder
-            && value.availability != items::catalysts::Availability::unsupported)) {
+            && value.availability != items::catalysts::Availability::unsupported) {
         return false;
         return false;
     }
     }
     record.itemDefinitionHash = value.itemDefinitionHash;
     record.itemDefinitionHash = value.itemDefinitionHash;
     record.itemDefinitionIndex = value.itemDefinitionIndex;
     record.itemDefinitionIndex = value.itemDefinitionIndex;
     record.completedPlugDefinitionIndex = value.completedPlugDefinitionIndex;
     record.completedPlugDefinitionIndex = value.completedPlugDefinitionIndex;
     record.effectDefinitionIndex = value.effectDefinitionIndex;
     record.effectDefinitionIndex = value.effectDefinitionIndex;
+    record.acquisitionDefinitionIndex = value.acquisitionDefinitionIndex;
+    record.completionValueIndex = value.completionValueIndex;
     record.socketLane = value.socketLane;
     record.socketLane = value.socketLane;
     record.availability = static_cast<std::uint8_t>(value.availability);
     record.availability = static_cast<std::uint8_t>(value.availability);
+    record.completionValue = value.completionValue;
     return true;
     return true;
 }
 }
 
 
@@ -24,19 +26,20 @@ bool decode(const ExoticCatalystRecord& record,
             items::catalysts::Definition& value) noexcept {
             items::catalysts::Definition& value) noexcept {
     value = {};
     value = {};
     const auto availability = static_cast<items::catalysts::Availability>(record.availability);
     const auto availability = static_cast<items::catalysts::Availability>(record.availability);
-    if (record.reserved != 0
-        || (availability != items::catalysts::Availability::released
+    if (availability != items::catalysts::Availability::released
             && availability != items::catalysts::Availability::placeholder
             && availability != items::catalysts::Availability::placeholder
-            && availability != items::catalysts::Availability::unsupported)) {
+            && availability != items::catalysts::Availability::unsupported) {
         return false;
         return false;
     }
     }
     value = {record.itemDefinitionHash,
     value = {record.itemDefinitionHash,
              record.itemDefinitionIndex,
              record.itemDefinitionIndex,
              record.completedPlugDefinitionIndex,
              record.completedPlugDefinitionIndex,
              record.effectDefinitionIndex,
              record.effectDefinitionIndex,
+             record.acquisitionDefinitionIndex,
+             record.completionValueIndex,
              record.socketLane,
              record.socketLane,
              availability,
              availability,
-             0};
+             record.completionValue};
     return true;
     return true;
 }
 }
 
 

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

@@ -30,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.
  * 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.
  * 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. */
 /** Signed -1 on disk means there is no equipment slot. */
 inline constexpr std::int8_t kAbsentEquipmentSlot = -1;
 inline constexpr std::int8_t kAbsentEquipmentSlot = -1;
 /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */
 /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */
@@ -211,10 +211,11 @@ struct ExoticCatalystRecord {
     std::uint16_t itemDefinitionIndex{};
     std::uint16_t itemDefinitionIndex{};
     std::uint16_t completedPlugDefinitionIndex{};
     std::uint16_t completedPlugDefinitionIndex{};
     std::uint16_t effectDefinitionIndex{};
     std::uint16_t effectDefinitionIndex{};
+    std::uint16_t acquisitionDefinitionIndex{};
+    std::uint16_t completionValueIndex{};
     std::uint8_t socketLane{};
     std::uint8_t socketLane{};
     std::uint8_t availability{};
     std::uint8_t availability{};
-    /** Must remain zero so all unused bytes have one canonical value. */
-    std::uint16_t reserved{};
+    std::int32_t completionValue{};
 };
 };
 
 
 /** Disk form of one inventory-bucket array-routing descriptor. */
 /** Disk form of one inventory-bucket array-routing descriptor. */
@@ -508,7 +509,8 @@ static_assert(sizeof(SocketPlugRuleRecord)
 static_assert(sizeof(SocketPlugPoolRecord) == 2 * sizeof(std::uint32_t));
 static_assert(sizeof(SocketPlugPoolRecord) == 2 * sizeof(std::uint32_t));
 static_assert(sizeof(SocketPlugMemberRecord) == sizeof(std::uint16_t));
 static_assert(sizeof(SocketPlugMemberRecord) == sizeof(std::uint16_t));
 static_assert(sizeof(ExoticCatalystRecord)
 static_assert(sizeof(ExoticCatalystRecord)
-              == sizeof(std::uint32_t) + 4 * sizeof(std::uint16_t) + 2 * sizeof(std::uint8_t));
+              == 2 * sizeof(std::uint32_t) + 5 * sizeof(std::uint16_t)
+                     + 2 * sizeof(std::uint8_t));
 static_assert(sizeof(InventoryBucketRecord)
 static_assert(sizeof(InventoryBucketRecord)
               == 4 * sizeof(std::uint8_t) + 2 * sizeof(std::uint16_t));
               == 4 * sizeof(std::uint8_t) + 2 * sizeof(std::uint16_t));
 static_assert(sizeof(SocketEntryListRecord)
 static_assert(sizeof(SocketEntryListRecord)

+ 48 - 2
Sunrise/src/state/build_data/items/catalysts/definition.h

@@ -8,6 +8,10 @@ namespace sunrise::state::build_data::items::catalysts {
 
 
 /** The target build contains fewer than 128 exotic weapon catalyst-shaped sockets. */
 /** The target build contains fewer than 128 exotic weapon catalyst-shaped sockets. */
 inline constexpr std::size_t kDefinitionCapacity = 128;
 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 acquired-state gate. */
+inline constexpr std::uint16_t kUnavailableAcquisitionIndex = 0xFFFFU;
 
 
 /** Release status from the build-scoped Season 11 availability data. */
 /** Release status from the build-scoped Season 11 availability data. */
 enum class Availability : std::uint8_t {
 enum class Availability : std::uint8_t {
@@ -27,6 +31,8 @@ enum class Error : std::uint8_t {
     invalidSocket,
     invalidSocket,
     invalidPlug,
     invalidPlug,
     invalidEffect,
     invalidEffect,
+    invalidAcquisition,
+    invalidCompletion,
     invalidItemState,
     invalidItemState,
 };
 };
 
 
@@ -54,6 +60,10 @@ enum class Error : std::uint8_t {
         return "invalid_plug";
         return "invalid_plug";
     case Error::invalidEffect:
     case Error::invalidEffect:
         return "invalid_effect";
         return "invalid_effect";
+    case Error::invalidAcquisition:
+        return "invalid_acquisition";
+    case Error::invalidCompletion:
+        return "invalid_completion";
     case Error::invalidItemState:
     case Error::invalidItemState:
         return "invalid_item_state";
         return "invalid_item_state";
     }
     }
@@ -68,10 +78,14 @@ struct Definition {
     std::uint16_t completedPlugDefinitionIndex{};
     std::uint16_t completedPlugDefinitionIndex{};
     /** Item row that supplies the completed catalyst's native perks and stat changes. */
     /** Item row that supplies the completed catalyst's native perks and stat changes. */
     std::uint16_t effectDefinitionIndex{};
     std::uint16_t effectDefinitionIndex{};
+    /** Family-5 acquired-state slot that makes the catalyst socket visible. */
+    std::uint16_t acquisitionDefinitionIndex{kUnavailableAcquisitionIndex};
+    /** Family-5 value slot used by legacy catalyst completion expressions. */
+    std::uint16_t completionValueIndex{kUnavailableCompletionValueIndex};
     std::uint8_t socketLane{};
     std::uint8_t socketLane{};
     Availability availability{Availability::unsupported};
     Availability availability{Availability::unsupported};
-    /** Must remain zero so the cache form stays canonical. */
-    std::uint16_t reserved{};
+    /** Required value for a legacy completion expression; zero when no value is needed. */
+    std::int32_t completionValue{};
 };
 };
 
 
 /** The only state a released catalyst exposes to callers. */
 /** The only state a released catalyst exposes to callers. */
@@ -79,6 +93,38 @@ struct CompletedCatalyst {
     std::uint8_t socketLane{};
     std::uint8_t socketLane{};
     std::uint16_t completedPlugDefinitionIndex{};
     std::uint16_t completedPlugDefinitionIndex{};
     std::uint16_t effectDefinitionIndex{};
     std::uint16_t effectDefinitionIndex{};
+    std::uint16_t acquisitionDefinitionIndex{kUnavailableAcquisitionIndex};
+    std::uint16_t completionValueIndex{kUnavailableCompletionValueIndex};
+    std::int32_t completionValue{};
+};
+
+/** 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 legacy completion value. */
+struct CompletionCondition {
+    std::uint16_t itemDefinitionIndex{};
+    std::uint16_t valueIndex{kUnavailableCompletionValueIndex};
+    std::int32_t value{};
+    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. */
 /** One resolver result. Errors never carry a completed state. */

+ 11 - 1
Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_build_data_runtime.cpp

@@ -29,6 +29,7 @@ namespace {
         items::Definition item{};
         items::Definition item{};
         items::Definition completedPlug{};
         items::Definition completedPlug{};
         items::Definition effect{};
         items::Definition effect{};
+        items::Definition acquisition{};
         items::details::Definition detail{};
         items::details::Definition detail{};
         items::details::Definition effectDetail{};
         items::details::Definition effectDetail{};
         const auto released = std::lower_bound(facts.releasedWeaponHashes.begin(),
         const auto released = std::lower_bound(facts.releasedWeaponHashes.begin(),
@@ -63,6 +64,7 @@ namespace {
         if (catalyst.availability != items::catalysts::Availability::unsupported
         if (catalyst.availability != items::catalysts::Availability::unsupported
             && (!items::find_index(catalyst.completedPlugDefinitionIndex, completedPlug)
             && (!items::find_index(catalyst.completedPlugDefinitionIndex, completedPlug)
                 || !items::find_index(catalyst.effectDefinitionIndex, effect)
                 || !items::find_index(catalyst.effectDefinitionIndex, effect)
+                || !items::find_index(catalyst.acquisitionDefinitionIndex, acquisition)
                 || !items::details::find(catalyst.effectDefinitionIndex, effectDetail)
                 || !items::details::find(catalyst.effectDefinitionIndex, effectDetail)
                 || effectDetail.definitionHash != effect.definitionHash
                 || effectDetail.definitionHash != effect.definitionHash
                 || completedPlug.plugCategoryHash == 0
                 || completedPlug.plugCategoryHash == 0
@@ -91,6 +93,8 @@ bool derive_exotic_catalysts(
     std::span<const items::socket_plugs::Rule> socketPlugRules,
     std::span<const items::socket_plugs::Rule> socketPlugRules,
     std::span<const items::socket_plugs::Pool> socketPlugPools,
     std::span<const items::socket_plugs::Pool> socketPlugPools,
     std::span<const items::socket_plugs::Member> socketPlugMembers,
     std::span<const items::socket_plugs::Member> socketPlugMembers,
+    std::span<const items::catalysts::CompletionCondition> completionConditions,
+    std::span<const items::catalysts::AcquisitionGate> acquisitionGates,
     std::span<items::catalysts::Definition> output,
     std::span<items::catalysts::Definition> output,
     std::size_t& count,
     std::size_t& count,
     items::catalysts::Report& report) noexcept {
     items::catalysts::Report& report) noexcept {
@@ -111,7 +115,9 @@ bool derive_exotic_catalysts(
                                           itemDetails,
                                           itemDetails,
                                           socketPlugRules,
                                           socketPlugRules,
                                           socketPlugPools,
                                           socketPlugPools,
-                                          socketPlugMembers};
+                                          socketPlugMembers,
+                                          completionConditions,
+                                          acquisitionGates};
     return items::catalysts::derive(
     return items::catalysts::derive(
         source, items::catalysts::generated_facts(), output, count, report);
         source, items::catalysts::generated_facts(), output, count, report);
 }
 }
@@ -132,6 +138,10 @@ items::catalysts::ApplyResult complete_exotic_catalyst(
     return items::catalysts::apply_completed(itemDefinitionIndex, flags, plugs);
     return items::catalysts::apply_completed(itemDefinitionIndex, flags, plugs);
 }
 }
 
 
+bool complete_exotic_catalyst_investment(Family5State& family) noexcept {
+    return items::catalysts::append_investment_overrides(family);
+}
+
 std::uint16_t resolve_exotic_catalyst_effect(std::uint16_t itemDefinitionIndex,
 std::uint16_t resolve_exotic_catalyst_effect(std::uint16_t itemDefinitionIndex,
                                              std::uint8_t socketLane,
                                              std::uint8_t socketLane,
                                              std::uint16_t plugDefinitionIndex) noexcept {
                                              std::uint16_t plugDefinitionIndex) noexcept {

+ 185 - 24
Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_builder.cpp

@@ -15,8 +15,11 @@ constexpr std::int8_t kLastWeaponSlot = 9;
 struct LaneResult {
 struct LaneResult {
     bool catalyst{};
     bool catalyst{};
     Error error{Error::none};
     Error error{Error::none};
-    std::uint16_t completedPlugDefinitionIndex{};
-    std::uint16_t effectDefinitionIndex{};
+    std::uint16_t completedPlugDefinitionIndex{details::kUnavailableItemIndex};
+    std::uint16_t effectDefinitionIndex{details::kUnavailableItemIndex};
+    std::uint16_t acquisitionDefinitionIndex{kUnavailableAcquisitionIndex};
+    std::uint16_t completionValueIndex{kUnavailableCompletionValueIndex};
+    std::int32_t completionValue{};
 };
 };
 
 
 /**
 /**
@@ -70,19 +73,66 @@ find_detail(std::span<const details::Definition> definitions, std::uint16_t inde
     return found != definitions.end() && found->definitionIndex == index ? &*found : nullptr;
     return found != definitions.end() && found->definitionIndex == index ? &*found : nullptr;
 }
 }
 
 
+/** Finds one dense or sorted completion-condition row. */
+[[nodiscard]] const CompletionCondition*
+find_completion_condition(std::span<const CompletionCondition> definitions,
+                          std::uint16_t index) noexcept {
+    if (static_cast<std::size_t>(index) < definitions.size()
+        && definitions[index].itemDefinitionIndex == index) {
+        return &definitions[index];
+    }
+    const auto found = std::lower_bound(
+        definitions.begin(),
+        definitions.end(),
+        index,
+        [](const CompletionCondition& value, auto key) {
+            return value.itemDefinitionIndex < key;
+        });
+    return found != definitions.end() && found->itemDefinitionIndex == index ? &*found : nullptr;
+}
+
+/** Finds one dense or sorted socket-type acquisition row. */
+[[nodiscard]] const AcquisitionGate*
+find_acquisition_gate(std::span<const AcquisitionGate> definitions,
+                      std::uint16_t socketType) noexcept {
+    if (static_cast<std::size_t>(socketType) < definitions.size()
+        && definitions[socketType].socketType == socketType) {
+        return &definitions[socketType];
+    }
+    const auto found = std::lower_bound(
+        definitions.begin(),
+        definitions.end(),
+        socketType,
+        [](const AcquisitionGate& value, auto key) { return value.socketType < key; });
+    return found != definitions.end() && found->socketType == socketType ? &*found : nullptr;
+}
+
 /**
 /**
  * Resolves the exotic item row that owns one completed plug's native perks and stat changes.
  * 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
  * 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.
  * the same category, so the unique exotic stackable item in that category supplies the effect.
  * @param source Parsed target-build tables.
  * @param source Parsed target-build tables.
- * @param completedPlugDefinitionIndex Completed socket plug.
+ * @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.
  * @return Completed lane relation, or an effect mapping error.
  */
  */
 [[nodiscard]] LaneResult complete_lane(const Source& source,
 [[nodiscard]] LaneResult complete_lane(const Source& source,
-                                       std::uint16_t completedPlugDefinitionIndex) noexcept {
+                                       std::uint16_t completedPlugDefinitionIndex,
+                                       std::uint16_t socketType) noexcept {
     const items::Definition* completed = find_item(source.items, completedPlugDefinitionIndex);
     const items::Definition* completed = find_item(source.items, completedPlugDefinitionIndex);
     if (completed == nullptr || completed->plugCategoryHash == 0) {
     if (completed == nullptr || completed->plugCategoryHash == 0) {
-        return {true, Error::invalidEffect, completedPlugDefinitionIndex, 0};
+        return {.catalyst = true,
+                .error = Error::invalidEffect,
+                .completedPlugDefinitionIndex = completedPlugDefinitionIndex};
+    }
+
+    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};
     }
     }
 
 
     std::optional<std::uint16_t> effect;
     std::optional<std::uint16_t> effect;
@@ -99,16 +149,42 @@ find_detail(std::span<const details::Definition> definitions, std::uint16_t inde
             continue;
             continue;
         }
         }
         if (effect.has_value()) {
         if (effect.has_value()) {
-            return {true, Error::invalidEffect, completedPlugDefinitionIndex, 0};
+            return {.catalyst = true,
+                    .error = Error::invalidEffect,
+                    .completedPlugDefinitionIndex = completedPlugDefinitionIndex};
         }
         }
         effect = candidate.definitionIndex;
         effect = candidate.definitionIndex;
     }
     }
-    return effect.has_value()
-               ? LaneResult{true,
-                            Error::none,
-                            completedPlugDefinitionIndex,
-                            *effect}
-               : LaneResult{true, Error::invalidEffect, completedPlugDefinitionIndex, 0};
+    if (!effect.has_value()) {
+        return {.catalyst = true,
+                .error = Error::invalidEffect,
+                .completedPlugDefinitionIndex = completedPlugDefinitionIndex};
+    }
+
+    std::uint16_t completionValueIndex = kUnavailableCompletionValueIndex;
+    std::int32_t completionValue = 0;
+    if (*effect != completedPlugDefinitionIndex) {
+        const CompletionCondition* condition =
+            find_completion_condition(source.completionConditions, *effect);
+        if (condition == nullptr || condition->state != CompletionConditionState::present
+            || condition->valueIndex == kUnavailableCompletionValueIndex
+            || condition->value <= 0) {
+            return {.catalyst = true,
+                    .error = Error::invalidCompletion,
+                    .completedPlugDefinitionIndex = completedPlugDefinitionIndex,
+                    .effectDefinitionIndex = *effect,
+                    .acquisitionDefinitionIndex = acquisition->definitionIndex};
+        }
+        completionValueIndex = condition->valueIndex;
+        completionValue = condition->value;
+    }
+    return {.catalyst = true,
+            .error = Error::none,
+            .completedPlugDefinitionIndex = completedPlugDefinitionIndex,
+            .effectDefinitionIndex = *effect,
+            .acquisitionDefinitionIndex = acquisition->definitionIndex,
+            .completionValueIndex = completionValueIndex,
+            .completionValue = completionValue};
 }
 }
 
 
 /**
 /**
@@ -168,12 +244,12 @@ released_index(std::span<const std::uint32_t> released, std::uint32_t hash) noex
         return {};
         return {};
     }
     }
     if (rule->reserved != 0 || rule->poolIndex >= source.socketPlugPools.size()) {
     if (rule->reserved != 0 || rule->poolIndex >= source.socketPlugPools.size()) {
-        return {true, Error::invalidSocket, 0, 0};
+        return {.catalyst = true, .error = Error::invalidSocket};
     }
     }
     const socket_plugs::Pool& pool = source.socketPlugPools[rule->poolIndex];
     const socket_plugs::Pool& pool = source.socketPlugPools[rule->poolIndex];
     if (pool.memberOffset > source.socketPlugMembers.size()
     if (pool.memberOffset > source.socketPlugMembers.size()
         || pool.memberCount > source.socketPlugMembers.size() - pool.memberOffset) {
         || pool.memberCount > source.socketPlugMembers.size() - pool.memberOffset) {
-        return {true, Error::invalidSocket, 0, 0};
+        return {.catalyst = true, .error = Error::invalidSocket};
     }
     }
 
 
     const auto members = source.socketPlugMembers.subspan(pool.memberOffset, pool.memberCount);
     const auto members = source.socketPlugMembers.subspan(pool.memberOffset, pool.memberCount);
@@ -201,14 +277,14 @@ released_index(std::span<const std::uint32_t> released, std::uint32_t hash) noex
         return {};
         return {};
     }
     }
     if (invalidMember) {
     if (invalidMember) {
-        return {true, Error::invalidPlug, 0, 0};
+        return {.catalyst = true, .error = Error::invalidPlug};
     }
     }
 
 
     const std::uint16_t defaultIndex = detail.initialPlugIndices[lane];
     const std::uint16_t defaultIndex = detail.initialPlugIndices[lane];
     const items::Definition* defaultPlug = find_item(source.items, defaultIndex);
     const items::Definition* defaultPlug = find_item(source.items, defaultIndex);
     if (defaultIndex == details::kUnavailableItemIndex || defaultPlug == nullptr
     if (defaultIndex == details::kUnavailableItemIndex || defaultPlug == nullptr
         || std::find(members.begin(), members.end(), defaultIndex) == members.end()) {
         || std::find(members.begin(), members.end(), defaultIndex) == members.end()) {
-        return {true, Error::invalidPlug, 0, 0};
+        return {.catalyst = true, .error = Error::invalidPlug};
     }
     }
 
 
     if (members.size() == 2 && hasEmpty && legacyCount == 0
     if (members.size() == 2 && hasEmpty && legacyCount == 0
@@ -217,14 +293,15 @@ released_index(std::span<const std::uint32_t> released, std::uint32_t hash) noex
             std::find_if(members.begin(), members.end(), [defaultIndex](auto member) {
             std::find_if(members.begin(), members.end(), [defaultIndex](auto member) {
                 return member != defaultIndex;
                 return member != defaultIndex;
             });
             });
-        return active != members.end() ? complete_lane(source, *active)
-                                       : LaneResult{true, Error::ambiguousLifecycle, 0, 0};
+        return active != members.end()
+                   ? complete_lane(source, *active, detail.socketTypes[lane])
+                   : LaneResult{.catalyst = true, .error = Error::ambiguousLifecycle};
     }
     }
     if (members.size() == 3 && !hasEmpty && legacyCount == 1
     if (members.size() == 3 && !hasEmpty && legacyCount == 1
         && legacyCompletionIndex != defaultIndex) {
         && legacyCompletionIndex != defaultIndex) {
-        return complete_lane(source, legacyCompletionIndex);
+        return complete_lane(source, legacyCompletionIndex, detail.socketTypes[lane]);
     }
     }
-    return {true, Error::ambiguousLifecycle, 0, 0};
+    return {.catalyst = true, .error = Error::ambiguousLifecycle};
 }
 }
 
 
 /**
 /**
@@ -317,8 +394,12 @@ bool derive(const Source& source,
                 unclear = Error::ambiguousLifecycle;
                 unclear = Error::ambiguousLifecycle;
                 continue;
                 continue;
             }
             }
-            completed = CompletedCatalyst{
-                lane, result.completedPlugDefinitionIndex, result.effectDefinitionIndex};
+            completed = CompletedCatalyst{lane,
+                                          result.completedPlugDefinitionIndex,
+                                          result.effectDefinitionIndex,
+                                          result.acquisitionDefinitionIndex,
+                                          result.completionValueIndex,
+                                          result.completionValue};
         }
         }
         if (!completed.has_value() && !unclear.has_value()) {
         if (!completed.has_value() && !unclear.has_value()) {
             continue;
             continue;
@@ -344,6 +425,8 @@ bool derive(const Source& source,
                                          item->definitionIndex,
                                          item->definitionIndex,
                                          details::kUnavailableItemIndex,
                                          details::kUnavailableItemIndex,
                                          details::kUnavailableItemIndex,
                                          details::kUnavailableItemIndex,
+                                         kUnavailableAcquisitionIndex,
+                                         kUnavailableCompletionValueIndex,
                                          detectedLane,
                                          detectedLane,
                                          Availability::unsupported,
                                          Availability::unsupported,
                                          0};
                                          0};
@@ -376,10 +459,12 @@ bool derive(const Source& source,
                                      item->definitionIndex,
                                      item->definitionIndex,
                                      completed->completedPlugDefinitionIndex,
                                      completed->completedPlugDefinitionIndex,
                                      completed->effectDefinitionIndex,
                                      completed->effectDefinitionIndex,
+                                     completed->acquisitionDefinitionIndex,
+                                     completed->completionValueIndex,
                                      completed->socketLane,
                                      completed->socketLane,
                                      release.has_value() ? Availability::released
                                      release.has_value() ? Availability::released
                                                          : Availability::placeholder,
                                                          : Availability::placeholder,
-                                     0};
+                                     completed->completionValue};
     }
     }
 
 
     for (std::size_t index = 0; index < facts.releasedWeaponHashes.size(); ++index) {
     for (std::size_t index = 0; index < facts.releasedWeaponHashes.size(); ++index) {
@@ -417,10 +502,86 @@ bool matches_derived(const Source& source,
                    && left.itemDefinitionIndex == right.itemDefinitionIndex
                    && left.itemDefinitionIndex == right.itemDefinitionIndex
                    && left.completedPlugDefinitionIndex == right.completedPlugDefinitionIndex
                    && left.completedPlugDefinitionIndex == right.completedPlugDefinitionIndex
                    && left.effectDefinitionIndex == right.effectDefinitionIndex
                    && left.effectDefinitionIndex == right.effectDefinitionIndex
+                   && left.acquisitionDefinitionIndex == right.acquisitionDefinitionIndex
+                   && left.completionValueIndex == right.completionValueIndex
                    && left.socketLane == right.socketLane
                    && left.socketLane == right.socketLane
                    && left.availability == right.availability
                    && left.availability == right.availability
-                   && left.reserved == right.reserved;
+                   && left.completionValue == right.completionValue;
         });
         });
 }
 }
 
 
+bool matches_cached(const Source& source,
+                    const Facts& facts,
+                    std::span<const Definition> definitions) noexcept {
+    std::array<CompletionCondition, kDefinitionCapacity> completionConditions{};
+    std::array<AcquisitionGate, kDefinitionCapacity> acquisitionGates{};
+    std::size_t completionCount = 0;
+    std::size_t acquisitionCount = 0;
+    for (const Definition& definition : definitions) {
+        if (definition.availability == Availability::unsupported) {
+            continue;
+        }
+        const details::Definition* detail = find_detail(source.details,
+                                                        definition.itemDefinitionIndex);
+        if (detail == nullptr || definition.socketLane >= detail->ordinarySocketCount
+            || find_item(source.items, definition.acquisitionDefinitionIndex) == nullptr) {
+            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};
+        }
+
+        if (definition.completionValueIndex == kUnavailableCompletionValueIndex) {
+            continue;
+        }
+        const auto priorCondition = std::find_if(
+            completionConditions.begin(),
+            completionConditions.begin() + completionCount,
+            [&definition](const CompletionCondition& condition) {
+                return condition.itemDefinitionIndex == definition.effectDefinitionIndex;
+            });
+        if (priorCondition != completionConditions.begin() + completionCount) {
+            if (priorCondition->valueIndex != definition.completionValueIndex
+                || priorCondition->value != definition.completionValue) {
+                return false;
+            }
+        } else if (completionCount >= completionConditions.size()) {
+            return false;
+        } else {
+            completionConditions[completionCount++] = {
+                definition.effectDefinitionIndex,
+                definition.completionValueIndex,
+                definition.completionValue,
+                CompletionConditionState::present};
+        }
+    }
+    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);
+    return matches_derived(rebuilt, facts, definitions);
+}
+
 } // namespace sunrise::state::build_data::items::catalysts
 } // namespace sunrise::state::build_data::items::catalysts

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

@@ -29,6 +29,10 @@ struct Source {
     std::span<const socket_plugs::Rule> socketPlugRules;
     std::span<const socket_plugs::Rule> socketPlugRules;
     std::span<const socket_plugs::Pool> socketPlugPools;
     std::span<const socket_plugs::Pool> socketPlugPools;
     std::span<const socket_plugs::Member> socketPlugMembers;
     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;
 };
 };
 
 
 /** @return The generated facts pinned to Destiny 2 build 86657.20.08.23. */
 /** @return The generated facts pinned to Destiny 2 build 86657.20.08.23. */
@@ -67,4 +71,16 @@ struct Source {
                                    const Facts& facts,
                                    const Facts& facts,
                                    std::span<const Definition> definitions) noexcept;
                                    std::span<const Definition> definitions) noexcept;
 
 
+/**
+ * Rebuilds package-only transient relations from a stored catalog, then re-derives it.
+ * The stored acquisition row must still name an installed item. 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
 } // namespace sunrise::state::build_data::items::catalysts

+ 115 - 8
Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_catalog.cpp

@@ -42,6 +42,70 @@ find(std::span<const Definition> definitions, std::uint16_t itemDefinitionIndex)
                : nullptr;
                : nullptr;
 }
 }
 
 
+/** Sets one acquired-state slot and removes duplicate authored rows for that slot. */
+[[nodiscard]] bool upsert_acquisition(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, 2};
+                found = true;
+            }
+            continue;
+        }
+        family.flags[write++] = row;
+    }
+    if (!found) {
+        if (write >= family.flags.size()) {
+            return false;
+        }
+        family.flags[write++] = {slot, 2};
+    }
+    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;
+}
+
 } // namespace
 } // namespace
 
 
 void clear() noexcept {
 void clear() noexcept {
@@ -69,16 +133,24 @@ bool valid(std::span<const Definition> definitions) noexcept {
             definition.completedPlugDefinitionIndex != details::kUnavailableItemIndex;
             definition.completedPlugDefinitionIndex != details::kUnavailableItemIndex;
         const bool hasEffect =
         const bool hasEffect =
             definition.effectDefinitionIndex != details::kUnavailableItemIndex;
             definition.effectDefinitionIndex != details::kUnavailableItemIndex;
+        const bool hasAcquisition =
+            definition.acquisitionDefinitionIndex != kUnavailableAcquisitionIndex;
+        const bool hasCompletionValue =
+            definition.completionValueIndex != kUnavailableCompletionValueIndex;
         if (definition.itemDefinitionHash == 0
         if (definition.itemDefinitionHash == 0
             || definition.socketLane >= details::kInitialPlugCapacity
             || definition.socketLane >= details::kInitialPlugCapacity
-            || definition.reserved != 0
             || (definition.availability != Availability::released
             || (definition.availability != Availability::released
                 && definition.availability != Availability::placeholder
                 && definition.availability != Availability::placeholder
                 && definition.availability != Availability::unsupported)
                 && definition.availability != Availability::unsupported)
             || (definition.availability == Availability::unsupported
             || (definition.availability == Availability::unsupported
-                && (hasCompletedPlug || hasEffect))
+                && (hasCompletedPlug || hasEffect || hasAcquisition || hasCompletionValue
+                    || definition.completionValue != 0))
             || (definition.availability != Availability::unsupported
             || (definition.availability != Availability::unsupported
-                && (!hasCompletedPlug || !hasEffect))
+                && (!hasCompletedPlug || !hasEffect || !hasAcquisition))
+            || (hasCompletionValue != (definition.completionValue > 0))
+            || ((definition.completedPlugDefinitionIndex
+                 != definition.effectDefinitionIndex)
+                != hasCompletionValue)
             || (index != 0 && !less(definitions[index - 1], definition))) {
             || (index != 0 && !less(definitions[index - 1], definition))) {
             return false;
             return false;
         }
         }
@@ -124,7 +196,10 @@ Result resolve(std::uint16_t itemDefinitionIndex) noexcept {
             Availability::released,
             Availability::released,
             {definition->socketLane,
             {definition->socketLane,
              definition->completedPlugDefinitionIndex,
              definition->completedPlugDefinitionIndex,
-             definition->effectDefinitionIndex}};
+             definition->effectDefinitionIndex,
+             definition->acquisitionDefinitionIndex,
+             definition->completionValueIndex,
+             definition->completionValue}};
 }
 }
 
 
 std::uint16_t resolve_effect(std::uint16_t itemDefinitionIndex,
 std::uint16_t resolve_effect(std::uint16_t itemDefinitionIndex,
@@ -134,7 +209,8 @@ std::uint16_t resolve_effect(std::uint16_t itemDefinitionIndex,
     const Definition* definition = find(g_definitions.rows(), itemDefinitionIndex);
     const Definition* definition = find(g_definitions.rows(), itemDefinitionIndex);
     if (definition == nullptr || definition->availability != Availability::released
     if (definition == nullptr || definition->availability != Availability::released
         || definition->socketLane != socketLane
         || definition->socketLane != socketLane
-        || definition->completedPlugDefinitionIndex != plugDefinitionIndex
+        || (definition->completedPlugDefinitionIndex != plugDefinitionIndex
+            && definition->effectDefinitionIndex != plugDefinitionIndex)
         || definition->effectDefinitionIndex == details::kUnavailableItemIndex) {
         || definition->effectDefinitionIndex == details::kUnavailableItemIndex) {
         return plugDefinitionIndex;
         return plugDefinitionIndex;
     }
     }
@@ -160,18 +236,49 @@ ApplyResult apply_completed(std::uint16_t itemDefinitionIndex,
     if (result.error != Error::none
     if (result.error != Error::none
         || !account::inventory::valid_item_state(flags)
         || !account::inventory::valid_item_state(flags)
         || result.completed.socketLane >= plugs.size()
         || result.completed.socketLane >= plugs.size()
-        || result.completed.completedPlugDefinitionIndex == details::kUnavailableItemIndex) {
+        || result.completed.effectDefinitionIndex == details::kUnavailableItemIndex) {
         return ApplyResult::failed;
         return ApplyResult::failed;
     }
     }
 
 
     const std::uint32_t completedFlags = flags | account::inventory::kMasterworkItemFlag;
     const std::uint32_t completedFlags = flags | account::inventory::kMasterworkItemFlag;
-    const std::optional<std::uint16_t> completedPlug =
-        result.completed.completedPlugDefinitionIndex;
+    const std::optional<std::uint16_t> completedPlug = result.completed.effectDefinitionIndex;
     plugs[result.completed.socketLane] = completedPlug;
     plugs[result.completed.socketLane] = completedPlug;
     flags = completedFlags;
     flags = completedFlags;
     return ApplyResult::completed;
     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_acquisition(candidate, definition.acquisitionDefinitionIndex)) {
+            return false;
+        }
+
+        if (definition.completionValueIndex == kUnavailableCompletionValueIndex) {
+            continue;
+        }
+        if (!upsert_completion_value(candidate,
+                                     definition.completionValueIndex,
+                                     definition.completionValue)) {
+            return false;
+        }
+    }
+    family = candidate;
+    return true;
+}
+
 bool snapshot(std::span<Definition> output,
 bool snapshot(std::span<Definition> output,
               std::size_t& outputCount,
               std::size_t& outputCount,
               Report& outputReport) noexcept {
               Report& outputReport) noexcept {

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

@@ -5,6 +5,7 @@
 #include <optional>
 #include <optional>
 #include <span>
 #include <span>
 
 
+#include "../../../investment/investment.h"
 #include "definition.h"
 #include "definition.h"
 
 
 namespace sunrise::state::build_data::items::catalysts {
 namespace sunrise::state::build_data::items::catalysts {
@@ -69,6 +70,15 @@ apply_completed(std::uint16_t itemDefinitionIndex,
                 std::uint32_t& flags,
                 std::uint32_t& flags,
                 std::span<std::optional<std::uint16_t>> plugs) noexcept;
                 std::span<std::optional<std::uint16_t>> plugs) noexcept;
 
 
+/**
+ * Adds the acquired-state gates and legacy completion values for all 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;
+
 /**
 /**
  * Copies the complete catalog under its shared lock.
  * Copies the complete catalog under its shared lock.
  * @param output Caller-owned fixed catalog storage.
  * @param output Caller-owned fixed catalog storage.

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

@@ -7,6 +7,7 @@
 #include <span>
 #include <span>
 #include <string_view>
 #include <string_view>
 
 
+#include "../investment/investment.h"
 #include "abilities/definition.h"
 #include "abilities/definition.h"
 #include "collectibles/collectible_catalog.h"
 #include "collectibles/collectible_catalog.h"
 #include "constants/definition.h"
 #include "constants/definition.h"
@@ -202,6 +203,8 @@ publish_socket_plug_rules(std::span<const items::socket_plugs::Rule> rules,
  * @param socketPlugRules Complete item-and-lane rule table.
  * @param socketPlugRules Complete item-and-lane rule table.
  * @param socketPlugPools Complete interned plug-pool table.
  * @param socketPlugPools Complete interned plug-pool table.
  * @param socketPlugMembers Complete plug-pool member table.
  * @param socketPlugMembers Complete plug-pool member table.
+ * @param completionConditions Dense item-indexed catalyst completion expressions.
+ * @param acquisitionGates Dense socket-type-indexed acquired-state gates.
  * @param output Fixed storage that receives the catalyst catalog.
  * @param output Fixed storage that receives the catalyst catalog.
  * @param count Receives the used output row count.
  * @param count Receives the used output row count.
  * @param report Receives catalog counts and the first unsafe released relation.
  * @param report Receives catalog counts and the first unsafe released relation.
@@ -213,6 +216,8 @@ publish_socket_plug_rules(std::span<const items::socket_plugs::Rule> rules,
     std::span<const items::socket_plugs::Rule> socketPlugRules,
     std::span<const items::socket_plugs::Rule> socketPlugRules,
     std::span<const items::socket_plugs::Pool> socketPlugPools,
     std::span<const items::socket_plugs::Pool> socketPlugPools,
     std::span<const items::socket_plugs::Member> socketPlugMembers,
     std::span<const items::socket_plugs::Member> socketPlugMembers,
+    std::span<const items::catalysts::CompletionCondition> completionConditions,
+    std::span<const items::catalysts::AcquisitionGate> acquisitionGates,
     std::span<items::catalysts::Definition> output,
     std::span<items::catalysts::Definition> output,
     std::size_t& count,
     std::size_t& count,
     items::catalysts::Report& report) noexcept;
     items::catalysts::Report& report) noexcept;
@@ -235,6 +240,9 @@ publish_socket_plug_rules(std::span<const items::socket_plugs::Rule> rules,
     std::uint32_t& flags,
     std::uint32_t& flags,
     std::span<std::optional<std::uint16_t>> plugs) noexcept;
     std::span<std::optional<std::uint16_t>> plugs) noexcept;
 
 
+/** Adds all released catalyst acquisition and completion overrides to one Family-5 snapshot. */
+[[nodiscard]] bool complete_exotic_catalyst_investment(Family5State& family) noexcept;
+
 /**
 /**
  * Resolves the item row that supplies one socketed catalyst's native perks and stat changes.
  * Resolves the item row that supplies one socketed catalyst's native perks and stat changes.
  * @param itemDefinitionIndex Native weapon definition index.
  * @param itemDefinitionIndex Native weapon definition index.

+ 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. */
 /** @return A copy of the active account state, read under the lock. */
 [[nodiscard]] AccountState account_snapshot() noexcept;
 [[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
 } // namespace sunrise::state

+ 9 - 4
Sunrise/src/state/runtime/state_runtime.cpp

@@ -364,12 +364,17 @@ bool new_bap_session(BapState& output) noexcept {
     return true;
     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 {
+    output = {};
     AcquireSRWLockShared(&runtime::storage::g_stateLock);
     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);
     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
 } // namespace sunrise::state

+ 2 - 0
tests/Sunrise.Tests.vcxproj

@@ -71,6 +71,7 @@
     <ClCompile Include="exotic_catalyst_regression_tests.cpp" />
     <ClCompile Include="exotic_catalyst_regression_tests.cpp" />
     <ClCompile Include="loadout_catalyst_regression_tests.cpp" />
     <ClCompile Include="loadout_catalyst_regression_tests.cpp" />
     <ClCompile Include="opcode406_item_state_regression_tests.cpp" />
     <ClCompile Include="opcode406_item_state_regression_tests.cpp" />
+    <ClCompile Include="..\Sunrise\src\client\content\items\packages\package_catalyst_condition_reader.cpp" />
     <ClCompile Include="..\Sunrise\src\core\settings\json_number.cpp" />
     <ClCompile Include="..\Sunrise\src\core\settings\json_number.cpp" />
     <ClCompile Include="..\Sunrise\src\core\settings\json_scalar.cpp" />
     <ClCompile Include="..\Sunrise\src\core\settings\json_scalar.cpp" />
     <ClCompile Include="..\Sunrise\src\core\settings\json_structure.cpp" />
     <ClCompile Include="..\Sunrise\src\core\settings\json_structure.cpp" />
@@ -113,6 +114,7 @@
     <ClCompile Include="..\Sunrise\src\state\build_data\inventory\buckets\inventory_bucket_catalog.cpp" />
     <ClCompile Include="..\Sunrise\src\state\build_data\inventory\buckets\inventory_bucket_catalog.cpp" />
     <ClCompile Include="..\Sunrise\src\state\build_data\socket_entry_lists\socket_entry_list_catalog.cpp" />
     <ClCompile Include="..\Sunrise\src\state\build_data\socket_entry_lists\socket_entry_list_catalog.cpp" />
     <ClCompile Include="..\Sunrise\src\middleware\encoding\bit_reader.cpp" />
     <ClCompile Include="..\Sunrise\src\middleware\encoding\bit_reader.cpp" />
+    <ClCompile Include="..\Sunrise\src\middleware\content\packages\tables\definition_index_table.cpp" />
     <ClCompile Include="..\Sunrise\src\middleware\web_service\messages\opcode406_codec.cpp" />
     <ClCompile Include="..\Sunrise\src\middleware\web_service\messages\opcode406_codec.cpp" />
   </ItemGroup>
   </ItemGroup>
   <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
   <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />

+ 259 - 14
tests/exotic_catalyst_regression_tests.cpp

@@ -2,9 +2,12 @@
 #include <cstddef>
 #include <cstddef>
 #include <cstdint>
 #include <cstdint>
 #include <cstdio>
 #include <cstdio>
+#include <cstring>
 #include <optional>
 #include <optional>
 #include <span>
 #include <span>
 
 
+#include "client/content/items/packages/package_socket_plug_build.h"
+#include "middleware/content/packages/tables/definition_index_table.h"
 #include "state/account/inventory/item_state.h"
 #include "state/account/inventory/item_state.h"
 #include "state/build_data/cache/records/codec.h"
 #include "state/build_data/cache/records/codec.h"
 #include "state/build_data/items/catalysts/exotic_catalyst_builder.h"
 #include "state/build_data/items/catalysts/exotic_catalyst_builder.h"
@@ -16,6 +19,8 @@ namespace {
 
 
 namespace catalysts = sunrise::state::build_data::items::catalysts;
 namespace catalysts = sunrise::state::build_data::items::catalysts;
 namespace cache_records = sunrise::state::build_data::cache::records;
 namespace cache_records = sunrise::state::build_data::cache::records;
+namespace package_items = sunrise::client::content::items::packages;
+namespace package_tables = sunrise::middleware::content::packages::tables;
 namespace details = sunrise::state::build_data::items::details;
 namespace details = sunrise::state::build_data::items::details;
 namespace items = sunrise::state::build_data::items;
 namespace items = sunrise::state::build_data::items;
 namespace socket_plugs = sunrise::state::build_data::items::socket_plugs;
 namespace socket_plugs = sunrise::state::build_data::items::socket_plugs;
@@ -29,9 +34,16 @@ constexpr std::uint32_t kWorldlineProgress = 0x172766F5U;
 constexpr std::uint32_t kWorldlineComplete = 0x61935831U;
 constexpr std::uint32_t kWorldlineComplete = 0x61935831U;
 constexpr std::uint32_t kWorldlineEffect = 0x9F651900U;
 constexpr std::uint32_t kWorldlineEffect = 0x9F651900U;
 constexpr std::uint32_t kWorldlineCategory = 0x06989D65U;
 constexpr std::uint32_t kWorldlineCategory = 0x06989D65U;
+constexpr std::uint16_t kWorldlineSocketType = 447;
+constexpr std::uint16_t kWorldlineAcquisition = 5044;
+constexpr std::uint32_t kWorldlineAcquisitionHash = 0x12345678U;
+constexpr std::uint16_t kWorldlineCompletionValue = 3167;
+constexpr std::int32_t kWorldlineCompletionThreshold = 4;
 constexpr std::uint32_t kWitherhoard = 0x8C8180D6U;
 constexpr std::uint32_t kWitherhoard = 0x8C8180D6U;
 constexpr std::uint32_t kWitherhoardComplete = 0xAC29C6ACU;
 constexpr std::uint32_t kWitherhoardComplete = 0xAC29C6ACU;
 constexpr std::uint32_t kWitherhoardCategory = 0x1234ABCDU;
 constexpr std::uint32_t kWitherhoardCategory = 0x1234ABCDU;
+constexpr std::uint16_t kWitherhoardSocketType = 727;
+constexpr std::uint16_t kWitherhoardAcquisition = 11276;
 constexpr std::uint32_t kWishEnder = 0x3092080CU;
 constexpr std::uint32_t kWishEnder = 0x3092080CU;
 constexpr std::uint32_t kWishEnderActive = 0xCE1AD8BBU;
 constexpr std::uint32_t kWishEnderActive = 0xCE1AD8BBU;
 
 
@@ -42,6 +54,32 @@ void expect(bool value, const char* label) noexcept {
     }
     }
 }
 }
 
 
+template <typename Value>
+void write_bytes(std::span<std::byte> output,
+                 std::size_t offset,
+                 const Value& value) noexcept {
+    std::memcpy(output.data() + offset, &value, sizeof value);
+}
+
+void write_completion_expression(std::span<std::byte> output,
+                                 std::size_t descriptorOffset,
+                                 std::size_t headerOffset,
+                                 std::uint32_t valueIndex,
+                                 std::uint32_t value) noexcept {
+    constexpr std::uint64_t kTokenCount = 3;
+    constexpr std::uint32_t kHeaderMarker = 0x80800001U;
+    const std::int64_t relative = static_cast<std::int64_t>(headerOffset)
+                                  - static_cast<std::int64_t>(descriptorOffset + 8);
+    write_bytes(output, descriptorOffset, kTokenCount);
+    write_bytes(output, descriptorOffset + 8, relative);
+    write_bytes(output, headerOffset - 4, kHeaderMarker);
+    write_bytes(output, headerOffset, kTokenCount);
+    write_bytes(output, headerOffset + 8, package_tables::kInvestmentExpressionRowClass);
+    const std::array<std::uint32_t, 6> tokens{
+        10, valueIndex, 11, value, 14, UINT32_MAX};
+    std::memcpy(output.data() + headerOffset + 16, tokens.data(), sizeof tokens);
+}
+
 items::Definition item(std::uint16_t index,
 items::Definition item(std::uint16_t index,
                        std::uint32_t hash,
                        std::uint32_t hash,
                        items::Tier tier = items::Tier::none) noexcept {
                        items::Tier tier = items::Tier::none) noexcept {
@@ -55,7 +93,8 @@ items::Definition item(std::uint16_t index,
 details::Definition weapon_detail(std::uint16_t index,
 details::Definition weapon_detail(std::uint16_t index,
                                   std::uint32_t hash,
                                   std::uint32_t hash,
                                   std::uint8_t lane,
                                   std::uint8_t lane,
-                                  std::uint16_t defaultPlug) noexcept {
+                                  std::uint16_t defaultPlug,
+                                  std::uint16_t socketType) noexcept {
     details::Definition value{};
     details::Definition value{};
     value.definitionIndex = index;
     value.definitionIndex = index;
     value.definitionHash = hash;
     value.definitionHash = hash;
@@ -64,17 +103,29 @@ details::Definition weapon_detail(std::uint16_t index,
     value.ordinarySocketState = details::OrdinarySocketState::present;
     value.ordinarySocketState = details::OrdinarySocketState::present;
     value.ordinarySocketCount = static_cast<std::uint8_t>(lane + 1);
     value.ordinarySocketCount = static_cast<std::uint8_t>(lane + 1);
     value.initialPlugIndices[lane] = defaultPlug;
     value.initialPlugIndices[lane] = defaultPlug;
+    value.socketTypes[lane] = socketType;
     return value;
     return value;
 }
 }
 
 
 struct Fixture {
 struct Fixture {
-    std::array<items::Definition, 5> itemRows{};
+    std::array<items::Definition, 6> itemRows{};
     std::array<details::Definition, 2> detailRows{};
     std::array<details::Definition, 2> detailRows{};
     std::array<socket_plugs::Rule, 1> rules{{{10, 7, 0, 1}}};
     std::array<socket_plugs::Rule, 1> rules{{{10, 7, 0, 1}}};
     std::array<socket_plugs::Pool, 2> pools{{{0, 0}, {0, 3}}};
     std::array<socket_plugs::Pool, 2> pools{{{0, 0}, {0, 3}}};
     std::array<socket_plugs::Member, 3> members{{20, 21, 22}};
     std::array<socket_plugs::Member, 3> members{{20, 21, 22}};
     std::array<std::uint32_t, 1> legacy{{kWorldlineComplete}};
     std::array<std::uint32_t, 1> legacy{{kWorldlineComplete}};
     std::array<std::uint32_t, 1> released{{kWorldline}};
     std::array<std::uint32_t, 1> released{{kWorldline}};
+    std::array<catalysts::CompletionCondition, 1> completionConditions{{{
+        23,
+        kWorldlineCompletionValue,
+        kWorldlineCompletionThreshold,
+        catalysts::CompletionConditionState::present,
+    }}};
+    std::array<catalysts::AcquisitionGate, 1> acquisitionGates{{{
+        kWorldlineSocketType,
+        kWorldlineAcquisition,
+        catalysts::AcquisitionState::present,
+    }}};
     std::array<catalysts::Definition, catalysts::kDefinitionCapacity> output{};
     std::array<catalysts::Definition, catalysts::kDefinitionCapacity> output{};
 
 
     Fixture() noexcept {
     Fixture() noexcept {
@@ -82,11 +133,12 @@ struct Fixture {
                     item(20, kWorldlineDefault),
                     item(20, kWorldlineDefault),
                     item(21, kWorldlineProgress),
                     item(21, kWorldlineProgress),
                     item(22, kWorldlineComplete),
                     item(22, kWorldlineComplete),
-                    item(23, kWorldlineEffect, items::Tier::exotic)};
-        for (std::size_t index = 1; index < itemRows.size(); ++index) {
+                    item(23, kWorldlineEffect, items::Tier::exotic),
+                    item(kWorldlineAcquisition, kWorldlineAcquisitionHash)};
+        for (std::size_t index = 1; index < itemRows.size() - 1; ++index) {
             itemRows[index].plugCategoryHash = kWorldlineCategory;
             itemRows[index].plugCategoryHash = kWorldlineCategory;
         }
         }
-        detailRows[0] = weapon_detail(10, kWorldline, 7, 20);
+        detailRows[0] = weapon_detail(10, kWorldline, 7, 20, kWorldlineSocketType);
         detailRows[1].definitionIndex = 23;
         detailRows[1].definitionIndex = 23;
         detailRows[1].definitionHash = kWorldlineEffect;
         detailRows[1].definitionHash = kWorldlineEffect;
         detailRows[1].instancedDefinitionState =
         detailRows[1].instancedDefinitionState =
@@ -96,7 +148,14 @@ struct Fixture {
     }
     }
 
 
     [[nodiscard]] catalysts::Source source() const noexcept {
     [[nodiscard]] catalysts::Source source() const noexcept {
-        return {{kTimestamp, kImageSize, 0}, itemRows, detailRows, rules, pools, members};
+        return {{kTimestamp, kImageSize, 0},
+                itemRows,
+                detailRows,
+                rules,
+                pools,
+                members,
+                completionConditions,
+                acquisitionGates};
     }
     }
 
 
     [[nodiscard]] catalysts::Facts facts() const noexcept {
     [[nodiscard]] catalysts::Facts facts() const noexcept {
@@ -104,6 +163,44 @@ struct Fixture {
     }
     }
 };
 };
 
 
+void test_completion_expression_reader() noexcept {
+    std::array<std::byte, 192> definition{};
+    write_completion_expression(
+        definition, 0, 64, kWorldlineCompletionValue, kWorldlineCompletionThreshold);
+    catalysts::CompletionCondition condition{};
+    package_items::read_catalyst_completion_condition(definition, 23, condition);
+    expect(condition.itemDefinitionIndex == 23
+               && condition.valueIndex == kWorldlineCompletionValue
+               && condition.value == kWorldlineCompletionThreshold
+               && condition.state == catalysts::CompletionConditionState::present,
+           "native completion expression resolves");
+
+    write_completion_expression(
+        definition, 16, 128, kWorldlineCompletionValue, kWorldlineCompletionThreshold);
+    package_items::read_catalyst_completion_condition(definition, 23, condition);
+    expect(condition.state == catalysts::CompletionConditionState::present
+               && condition.valueIndex == kWorldlineCompletionValue
+               && condition.value == kWorldlineCompletionThreshold,
+           "duplicate native completion expressions remain one condition");
+
+    write_completion_expression(
+        definition, 16, 128, kWorldlineCompletionValue - 1, kWorldlineCompletionThreshold);
+    package_items::read_catalyst_completion_condition(definition, 23, condition);
+    expect(condition.state == catalysts::CompletionConditionState::ambiguous
+               && condition.valueIndex == catalysts::kUnavailableCompletionValueIndex
+               && condition.value == 0,
+           "distinct native completion expressions fail closed");
+
+    definition = {};
+    write_completion_expression(
+        definition, 0, 64, kWorldlineCompletionValue, kWorldlineCompletionThreshold);
+    constexpr std::uint32_t kWrongComparison = 13;
+    write_bytes(definition, 64 + 16 + 4 * sizeof(std::uint32_t), kWrongComparison);
+    package_items::read_catalyst_completion_condition(definition, 23, condition);
+    expect(condition.state == catalysts::CompletionConditionState::absent,
+           "non-completion postfix expressions stay absent");
+}
+
 void test_worldline_legacy_completion() noexcept {
 void test_worldline_legacy_completion() noexcept {
     Fixture fixture;
     Fixture fixture;
     std::size_t count = 0;
     std::size_t count = 0;
@@ -125,6 +222,11 @@ void test_worldline_legacy_completion() noexcept {
     expect(record.socketLane == 7, "Worldline resolves lane 7");
     expect(record.socketLane == 7, "Worldline resolves lane 7");
     expect(record.completedPlugDefinitionIndex == 22, "Worldline resolves completed plug");
     expect(record.completedPlugDefinitionIndex == 22, "Worldline resolves completed plug");
     expect(record.effectDefinitionIndex == 23, "Worldline resolves actual catalyst effect");
     expect(record.effectDefinitionIndex == 23, "Worldline resolves actual catalyst effect");
+    expect(record.acquisitionDefinitionIndex == kWorldlineAcquisition,
+           "Worldline resolves its acquired-state gate");
+    expect(record.completionValueIndex == kWorldlineCompletionValue
+               && record.completionValue == kWorldlineCompletionThreshold,
+           "Worldline resolves its native completion condition");
     expect(record.availability == catalysts::Availability::released,
     expect(record.availability == catalysts::Availability::released,
            "Worldline is released");
            "Worldline is released");
 
 
@@ -144,7 +246,7 @@ void test_worldline_legacy_completion() noexcept {
                "Worldline completion applies");
                "Worldline completion applies");
         expect(changedFlags == (flags | sunrise::state::account::inventory::kMasterworkItemFlag),
         expect(changedFlags == (flags | sunrise::state::account::inventory::kMasterworkItemFlag),
                "Worldline completion preserves item-state bits");
                "Worldline completion preserves item-state bits");
-        expect(plugs[7] == 22, "Worldline completion applies plug with flag");
+        expect(plugs[7] == 23, "Worldline completion applies native effect plug with flag");
     }
     }
     catalysts::set_completion_enabled(false);
     catalysts::set_completion_enabled(false);
     std::array<std::optional<std::uint16_t>, 12> disabledPlugs{};
     std::array<std::optional<std::uint16_t>, 12> disabledPlugs{};
@@ -159,6 +261,78 @@ void test_worldline_legacy_completion() noexcept {
     catalysts::set_completion_enabled(true);
     catalysts::set_completion_enabled(true);
 }
 }
 
 
+void test_investment_completion_is_atomic() noexcept {
+    Fixture fixture;
+    std::size_t count = 0;
+    catalysts::Report report{};
+    if (!catalysts::derive(fixture.source(), fixture.facts(), fixture.output, count, report)
+        || !catalysts::replace(std::span(fixture.output).first(count))) {
+        expect(false, "investment fixture publishes");
+        return;
+    }
+
+    sunrise::state::Family5State family{};
+    family.objectSoid = 0x4000000000000005ULL;
+    family.contentGateArm = true;
+    family.flags[0] = {77, 1};
+    family.flags[1] = {kWorldlineAcquisition, 0};
+    family.flags[2] = {kWorldlineAcquisition, 1};
+    family.flagCount = 3;
+    family.values[0] = {88, 7};
+    family.values[1] = {kWorldlineCompletionValue, 2};
+    family.values[2] = {kWorldlineCompletionValue, 9};
+    family.valueCount = 3;
+
+    expect(catalysts::append_investment_overrides(family),
+           "Worldline investment state completes");
+    expect(family.objectSoid == 0x4000000000000005ULL && family.contentGateArm,
+           "catalyst completion preserves Family 5 identity and arm state");
+    expect(family.flagCount == 2 && family.flags[0].slot == 77
+               && family.flags[0].value == 1
+               && family.flags[1].slot == kWorldlineAcquisition
+               && family.flags[1].value == 2,
+           "catalyst completion deduplicates and sets its acquired-state gate");
+    expect(family.valueCount == 2 && family.values[0].slot == 88
+               && family.values[0].value == 7
+               && family.values[1].slot == kWorldlineCompletionValue
+               && family.values[1].value == 9,
+           "catalyst completion deduplicates without lowering authored values");
+
+    catalysts::set_completion_enabled(false);
+    const std::size_t disabledFlagCount = family.flagCount;
+    const std::size_t disabledValueCount = family.valueCount;
+    const auto disabledFlags = family.flags;
+    const auto disabledValues = family.values;
+    expect(catalysts::append_investment_overrides(family),
+           "disabled catalyst investment completion succeeds without changes");
+    expect(family.flagCount == disabledFlagCount && family.valueCount == disabledValueCount
+               && family.flags[0].slot == disabledFlags[0].slot
+               && family.flags[0].value == disabledFlags[0].value
+               && family.values[0].slot == disabledValues[0].slot
+               && family.values[0].value == disabledValues[0].value,
+           "disabled catalyst investment completion leaves authored state unchanged");
+    catalysts::set_completion_enabled(true);
+
+    sunrise::state::Family5State full{};
+    full.flags[0] = {kWorldlineAcquisition, 0};
+    full.flagCount = 1;
+    full.valueCount = full.values.size();
+    for (std::size_t index = 0; index < full.valueCount; ++index) {
+        full.values[index] = {static_cast<std::uint16_t>(index),
+                              static_cast<std::int32_t>(index)};
+    }
+    const auto fullValues = full.values;
+    expect(!catalysts::append_investment_overrides(full),
+           "full completion-value bank rejects catalyst completion");
+    expect(full.flagCount == 1 && full.flags[0].value == 0
+               && full.valueCount == full.values.size()
+               && full.values[full.valueCount - 1].slot
+                      == fullValues[full.valueCount - 1].slot
+               && full.values[full.valueCount - 1].value
+                      == fullValues[full.valueCount - 1].value,
+           "failed investment completion rolls back both Family 5 banks");
+}
+
 void test_later_two_plug_and_placeholder() noexcept {
 void test_later_two_plug_and_placeholder() noexcept {
     std::array<items::Definition, 3> itemRows{
     std::array<items::Definition, 3> itemRows{
         item(10, kWitherhoard, items::Tier::exotic),
         item(10, kWitherhoard, items::Tier::exotic),
@@ -169,7 +343,7 @@ void test_later_two_plug_and_placeholder() noexcept {
     itemRows[2].plugCategoryHash = kWitherhoardCategory;
     itemRows[2].plugCategoryHash = kWitherhoardCategory;
     itemRows[2].tier = static_cast<std::uint8_t>(items::Tier::exotic);
     itemRows[2].tier = static_cast<std::uint8_t>(items::Tier::exotic);
     std::array<details::Definition, 2> detailRows{
     std::array<details::Definition, 2> detailRows{
-        weapon_detail(10, kWitherhoard, 10, 20),
+        weapon_detail(10, kWitherhoard, 10, 20, kWitherhoardSocketType),
         details::Definition{},
         details::Definition{},
     };
     };
     detailRows[1].definitionIndex = 21;
     detailRows[1].definitionIndex = 21;
@@ -181,17 +355,32 @@ void test_later_two_plug_and_placeholder() noexcept {
     std::array<socket_plugs::Pool, 2> pools{{{0, 0}, {0, 2}}};
     std::array<socket_plugs::Pool, 2> pools{{{0, 0}, {0, 2}}};
     std::array<socket_plugs::Member, 2> members{{20, 21}};
     std::array<socket_plugs::Member, 2> members{{20, 21}};
     std::array<std::uint32_t, 1> released{{kWitherhoard}};
     std::array<std::uint32_t, 1> released{{kWitherhoard}};
+    std::array<catalysts::AcquisitionGate, 1> acquisitionGates{{{
+        kWitherhoardSocketType,
+        kWitherhoardAcquisition,
+        catalysts::AcquisitionState::present,
+    }}};
     std::array<catalysts::Definition, catalysts::kDefinitionCapacity> output{};
     std::array<catalysts::Definition, catalysts::kDefinitionCapacity> output{};
     const catalysts::Facts facts{kTimestamp, kImageSize, kEmptyCatalyst, {}, released};
     const catalysts::Facts facts{kTimestamp, kImageSize, kEmptyCatalyst, {}, released};
     const catalysts::Source source{
     const catalysts::Source source{
-        {kTimestamp, kImageSize, 0}, itemRows, detailRows, rules, pools, members};
+        {kTimestamp, kImageSize, 0},
+        itemRows,
+        detailRows,
+        rules,
+        pools,
+        members,
+        {},
+        acquisitionGates};
     std::size_t count = 0;
     std::size_t count = 0;
     catalysts::Report report{};
     catalysts::Report report{};
     expect(catalysts::derive(source, facts, output, count, report),
     expect(catalysts::derive(source, facts, output, count, report),
            "Witherhoard catalog derives");
            "Witherhoard catalog derives");
     expect(count == 1 && output[0].socketLane == 10
     expect(count == 1 && output[0].socketLane == 10
                && output[0].completedPlugDefinitionIndex == 21
                && output[0].completedPlugDefinitionIndex == 21
-               && output[0].effectDefinitionIndex == 21,
+               && output[0].effectDefinitionIndex == 21
+               && output[0].acquisitionDefinitionIndex == kWitherhoardAcquisition
+               && output[0].completionValueIndex
+                      == catalysts::kUnavailableCompletionValueIndex,
            "Witherhoard resolves later lifecycle");
            "Witherhoard resolves later lifecycle");
 
 
     itemRows[0].definitionHash = kWishEnder;
     itemRows[0].definitionHash = kWishEnder;
@@ -268,6 +457,22 @@ void test_exclusions_and_errors() noexcept {
            "missing legacy catalyst effect fails");
            "missing legacy catalyst effect fails");
     fixture.itemRows[4].tier = static_cast<std::uint8_t>(items::Tier::exotic);
     fixture.itemRows[4].tier = static_cast<std::uint8_t>(items::Tier::exotic);
 
 
+    fixture.acquisitionGates[0].state = catalysts::AcquisitionState::ambiguous;
+    count = 0;
+    report = {};
+    expect(!catalysts::derive(fixture.source(), fixture.facts(), fixture.output, count, report)
+               && count == 0 && report.error == catalysts::Error::invalidAcquisition,
+           "unclear acquired-state gate fails a released catalyst");
+    fixture.acquisitionGates[0].state = catalysts::AcquisitionState::present;
+
+    fixture.completionConditions[0].state = catalysts::CompletionConditionState::ambiguous;
+    count = 0;
+    report = {};
+    expect(!catalysts::derive(fixture.source(), fixture.facts(), fixture.output, count, report)
+               && count == 0 && report.error == catalysts::Error::invalidCompletion,
+           "unclear completion expression fails a released catalyst");
+    fixture.completionConditions[0].state = catalysts::CompletionConditionState::present;
+
     catalysts::Source wrongBuild = fixture.source();
     catalysts::Source wrongBuild = fixture.source();
     wrongBuild.build.imageTimestamp ^= 1U;
     wrongBuild.build.imageTimestamp ^= 1U;
     count = 0;
     count = 0;
@@ -311,7 +516,15 @@ void test_unclear_placeholder_is_safe() noexcept {
 
 
 void test_cache_record_round_trip() noexcept {
 void test_cache_record_round_trip() noexcept {
     const catalysts::Definition released{
     const catalysts::Definition released{
-        kWorldline, 10, 22, 23, 7, catalysts::Availability::released, 0};
+        kWorldline,
+        10,
+        22,
+        23,
+        kWorldlineAcquisition,
+        kWorldlineCompletionValue,
+        7,
+        catalysts::Availability::released,
+        kWorldlineCompletionThreshold};
     cache_records::ExoticCatalystRecord record{};
     cache_records::ExoticCatalystRecord record{};
     catalysts::Definition decoded{};
     catalysts::Definition decoded{};
     expect(cache_records::encode(released, record), "released catalyst cache record encodes");
     expect(cache_records::encode(released, record), "released catalyst cache record encodes");
@@ -321,14 +534,20 @@ void test_cache_record_round_trip() noexcept {
                && decoded.completedPlugDefinitionIndex
                && decoded.completedPlugDefinitionIndex
                       == released.completedPlugDefinitionIndex
                       == released.completedPlugDefinitionIndex
                && decoded.effectDefinitionIndex == released.effectDefinitionIndex
                && decoded.effectDefinitionIndex == released.effectDefinitionIndex
+               && decoded.acquisitionDefinitionIndex
+                      == released.acquisitionDefinitionIndex
+               && decoded.completionValueIndex == released.completionValueIndex
                && decoded.socketLane == released.socketLane
                && decoded.socketLane == released.socketLane
-               && decoded.availability == released.availability && decoded.reserved == 0,
+               && decoded.availability == released.availability
+               && decoded.completionValue == released.completionValue,
            "released catalyst cache record round-trips");
            "released catalyst cache record round-trips");
 
 
     const catalysts::Definition unsupported{kWishEnder,
     const catalysts::Definition unsupported{kWishEnder,
                                              11,
                                              11,
                                              details::kUnavailableItemIndex,
                                              details::kUnavailableItemIndex,
                                              details::kUnavailableItemIndex,
                                              details::kUnavailableItemIndex,
+                                             catalysts::kUnavailableAcquisitionIndex,
+                                             catalysts::kUnavailableCompletionValueIndex,
                                              10,
                                              10,
                                              catalysts::Availability::unsupported,
                                              catalysts::Availability::unsupported,
                                              0};
                                              0};
@@ -339,13 +558,36 @@ void test_cache_record_round_trip() noexcept {
                && decoded.completedPlugDefinitionIndex == details::kUnavailableItemIndex
                && decoded.completedPlugDefinitionIndex == details::kUnavailableItemIndex
                && decoded.effectDefinitionIndex == details::kUnavailableItemIndex,
                && decoded.effectDefinitionIndex == details::kUnavailableItemIndex,
            "unsupported catalyst cache record round-trips");
            "unsupported catalyst cache record round-trips");
-    record.reserved = 1;
+    record.availability = 0xFFU;
     expect(!cache_records::decode(record, decoded),
     expect(!cache_records::decode(record, decoded),
            "noncanonical catalyst cache record is rejected");
            "noncanonical catalyst cache record is rejected");
-    expect(cache_records::kCacheFormatVersion == 46,
+    expect(cache_records::kCacheFormatVersion == 47,
            "catalyst records use the next cache format");
            "catalyst records use the next cache format");
 }
 }
 
 
+void test_cached_catalog_revalidation() noexcept {
+    Fixture fixture;
+    std::size_t count = 0;
+    catalysts::Report report{};
+    if (!catalysts::derive(fixture.source(), fixture.facts(), fixture.output, count, report)) {
+        expect(false, "cache validation fixture derives");
+        return;
+    }
+    catalysts::Source cachedSource = fixture.source();
+    cachedSource.completionConditions = {};
+    cachedSource.acquisitionGates = {};
+    expect(catalysts::matches_cached(cachedSource,
+                                     fixture.facts(),
+                                     std::span(fixture.output).first(count)),
+           "cached catalyst catalog re-derives from stored relations");
+
+    fixture.output[0].acquisitionDefinitionIndex = kWorldlineAcquisition - 1;
+    expect(!catalysts::matches_cached(cachedSource,
+                                      fixture.facts(),
+                                      std::span(fixture.output).first(count)),
+           "cached catalyst catalog rejects an absent acquisition item");
+}
+
 void test_atomic_rollback() noexcept {
 void test_atomic_rollback() noexcept {
     Fixture fixture;
     Fixture fixture;
     std::size_t count = 0;
     std::size_t count = 0;
@@ -375,11 +617,14 @@ void test_atomic_rollback() noexcept {
 } // namespace
 } // namespace
 
 
 void test_exotic_catalysts() noexcept {
 void test_exotic_catalysts() noexcept {
+    test_completion_expression_reader();
     test_worldline_legacy_completion();
     test_worldline_legacy_completion();
+    test_investment_completion_is_atomic();
     test_later_two_plug_and_placeholder();
     test_later_two_plug_and_placeholder();
     test_exclusions_and_errors();
     test_exclusions_and_errors();
     test_unclear_placeholder_is_safe();
     test_unclear_placeholder_is_safe();
     test_atomic_rollback();
     test_atomic_rollback();
     test_cache_record_round_trip();
     test_cache_record_round_trip();
+    test_cached_catalog_revalidation();
     catalysts::clear();
     catalysts::clear();
 }
 }

+ 11 - 3
tests/loadout_catalyst_regression_tests.cpp

@@ -157,7 +157,15 @@ void test_resolved_catalyst_output() noexcept {
         socket_lists::Definition{0x12345678U, 0, 0, 0},
         socket_lists::Definition{0x12345678U, 0, 0, 0},
     };
     };
     const std::array catalystRows{
     const std::array catalystRows{
-        catalysts::Definition{kWeaponHash, 0, 3, 4, 7, catalysts::Availability::released, 0},
+        catalysts::Definition{kWeaponHash,
+                              0,
+                              3,
+                              4,
+                              500,
+                              600,
+                              7,
+                              catalysts::Availability::released,
+                              4},
     };
     };
 
 
     const bool ready = items::replace(itemRows) && details::replace(detailRows)
     const bool ready = items::replace(itemRows) && details::replace(detailRows)
@@ -182,8 +190,8 @@ void test_resolved_catalyst_output() noexcept {
                authored, character, itemRows.size(), socketListRows.size(), output),
                authored, character, itemRows.size(), socketListRows.size(), output),
            "resolved client item accepts catalyst completion");
            "resolved client item accepts catalyst completion");
     expect(output.item.flags == 7, "resolved client item carries all item-state bits");
     expect(output.item.flags == 7, "resolved client item carries all item-state bits");
-    expect(output.item.instance.ordinarySockets.plugs[7] == 3,
-           "resolved client item carries the completed catalyst plug");
+    expect(output.item.instance.ordinarySockets.plugs[7] == 4,
+           "resolved client item carries the native catalyst effect plug");
 
 
     loadout::ResolvedInstances instances{};
     loadout::ResolvedInstances instances{};
     instances.itemCount = 1;
     instances.itemCount = 1;